UNPKG

@atlaskit/editor-plugin-block-menu

Version:

BlockMenu plugin for @atlaskit/editor-core

252 lines (240 loc) • 12.5 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.scheduleBlockMenuPositionFix = exports.getScrollDistanceToRevealAnchor = exports.getBlockMenuPositionSnapshot = exports.getBlockMenuAnchorMetrics = exports.fixBlockMenuPositionAndScroll = void 0; var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _browserApis = require("@atlaskit/browser-apis"); var _key = require("@atlaskit/editor-common/block-menu/key"); var _dragHandle = require("@atlaskit/editor-common/styles/drag-handle"); 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 POPUP_WRAPPER_TEST_ID = 'popup-wrapper'; var EDITOR_CONTENT_CONTAINER_SELECTOR = '[data-testid="editor-content-container"]'; /** * Breathing room left between the revealed block and the fold it just crossed, so the block does * not end up flush against the edge of the viewport. */ var REVEAL_MARGIN = 8; /** * Where the drag handle and the popup sit relative to their block. The drag handle is removed from * the DOM as soon as the pointer moves onto the block menu, so this has to be measured while the * menu opens rather than when a menu item is clicked. */ /** * Minimum scroll needed to bring the drag handle back inside the viewport. * Zero means the handle is already fully visible. */ var getScrollDistanceToRevealAnchor = exports.getScrollDistanceToRevealAnchor = function getScrollDistanceToRevealAnchor(dragHandleTop, dragHandleHeight, viewport) { if (dragHandleTop < viewport.top) { return dragHandleTop - viewport.top; } var dragHandleBottom = dragHandleTop + dragHandleHeight; if (dragHandleBottom > viewport.bottom) { return dragHandleBottom - viewport.bottom; } return 0; }; /** * The editor content container is not always the element that actually scrolls - depending on the * product it can be an ancestor, or the document itself. Pick the closest one that really scrolls. */ var getScrollContainer = function getScrollContainer(doc, selectedBlock) { var editorContentContainer = doc.querySelector(EDITOR_CONTENT_CONTAINER_SELECTOR); if (editorContentContainer && editorContentContainer.scrollHeight > editorContentContainer.clientHeight) { return editorContentContainer; } var ancestor = selectedBlock.parentElement; while (ancestor) { var _doc$defaultView; var overflowY = (_doc$defaultView = doc.defaultView) === null || _doc$defaultView === void 0 ? void 0 : _doc$defaultView.getComputedStyle(ancestor).overflowY; if ((overflowY === 'auto' || overflowY === 'scroll') && ancestor.scrollHeight > ancestor.clientHeight) { return ancestor; } ancestor = ancestor.parentElement; } return doc.scrollingElement; }; var getScrollViewport = function getScrollViewport(doc, scrollContainer) { if (scrollContainer === doc.scrollingElement) { var _doc$defaultView$inne, _doc$defaultView2; return { bottom: (_doc$defaultView$inne = (_doc$defaultView2 = doc.defaultView) === null || _doc$defaultView2 === void 0 ? void 0 : _doc$defaultView2.innerHeight) !== null && _doc$defaultView$inne !== void 0 ? _doc$defaultView$inne : 0, top: 0 }; } var _scrollContainer$getB = scrollContainer.getBoundingClientRect(), top = _scrollContainer$getB.top, bottom = _scrollContainer$getB.bottom; return { bottom: bottom, top: top }; }; /** * Float based node views - a media group is one - collapse their top level wrapper to zero height, * so the visual bottom of the block has to come from its children. */ var getBlockBottom = function getBlockBottom(block) { var rect = block.getBoundingClientRect(); if (rect.height > 0) { return rect.bottom; } return Array.from(block.children).reduce(function (bottom, child) { return Math.max(bottom, child.getBoundingClientRect().bottom); }, rect.bottom); }; var setPopupTop = function setPopupTop(blockMenuEl, targetTop) { var _blockMenuEl$closest; var popupWrapper = (_blockMenuEl$closest = blockMenuEl.closest("[data-testid=\"".concat(POPUP_WRAPPER_TEST_ID, "\"]"))) !== null && _blockMenuEl$closest !== void 0 ? _blockMenuEl$closest : blockMenuEl.parentElement; if (!(popupWrapper instanceof HTMLElement)) { return; } var distance = blockMenuEl.getBoundingClientRect().top - targetTop; var hasTopProperty = popupWrapper.style.top !== ''; var hasBottomProperty = popupWrapper.style.bottom !== ''; if (hasBottomProperty && !hasTopProperty) { popupWrapper.style.bottom = "".concat(parseFloat(popupWrapper.style.bottom || '0') + distance, "px"); } else { popupWrapper.style.top = "".concat(parseFloat(popupWrapper.style.top || '0') - distance, "px"); } }; /** * Measured while the menu opens, when the popup is still aligned to the drag handle. Moves that * keep the handle visible deliberately leave the popup where it is, so this alignment - not the * drifted one - is what the popup is restored to when a fold is crossed. */ var getBlockMenuAnchorMetrics = exports.getBlockMenuAnchorMetrics = function getBlockMenuAnchorMetrics(selectedBlock) { var _doc$querySelector; var doc = (0, _browserApis.getDocument)(); var blockMenuEl = doc === null || doc === void 0 ? void 0 : doc.querySelector("[data-testid=\"".concat(_key.BLOCK_MENU_TEST_ID, "\"]")); if (!doc || !selectedBlock || !blockMenuEl) { return; } var blockMenuRect = blockMenuEl.getBoundingClientRect(); var dragHandleRect = (_doc$querySelector = doc.querySelector(_dragHandle.DRAG_HANDLE_SELECTOR)) === null || _doc$querySelector === void 0 ? void 0 : _doc$querySelector.getBoundingClientRect(); if (blockMenuRect.height === 0 || !(dragHandleRect !== null && dragHandleRect !== void 0 && dragHandleRect.height)) { return; } var blockTop = selectedBlock.getBoundingClientRect().top; return { blockHeight: getBlockBottom(selectedBlock) - blockTop, dragHandleHeight: dragHandleRect.height, dragHandleOffsetFromBlock: dragHandleRect.top - blockTop, popupOffsetFromBlock: blockMenuRect.top - blockTop, popupHeight: blockMenuRect.height }; }; /** * Captured before the move transaction, so the popup can be realigned to the block after it. */ var getBlockMenuPositionSnapshot = exports.getBlockMenuPositionSnapshot = function getBlockMenuPositionSnapshot(selectedBlock, anchorMetrics) { var doc = (0, _browserApis.getDocument)(); if (!doc || !selectedBlock) { return; } var scrollContainer = getScrollContainer(doc, selectedBlock); var metrics = anchorMetrics !== null && anchorMetrics !== void 0 ? anchorMetrics : getBlockMenuAnchorMetrics(selectedBlock); if (!scrollContainer || !metrics) { return; } return _objectSpread(_objectSpread({}, metrics), {}, { scrollContainer: scrollContainer, scrollTop: scrollContainer.scrollTop }); }; /** * Called after a move up/down transaction. * * With a `positionSnapshot` the popup is only sticky when it has to be: the editor stays put while * the moved node's drag handle is still visible, and scrolls the minimum needed to reveal the * handle otherwise - realigning the popup to the revealed handle. */ var fixBlockMenuPositionAndScroll = exports.fixBlockMenuPositionAndScroll = function fixBlockMenuPositionAndScroll(selectedNode, positionSnapshot) { var doc = (0, _browserApis.getDocument)(); if (!doc) { return; } var blockMenuEl = doc.querySelector("[data-testid=\"".concat(_key.BLOCK_MENU_TEST_ID, "\"]")); if (!(blockMenuEl !== null && blockMenuEl !== void 0 && blockMenuEl.parentElement)) { return; } if (positionSnapshot) { if (!selectedNode) { return; } // Reuse the container resolved when the snapshot was taken - re-resolving it independently // here could pick a different element (e.g. for a node near an overflow boundary), applying // positionSnapshot.scrollTop to the wrong container. var scrollContainer = positionSnapshot.scrollContainer; // The move transaction scrolls the new selection into view - undo that, only a drag handle // leaving the viewport is allowed to move the editor. scrollContainer.scrollTo({ behavior: 'instant', top: positionSnapshot.scrollTop }); var viewport = getScrollViewport(doc, scrollContainer); var anchorTop = selectedNode.getBoundingClientRect().top + positionSnapshot.dragHandleOffsetFromBlock; // Whether to scroll at all is decided by the drag handle alone - a block taller than the // viewport is expected to be cut off while its handle stays usable. if (getScrollDistanceToRevealAnchor(anchorTop, positionSnapshot.dragHandleHeight, viewport) === 0) { return; } // Once we do scroll, reveal the block and the popup, not just the handle sized sliver of it. // popupOffsetFromBlock is relative to the block - re-express it relative to the handle (the // anchor everything else here is measured from) to build one span covering all three. The // handle itself (0 to dragHandleHeight) always stays inside that span, so its visibility is // never sacrificed to make room for the block or the popup. var popupTopFromHandle = positionSnapshot.popupOffsetFromBlock - positionSnapshot.dragHandleOffsetFromBlock; var revealTop = Math.min(0, popupTopFromHandle); var revealBottom = Math.max(positionSnapshot.dragHandleHeight, positionSnapshot.blockHeight - positionSnapshot.dragHandleOffsetFromBlock, popupTopFromHandle + positionSnapshot.popupHeight); var revealHeight = Math.min(revealBottom - revealTop, Math.max(viewport.bottom - viewport.top - 2 * REVEAL_MARGIN, 0)); var scrollDistance = getScrollDistanceToRevealAnchor(anchorTop + revealTop - REVEAL_MARGIN, revealHeight + 2 * REVEAL_MARGIN, viewport); scrollContainer.scrollBy({ behavior: 'instant', top: scrollDistance }); setPopupTop(blockMenuEl, selectedNode.getBoundingClientRect().top + positionSnapshot.popupOffsetFromBlock); return; } var scrollableContainer = doc.querySelector(EDITOR_CONTENT_CONTAINER_SELECTOR); if (!selectedNode || !scrollableContainer) { return; } var parentElement = blockMenuEl.parentElement; var currentTop = parentElement.getBoundingClientRect().top; var distance = selectedNode.getBoundingClientRect().top - blockMenuEl.getBoundingClientRect().top; scrollableContainer.scrollBy({ behavior: 'instant', top: distance }); var newTop = parentElement.getBoundingClientRect().top; var topDifference = currentTop - newTop; var hasTopProperty = parentElement.style.top !== ''; var hasBottomProperty = parentElement.style.bottom !== ''; if (hasBottomProperty && !hasTopProperty) { var currentBottomValue = parseFloat(parentElement.style.bottom || '0'); parentElement.style.bottom = "".concat(currentBottomValue - topDifference, "px"); } else { var currentTopValue = parseFloat(parentElement.style.top || '0'); parentElement.style.top = "".concat(currentTopValue + topDifference, "px"); } }; /** * Shared by move-up.tsx and move-down.tsx: schedules the post-move fix for the frame after * ProseMirror has re-rendered. * * Falls back to the always-scroll path (via `getFirstSelectedDomNode`) whenever a * `positionSnapshot` wasn't captured - e.g. a media/file thumbnail reporting zero height for a * frame when the menu opened - rather than silently doing nothing. */ var scheduleBlockMenuPositionFix = exports.scheduleBlockMenuPositionFix = function scheduleBlockMenuPositionFix(positionSnapshot, getMovedBlockDomNode, getFirstSelectedDomNode) { requestAnimationFrame(function () { if (positionSnapshot) { fixBlockMenuPositionAndScroll(getMovedBlockDomNode(), positionSnapshot); return; } fixBlockMenuPositionAndScroll(getFirstSelectedDomNode()); }); };