UNPKG

@atlaskit/editor-plugin-layout

Version:

Layout plugin for @atlaskit/editor-core

357 lines (337 loc) 14.5 kB
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 { bind } from 'bind-event-listener'; import { Fragment } from '@atlaskit/editor-prosemirror/model'; import { Decoration } from '@atlaskit/editor-prosemirror/view'; // Class names for the column resize divider widget — must stay in sync with layout.ts in editor-core var layoutColumnDividerClassName = 'layout-column-divider'; var layoutColumnDividerRailClassName = 'layout-column-divider-rail'; var layoutColumnDividerThumbClassName = 'layout-column-divider-thumb'; // Minimum column width percentage to prevent columns from collapsing var MIN_COLUMN_WIDTH_PERCENT = 5; // Module-level drag state so it survives widget DOM recreation during transactions. var dragState = null; /** * Dispatches a single undoable ProseMirror transaction to commit the final * column widths after a drag completes. */ var dispatchColumnWidths = function dispatchColumnWidths(view, sectionPos, leftColIndex, leftWidth, rightWidth) { var state = view.state; var sectionNode = state.doc.nodeAt(sectionPos); if (!sectionNode) { return; } var layoutColumn = state.schema.nodes.layoutColumn; var tr = state.tr; var newColumns = []; sectionNode.forEach(function (child, _offset, index) { if (child.type === layoutColumn) { var newWidth = child.attrs.width; if (index === leftColIndex) { newWidth = Number(leftWidth.toFixed(2)); } else if (index === leftColIndex + 1) { newWidth = Number(rightWidth.toFixed(2)); } newColumns.push(layoutColumn.create(_objectSpread(_objectSpread({}, child.attrs), {}, { width: newWidth }), child.content, child.marks)); } else { newColumns.push(child); } }); tr.replaceWith(sectionPos + 1, sectionPos + sectionNode.nodeSize - 1, Fragment.from(newColumns)); tr.setMeta('layoutColumnResize', true); tr.setMeta('scrollIntoView', false); view.dispatch(tr); }; /** * Calculates new column widths from the current mouse X position during drag. * Uses the columns-only width cached at mousedown — no layout reflow. * * The denominator is `columnsWidth` (the total flex container width minus * divider widgets and flex gaps) so that a 1 px mouse movement corresponds * to the exact same visual shift in the column boundary, eliminating the * few-pixel drift that occurred when using the full section width. */ var calcDragWidths = function calcDragWidths(clientX) { if (!dragState) { return null; } var _dragState = dragState, columnsWidth = _dragState.columnsWidth; if (columnsWidth === 0) { return null; } var deltaX = clientX - dragState.startX; var combinedWidth = dragState.startLeftWidth + dragState.startRightWidth; var deltaPercent = deltaX / columnsWidth * 100; var leftWidth = dragState.startLeftWidth + deltaPercent; var rightWidth = dragState.startRightWidth - deltaPercent; if (leftWidth < MIN_COLUMN_WIDTH_PERCENT) { leftWidth = MIN_COLUMN_WIDTH_PERCENT; rightWidth = combinedWidth - MIN_COLUMN_WIDTH_PERCENT; } else if (rightWidth < MIN_COLUMN_WIDTH_PERCENT) { rightWidth = MIN_COLUMN_WIDTH_PERCENT; leftWidth = combinedWidth - MIN_COLUMN_WIDTH_PERCENT; } return { leftWidth: leftWidth, rightWidth: rightWidth }; }; var onDragMouseMove = function onDragMouseMove(e) { if (!dragState) { return; } // If the mouse button was released outside the window (e.g. over browser chrome // or an iframe), we won't receive a mouseup on ownerDoc. Detect this by checking // whether any button is still held — if not, treat it as a drag end. if (e.buttons === 0) { onDragEnd(e.clientX); return; } // Always capture the latest clientX so the rAF callback uses the most recent // mouse position. Previously, intermediate positions were dropped when an rAF // was already scheduled, causing the column boundary to lag behind the cursor. dragState.lastClientX = e.clientX; // If a paint frame is already scheduled it will pick up lastClientX — no need // to schedule another one. if (dragState.rafId !== null) { return; } dragState.rafId = requestAnimationFrame(function () { if (!dragState) { return; } dragState.rafId = null; var widths = calcDragWidths(dragState.lastClientX); if (!widths) { return; } // Write flex-basis directly onto the column elements' inline styles for immediate // visual feedback. This beats PM's own inline flex-basis value without dispatching // any PM transaction — keeping drag completely off the ProseMirror render path. // The LayoutColumnView.ignoreMutation implementation ensures PM's MutationObserver // does not revert these style changes mid-drag. dragState.hasDragged = true; dragState.leftColEl.style.flexBasis = "".concat(widths.leftWidth, "%"); dragState.rightColEl.style.flexBasis = "".concat(widths.rightWidth, "%"); }); }; /** * Shared teardown for all drag-end paths (mouseup, missed mouseup detected via * e.buttons===0 on mousemove, window blur, and visibilitychange). Commits the * final column widths if a real drag occurred. */ var onDragEnd = function onDragEnd(clientX) { if (!dragState) { return; } var _dragState2 = dragState, view = _dragState2.view, sectionPos = _dragState2.sectionPos, leftColIndex = _dragState2.leftColIndex, leftColEl = _dragState2.leftColEl, rightColEl = _dragState2.rightColEl, hasDragged = _dragState2.hasDragged, rafId = _dragState2.rafId, startLeftWidth = _dragState2.startLeftWidth, startRightWidth = _dragState2.startRightWidth, unbindListeners = _dragState2.unbindListeners; unbindListeners(); // Cancel any pending rAF so a stale frame doesn't write styles after teardown. if (rafId !== null) { cancelAnimationFrame(rafId); } var ownerDoc = view.dom.ownerDocument; ownerDoc.body.style.userSelect = ''; ownerDoc.body.style.cursor = ''; var widths = calcDragWidths(clientX); dragState = null; if (!hasDragged) { // The user clicked without dragging — no flex-basis overrides were written, // so there is nothing to clean up and no transaction to dispatch. return; } // Clear the drag-time flex-basis overrides. The PM transaction below will // write the committed widths back into the node attrs and re-render the DOM. leftColEl.style.flexBasis = ''; rightColEl.style.flexBasis = ''; if (widths && (widths.leftWidth !== startLeftWidth || widths.rightWidth !== startRightWidth)) { dispatchColumnWidths(view, sectionPos, leftColIndex, widths.leftWidth, widths.rightWidth); } }; var onDragMouseUp = function onDragMouseUp(e) { onDragEnd(e.clientX); }; /** * Called when the window loses focus (blur) or the tab becomes hidden * (visibilitychange). In either case the user can't be dragging anymore, * so we commit the drag at the last known mouse position. */ var onDragCancel = function onDragCancel() { if (!dragState) { return; } // Commit at the last captured mouse position rather than startX, so the // columns stay where the user last saw them. onDragEnd(dragState.lastClientX); }; /** * Creates a column divider widget DOM element with drag-to-resize interaction for * the adjacent layout columns. During drag, flex-basis is mutated directly on the * column DOM elements for zero-overhead visual feedback (no PM transactions). * A single undoable PM transaction is dispatched on mouseup to commit the final widths. */ var createColumnDividerWidget = function createColumnDividerWidget(view, sectionPos, columnIndex // index of the column to the RIGHT of this divider ) { var ownerDoc = view.dom.ownerDocument; // Outer container: wide transparent hit area for easy grabbing, zero flex footprint var divider = ownerDoc.createElement('div'); divider.classList.add(layoutColumnDividerClassName); divider.contentEditable = 'false'; // Rail: styled via layoutColumnDividerStyles in layout.ts var rail = ownerDoc.createElement('div'); rail.classList.add(layoutColumnDividerRailClassName); divider.appendChild(rail); // Thumb: styled via layoutColumnDividerStyles in layout.ts var thumb = ownerDoc.createElement('div'); thumb.classList.add(layoutColumnDividerThumbClassName); rail.appendChild(thumb); var leftColIndex = columnIndex - 1; bind(divider, { type: 'mousedown', listener: function listener(e) { var _ownerDoc$defaultView; e.preventDefault(); e.stopPropagation(); var sectionNode = view.state.doc.nodeAt(sectionPos); if (!sectionNode) { return; } // Get the initial widths of the two adjacent columns var leftCol = null; var rightCol = null; sectionNode.forEach(function (child, _offset, index) { if (index === leftColIndex) { leftCol = child; } if (index === leftColIndex + 1) { rightCol = child; } }); if (!leftCol || !rightCol) { return; } var sectionElement = divider.closest('[data-layout-section]'); if (!(sectionElement instanceof HTMLElement)) { return; } // Capture the two adjacent column DOM elements upfront so mousemove can // mutate their flex-basis directly without any PM transaction overhead. // Query by data-layout-column-index (stamped by LayoutColumnView) rather than // relying on positional order of [data-layout-column] elements, which would // break if the DOM structure or ordering ever changes. var leftColEl = sectionElement.querySelector("[data-layout-column-index=\"".concat(leftColIndex, "\"]")); var rightColEl = sectionElement.querySelector("[data-layout-column-index=\"".concat(leftColIndex + 1, "\"]")); if (!leftColEl || !rightColEl) { return; } var unbindMove = bind(ownerDoc, { type: 'mousemove', listener: onDragMouseMove }); var unbindUp = bind(ownerDoc, { type: 'mouseup', listener: onDragMouseUp }); // If the user releases the mouse outside the browser window (e.g. over the // OS desktop) and then brings the cursor back, we won't get a mouseup on // ownerDoc. Listening on window for blur and on the document for // visibilitychange catches tab switches and window focus loss respectively. var unbindBlur = bind((_ownerDoc$defaultView = ownerDoc.defaultView) !== null && _ownerDoc$defaultView !== void 0 ? _ownerDoc$defaultView : window, { type: 'blur', listener: onDragCancel }); var unbindVisibility = bind(ownerDoc, { type: 'visibilitychange', listener: onDragCancel }); // Compute the width available to columns only (excluding divider widgets and // flex gaps). Using this as the denominator ensures that a 1 px mouse delta // translates to the exact pixel shift on the column boundary. var sectionRect = sectionElement.getBoundingClientRect(); var dividers = sectionElement.querySelectorAll(".".concat(layoutColumnDividerClassName)); var dividersWidth = 0; dividers.forEach(function (d) { dividersWidth += d.getBoundingClientRect().width; }); // Account for CSS gap between flex children. The gap is applied between // every pair of direct children (columns + divider widgets). var computedGap = parseFloat(getComputedStyle(sectionElement).gap || '0'); var childCount = sectionElement.children.length; var totalGap = childCount > 1 ? computedGap * (childCount - 1) : 0; var columnsWidth = sectionRect.width - dividersWidth - totalGap; dragState = { hasDragged: false, lastClientX: e.clientX, rafId: null, view: view, sectionPos: sectionPos, leftColIndex: leftColIndex, leftColEl: leftColEl, rightColEl: rightColEl, startX: e.clientX, startLeftWidth: leftCol.attrs.width, startRightWidth: rightCol.attrs.width, columnsWidth: columnsWidth, sectionElement: sectionElement, unbindListeners: function unbindListeners() { unbindMove(); unbindUp(); unbindBlur(); unbindVisibility(); } }; ownerDoc.body.style.userSelect = 'none'; ownerDoc.body.style.cursor = 'col-resize'; } }); return divider; }; /** * Returns ProseMirror Decoration widgets for column dividers between layout columns. * Each divider supports drag-to-resize interaction for the adjacent columns. */ export var getColumnDividerDecorations = function getColumnDividerDecorations(state, view) { var decorations = []; if (!view) { return decorations; } var layoutSection = state.schema.nodes.layoutSection; state.doc.descendants(function (node, pos) { if (node.type === layoutSection) { // Walk through layout column children and add dividers between them node.forEach(function (child, offset, index) { // Add a divider widget BEFORE every column except the first if (index > 0) { var sectionPos = pos; var colIndex = index; var widgetPos = pos + offset + 1; // position at the start of this column decorations.push(Decoration.widget(widgetPos, function () { return createColumnDividerWidget(view, sectionPos, colIndex); }, { side: -1, // place before the position key: "layout-col-divider-".concat(pos, "-").concat(index), ignoreSelection: true })); } }); return false; // don't descend into children } return true; // continue descending }); return decorations; };