UNPKG

@atlaskit/editor-plugin-paste-options-toolbar

Version:

Paste options toolbar for @atlaskit/editor-core

386 lines (371 loc) 20.8 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); var _typeof = require("@babel/runtime/helpers/typeof"); Object.defineProperty(exports, "__esModule", { value: true }); exports.PasteActionsMenu = void 0; exports.findBlockAncestorDOM = findBlockAncestorDOM; exports.getTargetElement = getTargetElement; exports.getVisualEndBottom = getVisualEndBottom; exports.onInlinePositionCalculated = onInlinePositionCalculated; exports.onPositionCalculated = onPositionCalculated; exports.resolveTableAfterPos = resolveTableAfterPos; var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _react = _interopRequireWildcard(require("react")); var _analytics = require("@atlaskit/editor-common/analytics"); var _hooks = require("@atlaskit/editor-common/hooks"); var _toolbar = require("@atlaskit/editor-common/toolbar"); var _ui = require("@atlaskit/editor-common/ui"); var _uiReact = require("@atlaskit/editor-common/ui-react"); var _utils = require("@atlaskit/editor-prosemirror/utils"); var _editorSharedStyles = require("@atlaskit/editor-shared-styles"); var _editorToolbar = require("@atlaskit/editor-toolbar"); var _commands = require("../../editor-commands/commands"); var _constants = require("../../pm-plugins/constants"); var _types = require("../../types/types"); var _toolbar2 = require("../toolbar"); var _hasVisibleButton = require("./hasVisibleButton"); var _PasteActionsMenuContent = require("./PasteActionsMenuContent"); function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != _typeof(e) && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (var _t in e) "default" !== _t && {}.hasOwnProperty.call(e, _t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, _t)) && (i.get || i.set) ? o(f, _t, i) : f[_t] = e[_t]); return f; })(e, t); } function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } var PopupWithListeners = (0, _uiReact.withReactEditorViewOuterListeners)(_ui.Popup); /** * Returns the DOM element at the given document position for use as a Popup anchor. * For empty blocks (BR elements), returns the parent element to ensure correct positioning. */ function getTargetElement(editorView, pos) { try { var domRef = (0, _utils.findDomRefAtPos)(pos, editorView.domAtPos.bind(editorView)); if (domRef instanceof HTMLElement) { // Empty blocks render a <br> placeholder whose bounding rect has no // meaningful dimensions (height ≈ 0). Using it as the Popup anchor // causes the menu to appear at an unexpected position. Walk up to the // parent block element so the Popup anchors correctly. if (domRef.nodeName === 'BR' && domRef.parentElement) { return domRef.parentElement; } return domRef; } return null; } catch (_unused) { return null; } } /** * Returns the position immediately after a table ancestor of `pos`, or * `undefined` if not inside a table. Safe to cache per document version. */ function resolveTableAfterPos(editorView, pos) { var $pos = editorView.state.doc.resolve(pos); for (var depth = $pos.depth; depth > 0; depth--) { if ($pos.node(depth).type.name === 'table') { return $pos.after(depth); } } return undefined; } /** * Returns the visual bottom of the pasted content. For positions inside a * table, uses the pre-computed `tableAfterPos` to get the correct bottom edge. */ function getVisualEndBottom(editorView, pasteEndPos, tableAfterPos) { var endCoords = editorView.coordsAtPos(pasteEndPos); var bottom = endCoords.bottom; if (tableAfterPos !== undefined) { var afterCoords = editorView.coordsAtPos(tableAfterPos); bottom = Math.max(bottom, afterCoords.bottom); } return bottom; } /** * Finds the DOM element for the nearest block-level ProseMirror ancestor of * the given document position. Uses ProseMirror's schema (`node.isBlock`) * rather than CSS display properties, so the check is always in sync with the * document model. * * Returns `null` if no block ancestor can be resolved to a DOM element. */ function findBlockAncestorDOM(editorView, pos) { try { var $pos = editorView.state.doc.resolve(pos); // Walk up the document tree from the resolved position's innermost // node towards the root. $pos.node(depth) gives the ancestor at each // depth; $pos.start(depth) gives the position just inside that ancestor, // so `$pos.start(depth) - 1` is the position of the ancestor node itself // (which is what nodeDOM expects). for (var depth = $pos.depth; depth >= 0; depth--) { var node = $pos.node(depth); if (node.isBlock) { var domNode = editorView.nodeDOM($pos.start(depth) - 1); if (domNode instanceof HTMLElement) { return domNode; } // depth 0 is the doc node — nodeDOM(–1) won't work, so try // the editor's own DOM element as a fallback. if (depth === 0 && editorView.dom instanceof HTMLElement) { return editorView.dom; } } } } catch (_unused2) { // Position may be out of range after a concurrent edit — fall through. } return null; } /** * Positions the paste menu inline, immediately to the right of the cursor * at the paste end position, vertically centered with the line. * Used for short pastes without AI actions. */ function onInlinePositionCalculated(editorView, pasteEndPos, targetElement, popupContentRef) { return function (position) { var _popupContentRef$curr, _popupContentRef$curr2, _position$top, _position$left; var endCoords = editorView.coordsAtPos(pasteEndPos); var targetRect = targetElement.getBoundingClientRect(); // Vertical: center the menu with the line at the paste end position. var lineHeight = endCoords.bottom - endCoords.top; var lineMidpoint = endCoords.top + lineHeight / 2; var menuHeight = (_popupContentRef$curr = (_popupContentRef$curr2 = popupContentRef.current) === null || _popupContentRef$curr2 === void 0 ? void 0 : _popupContentRef$curr2.getBoundingClientRect().height) !== null && _popupContentRef$curr !== void 0 ? _popupContentRef$curr : lineHeight; var menuTop = lineMidpoint - menuHeight / 2; var topDelta = menuTop - (targetRect.top + targetRect.height); var adjustedTop = ((_position$top = position.top) !== null && _position$top !== void 0 ? _position$top : 0) + topDelta; // Horizontal: position to the right of the cursor var leftDelta = endCoords.right - targetRect.right; var adjustedLeft = ((_position$left = position.left) !== null && _position$left !== void 0 ? _position$left : 0) + leftDelta; return _objectSpread(_objectSpread({}, position), {}, { top: adjustedTop, left: adjustedLeft }); }; } /** * Adjusts the position of the paste menu so that: * * **Vertical:** The menu aligns with the top of the pasted content using the * exact coordinates at the paste start position, and sticks to the top of the * scroll container when the pasted content scrolls above the visible area. * * The Popup uses alignY="bottom", which positions the popup below the target * element's bottom edge. This override: * * 1. Shifts the popup from the target's bottom edge to align with the paste * start position. * 2. When the paste start scrolls above the scroll container, clamps the menu * to the scroll container's top edge (sticky-top). * 3. Stops sticking once the entire pasted range (pasteEndPos) has scrolled * above the visible area. * * **Horizontal:** When the target element is an inline element (e.g. a mark * wrapper like `<strong>`, or an inline node like an emoji), the Popup's * `alignX="end"` would place the menu at the right edge of that narrow * element. This override resolves the nearest block-level ProseMirror * ancestor (using `node.isBlock` from the document schema) and re-anchors * the horizontal position to its right edge, so the menu consistently * appears at the right side of the content area. */ function onPositionCalculated(editorView, pasteStartPos, pasteEndPos, targetElement, scrollableElement) { // Pre-compute once per render to avoid doc.resolve() on every scroll frame. var tableAfterPos = resolveTableAfterPos(editorView, pasteEndPos); var blockAncestorDOM = findBlockAncestorDOM(editorView, pasteStartPos); return function (position) { var _position$top2; var startCoords = editorView.coordsAtPos(pasteStartPos); var endBottom = getVisualEndBottom(editorView, pasteEndPos, tableAfterPos); var targetRect = targetElement.getBoundingClientRect(); // ── Vertical adjustment ────────────────────────────────────────── // The Popup places the menu at the target's bottom edge by default. // We shift it up so it aligns with the paste start position. // Both coordinates are in viewport space, so the delta is offset-parent agnostic. var topDelta = startCoords.top - (targetRect.top + targetRect.height); var adjustedTop = ((_position$top2 = position.top) !== null && _position$top2 !== void 0 ? _position$top2 : 0) + topDelta; // Sticky-top: clamp to the scroll container's top edge when the paste // start has scrolled above the visible area, but only while some pasted // content is still visible. if (scrollableElement) { var scrollContainerTop = scrollableElement.getBoundingClientRect().top; if (startCoords.top < scrollContainerTop && endBottom > scrollContainerTop) { adjustedTop += scrollContainerTop - startCoords.top + _constants.PASTE_MENU_GAP_TOP; } } // ── Horizontal adjustment ──────────────────────────────────────── // When pasted content starts with a mark (bold, italic, link …) or // an inline node (emoji, smart link, inline image …), // findDomRefAtPos returns the narrow inline wrapper element. The // Popup's alignX="end" then places the menu at that element's right // edge instead of the content area's right edge. We correct this by // resolving the nearest block-level ProseMirror ancestor and // re-anchoring to its right edge. var adjustedLeft = position.left; if (blockAncestorDOM && blockAncestorDOM !== targetElement) { var _position$left2; var blockRect = blockAncestorDOM.getBoundingClientRect(); // Shift left by the difference between the block's right edge and // the inline target's right edge. This mirrors what alignX="end" // would have computed if the target were the block element. var leftDelta = blockRect.right - targetRect.right; adjustedLeft = ((_position$left2 = position.left) !== null && _position$left2 !== void 0 ? _position$left2 : 0) + leftDelta; } return _objectSpread(_objectSpread({}, position), {}, { top: adjustedTop, left: adjustedLeft }); }; } var PasteActionsMenu = exports.PasteActionsMenu = function PasteActionsMenu(_ref) { var _api$analytics, _api$uiControlRegistr, _api$uiControlRegistr2; var api = _ref.api, editorView = _ref.editorView, mountTo = _ref.mountTo, boundariesElement = _ref.boundariesElement, scrollableElement = _ref.scrollableElement; var editorAnalyticsAPI = api === null || api === void 0 || (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions; var _useSharedPluginState = (0, _hooks.useSharedPluginStateWithSelector)(api, ['paste'], function (states) { var _states$pasteState; return { lastContentPasted: (_states$pasteState = states.pasteState) === null || _states$pasteState === void 0 ? void 0 : _states$pasteState.lastContentPasted }; }), lastContentPasted = _useSharedPluginState.lastContentPasted; var prevShowToolbarRef = (0, _react.useRef)(false); var popupContentRef = (0, _react.useRef)(null); (0, _react.useEffect)(function () { if (!lastContentPasted) { (0, _commands.hideToolbar)()(editorView.state, editorView.dispatch); return; } var selectedOption = _types.ToolbarDropdownOption.None; if (!lastContentPasted.isPlainText) { selectedOption = _types.ToolbarDropdownOption.RichText; } else if (lastContentPasted.isShiftPressed) { selectedOption = _types.ToolbarDropdownOption.PlainText; } else { selectedOption = _types.ToolbarDropdownOption.Markdown; } var $pos = editorView.state.doc.resolve(lastContentPasted.pasteStartPos); var pasteAncestorNodeNames = []; for (var depth = $pos.depth; depth > 0; depth--) { // Only include an ancestor if the entire pasted range is contained within it. // This prevents nodes like 'heading' from being flagged as ancestors when the // pasted content starts in a heading but extends beyond it (e.g. heading + paragraph). if (lastContentPasted.pasteEndPos <= $pos.end(depth)) { pasteAncestorNodeNames.push($pos.node(depth).type.name); } } var legacyVisible = (0, _toolbar2.isToolbarVisible)(editorView.state, lastContentPasted); (0, _commands.showToolbar)(lastContentPasted, selectedOption, legacyVisible, pasteAncestorNodeNames)(editorView.state, editorView.dispatch); }, [lastContentPasted, editorView]); var _useSharedPluginState2 = (0, _hooks.useSharedPluginStateWithSelector)(api, ['pasteOptionsToolbarPlugin'], function (states) { var _pluginState$showTool, _pluginState$pasteSta, _pluginState$pasteEnd; var pluginState = states.pasteOptionsToolbarPluginState; return { showToolbar: (_pluginState$showTool = pluginState === null || pluginState === void 0 ? void 0 : pluginState.showToolbar) !== null && _pluginState$showTool !== void 0 ? _pluginState$showTool : false, pasteStartPos: (_pluginState$pasteSta = pluginState === null || pluginState === void 0 ? void 0 : pluginState.pasteStartPos) !== null && _pluginState$pasteSta !== void 0 ? _pluginState$pasteSta : 0, pasteEndPos: (_pluginState$pasteEnd = pluginState === null || pluginState === void 0 ? void 0 : pluginState.pasteEndPos) !== null && _pluginState$pasteEnd !== void 0 ? _pluginState$pasteEnd : 0 }; }), isToolbarShown = _useSharedPluginState2.showToolbar, pasteStartPos = _useSharedPluginState2.pasteStartPos, pasteEndPos = _useSharedPluginState2.pasteEndPos; var preventEditorFocusLoss = (0, _react.useCallback)(function (e) { e.preventDefault(); }, []); var handleDismiss = (0, _react.useCallback)(function () { (0, _commands.hideToolbar)()(editorView.state, editorView.dispatch); }, [editorView]); var handleMouseEnter = (0, _react.useCallback)(function () { (0, _commands.highlightContent)()(editorView.state, editorView.dispatch); }, [editorView]); var handleClickOutside = (0, _react.useCallback)(function (evt) { if (evt.target instanceof Element) { var isInsideNestedDropdown = evt.target.closest('[data-toolbar-nested-dropdown-menu]'); if (isInsideNestedDropdown) { return; } } handleDismiss(); }, [handleDismiss]); var handleSetIsOpen = (0, _react.useCallback)(function (isOpen) { if (!isOpen) { handleDismiss(); } }, [handleDismiss]); // Find the actual scroll container using the same utility the Popup's // stick prop uses internally. We pass this as the scrollableElement prop // so the Popup attaches its built-in scroll listener, which calls // scheduledUpdatePosition (RAF-throttled) on each scroll event — triggering // onPositionCalculated with fresh viewport coordinates. var overflowScrollParent = isToolbarShown ? (0, _ui.findOverflowScrollParent)(editorView.dom) : false; var effectiveScrollableElement = overflowScrollParent || scrollableElement; var pasteMenuComponents = (_api$uiControlRegistr = api === null || api === void 0 || (_api$uiControlRegistr2 = api.uiControlRegistry) === null || _api$uiControlRegistr2 === void 0 ? void 0 : _api$uiControlRegistr2.actions.getComponents(_toolbar.PASTE_MENU.key)) !== null && _api$uiControlRegistr !== void 0 ? _api$uiControlRegistr : []; var anyComponentVisible = (0, _hasVisibleButton.hasVisibleButton)(pasteMenuComponents); // eslint-disable-next-line @atlassian/perf-linting/no-expensive-computations-in-render -- pasteMenuComponents changes by reference each render; filter is small (< 10 items) var aiMenuItems = pasteMenuComponents.filter(function (c) { var _c$parents; return c.type === 'menu-item' && ((_c$parents = c.parents) === null || _c$parents === void 0 ? void 0 : _c$parents.some(function (p) { return p.key === _toolbar.AI_PASTE_MENU_SECTION.key; })); }); var visibleAiActionKeys = (0, _hasVisibleButton.getVisibleKeys)(aiMenuItems, ['menu-item']); // Two positioning modes: // 1. Inline: no AI actions visible — menu appears to the right of the cursor, // vertically centered with the text line. // 2. Block-anchored: AI actions are visible — menu appears at the right edge // of the content block, aligned with paste start. var hasVisibleAiActions = visibleAiActionKeys.length > 0; (0, _react.useEffect)(function () { if (!prevShowToolbarRef.current && isToolbarShown) { editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 || editorAnalyticsAPI.fireAnalyticsEvent({ action: _analytics.ACTION.OPENED, actionSubject: _analytics.ACTION_SUBJECT.PASTE_ACTIONS_MENU, eventType: _analytics.EVENT_TYPE.UI, attributes: { visibleAiActions: visibleAiActionKeys } }); } prevShowToolbarRef.current = isToolbarShown; // eslint-disable-next-line react-hooks/exhaustive-deps }, [isToolbarShown, editorAnalyticsAPI]); var useInlinePosition = !hasVisibleAiActions; if (!isToolbarShown) { return null; } if (!anyComponentVisible) { return null; } var target = getTargetElement(editorView, useInlinePosition ? pasteEndPos : pasteStartPos); if (!target) { return null; } // Choose positioning strategy based on whether the menu appears inline // (right of cursor for short pastes) or anchored to the block ancestor // (right side of content area for longer pastes / AI actions). var positionCalculator = useInlinePosition ? onInlinePositionCalculated(editorView, pasteEndPos, target, popupContentRef) : onPositionCalculated(editorView, pasteStartPos, pasteEndPos, target, effectiveScrollableElement); return /*#__PURE__*/_react.default.createElement(PopupWithListeners, { target: target, mountTo: mountTo, boundariesElement: boundariesElement, scrollableElement: effectiveScrollableElement, minPopupMargin: _constants.PASTE_MENU_GAP_HORIZONTAL, zIndex: _editorSharedStyles.akEditorFloatingPanelZIndex, alignX: "end", alignY: useInlinePosition ? 'top' : 'bottom' /* eslint-disable-next-line @atlassian/perf-linting/no-unstable-inline-props -- Ignored via go/ees017 (to be fixed) */, offset: [_constants.PASTE_MENU_GAP_HORIZONTAL, 0], onPositionCalculated: positionCalculator, handleClickOutside: handleClickOutside, handleEscapeKeydown: handleDismiss }, /*#__PURE__*/_react.default.createElement(_toolbar.EditorToolbarProvider, { editorView: editorView }, /*#__PURE__*/_react.default.createElement(_editorToolbar.ToolbarDropdownMenuProvider, { isOpen: isToolbarShown, setIsOpen: handleSetIsOpen }, /*#__PURE__*/_react.default.createElement(_PasteActionsMenuContent.PasteActionsMenuContent, { onMouseDown: preventEditorFocusLoss, onMouseEnter: handleMouseEnter, components: pasteMenuComponents, contentRef: popupContentRef })))); };