@atlaskit/editor-plugin-block-menu
Version:
BlockMenu plugin for @atlaskit/editor-core
245 lines (234 loc) • 11.9 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
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) { _defineProperty(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; }
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';
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.
*/
export var 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.
*/
export var getBlockMenuAnchorMetrics = function getBlockMenuAnchorMetrics(selectedBlock) {
var _doc$querySelector;
var doc = getDocument();
var blockMenuEl = doc === null || doc === void 0 ? void 0 : doc.querySelector("[data-testid=\"".concat(BLOCK_MENU_TEST_ID, "\"]"));
if (!doc || !selectedBlock || !blockMenuEl) {
return;
}
var blockMenuRect = blockMenuEl.getBoundingClientRect();
var 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;
}
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.
*/
export var getBlockMenuPositionSnapshot = function getBlockMenuPositionSnapshot(selectedBlock, anchorMetrics) {
var doc = 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.
*/
export var fixBlockMenuPositionAndScroll = function fixBlockMenuPositionAndScroll(selectedNode, positionSnapshot) {
var doc = getDocument();
if (!doc) {
return;
}
var blockMenuEl = doc.querySelector("[data-testid=\"".concat(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.
*/
export var scheduleBlockMenuPositionFix = function scheduleBlockMenuPositionFix(positionSnapshot, getMovedBlockDomNode, getFirstSelectedDomNode) {
requestAnimationFrame(function () {
if (positionSnapshot) {
fixBlockMenuPositionAndScroll(getMovedBlockDomNode(), positionSnapshot);
return;
}
fixBlockMenuPositionAndScroll(getFirstSelectedDomNode());
});
};