@atlaskit/editor-plugin-paste-options-toolbar
Version:
Paste options toolbar for @atlaskit/editor-core
386 lines (371 loc) • 20.8 kB
JavaScript
"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
}))));
};