UNPKG

@atlaskit/editor-plugin-block-menu

Version:

BlockMenu plugin for @atlaskit/editor-core

242 lines (231 loc) • 10.8 kB
import { getDocument } from '@atlaskit/browser-apis'; import { BLOCK_MENU_TEST_ID } from '@atlaskit/editor-common/block-menu/key'; import { DRAG_HANDLE_SELECTOR } from '@atlaskit/editor-common/styles/drag-handle'; const POPUP_WRAPPER_TEST_ID = 'popup-wrapper'; const 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. */ const 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. */ export const getScrollDistanceToRevealAnchor = (dragHandleTop, dragHandleHeight, viewport) => { if (dragHandleTop < viewport.top) { return dragHandleTop - viewport.top; } const 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. */ const getScrollContainer = (doc, selectedBlock) => { const editorContentContainer = doc.querySelector(EDITOR_CONTENT_CONTAINER_SELECTOR); if (editorContentContainer && editorContentContainer.scrollHeight > editorContentContainer.clientHeight) { return editorContentContainer; } let ancestor = selectedBlock.parentElement; while (ancestor) { var _doc$defaultView; const 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; }; const 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 }; } const { top, bottom } = scrollContainer.getBoundingClientRect(); return { bottom, 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. */ const getBlockBottom = block => { const rect = block.getBoundingClientRect(); if (rect.height > 0) { return rect.bottom; } return Array.from(block.children).reduce((bottom, child) => Math.max(bottom, child.getBoundingClientRect().bottom), rect.bottom); }; const setPopupTop = (blockMenuEl, targetTop) => { var _blockMenuEl$closest; const popupWrapper = (_blockMenuEl$closest = blockMenuEl.closest(`[data-testid="${POPUP_WRAPPER_TEST_ID}"]`)) !== null && _blockMenuEl$closest !== void 0 ? _blockMenuEl$closest : blockMenuEl.parentElement; if (!(popupWrapper instanceof HTMLElement)) { return; } const distance = blockMenuEl.getBoundingClientRect().top - targetTop; const hasTopProperty = popupWrapper.style.top !== ''; const hasBottomProperty = popupWrapper.style.bottom !== ''; if (hasBottomProperty && !hasTopProperty) { popupWrapper.style.bottom = `${parseFloat(popupWrapper.style.bottom || '0') + distance}px`; } else { popupWrapper.style.top = `${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. */ export const getBlockMenuAnchorMetrics = selectedBlock => { var _doc$querySelector; const doc = getDocument(); const blockMenuEl = doc === null || doc === void 0 ? void 0 : doc.querySelector(`[data-testid="${BLOCK_MENU_TEST_ID}"]`); if (!doc || !selectedBlock || !blockMenuEl) { return; } const blockMenuRect = blockMenuEl.getBoundingClientRect(); const dragHandleRect = (_doc$querySelector = doc.querySelector(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; } const 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. */ export const getBlockMenuPositionSnapshot = (selectedBlock, anchorMetrics) => { const doc = getDocument(); if (!doc || !selectedBlock) { return; } const scrollContainer = getScrollContainer(doc, selectedBlock); const metrics = anchorMetrics !== null && anchorMetrics !== void 0 ? anchorMetrics : getBlockMenuAnchorMetrics(selectedBlock); if (!scrollContainer || !metrics) { return; } return { ...metrics, 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. */ export const fixBlockMenuPositionAndScroll = (selectedNode, positionSnapshot) => { const doc = getDocument(); if (!doc) { return; } const blockMenuEl = doc.querySelector(`[data-testid="${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. const 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 }); const viewport = getScrollViewport(doc, scrollContainer); const 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. const popupTopFromHandle = positionSnapshot.popupOffsetFromBlock - positionSnapshot.dragHandleOffsetFromBlock; const revealTop = Math.min(0, popupTopFromHandle); const revealBottom = Math.max(positionSnapshot.dragHandleHeight, positionSnapshot.blockHeight - positionSnapshot.dragHandleOffsetFromBlock, popupTopFromHandle + positionSnapshot.popupHeight); const revealHeight = Math.min(revealBottom - revealTop, Math.max(viewport.bottom - viewport.top - 2 * REVEAL_MARGIN, 0)); const 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; } const scrollableContainer = doc.querySelector(EDITOR_CONTENT_CONTAINER_SELECTOR); if (!selectedNode || !scrollableContainer) { return; } const parentElement = blockMenuEl.parentElement; const currentTop = parentElement.getBoundingClientRect().top; const distance = selectedNode.getBoundingClientRect().top - blockMenuEl.getBoundingClientRect().top; scrollableContainer.scrollBy({ behavior: 'instant', top: distance }); const newTop = parentElement.getBoundingClientRect().top; const topDifference = currentTop - newTop; const hasTopProperty = parentElement.style.top !== ''; const hasBottomProperty = parentElement.style.bottom !== ''; if (hasBottomProperty && !hasTopProperty) { const currentBottomValue = parseFloat(parentElement.style.bottom || '0'); parentElement.style.bottom = `${currentBottomValue - topDifference}px`; } else { const currentTopValue = parseFloat(parentElement.style.top || '0'); parentElement.style.top = `${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. */ export const scheduleBlockMenuPositionFix = (positionSnapshot, getMovedBlockDomNode, getFirstSelectedDomNode) => { requestAnimationFrame(() => { if (positionSnapshot) { fixBlockMenuPositionAndScroll(getMovedBlockDomNode(), positionSnapshot); return; } fixBlockMenuPositionAndScroll(getFirstSelectedDomNode()); }); };