@atlaskit/editor-plugin-layout
Version:
Layout plugin for @atlaskit/editor-core
361 lines (341 loc) • 13 kB
JavaScript
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
const layoutColumnDividerClassName = 'layout-column-divider';
const layoutColumnDividerRailClassName = 'layout-column-divider-rail';
const layoutColumnDividerThumbClassName = 'layout-column-divider-thumb';
// Minimum column width percentage to prevent columns from collapsing
const MIN_COLUMN_WIDTH_PERCENT = 5;
// Module-level drag state so it survives widget DOM recreation during transactions.
let dragState = null;
/**
* Dispatches a single undoable ProseMirror transaction to commit the final
* column widths after a drag completes.
*/
const dispatchColumnWidths = (view, sectionPos, leftColIndex, leftWidth, rightWidth) => {
const {
state
} = view;
const sectionNode = state.doc.nodeAt(sectionPos);
if (!sectionNode) {
return;
}
const {
layoutColumn
} = state.schema.nodes;
const tr = state.tr;
const newColumns = [];
sectionNode.forEach((child, _offset, index) => {
if (child.type === layoutColumn) {
let 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({
...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.
*/
const calcDragWidths = clientX => {
if (!dragState) {
return null;
}
const {
columnsWidth
} = dragState;
if (columnsWidth === 0) {
return null;
}
const deltaX = clientX - dragState.startX;
const combinedWidth = dragState.startLeftWidth + dragState.startRightWidth;
const deltaPercent = deltaX / columnsWidth * 100;
let leftWidth = dragState.startLeftWidth + deltaPercent;
let 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,
rightWidth
};
};
const 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(() => {
if (!dragState) {
return;
}
dragState.rafId = null;
const 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 = `${widths.leftWidth}%`;
dragState.rightColEl.style.flexBasis = `${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.
*/
const onDragEnd = clientX => {
if (!dragState) {
return;
}
const {
view,
sectionPos,
leftColIndex,
leftColEl,
rightColEl,
hasDragged,
rafId,
startLeftWidth,
startRightWidth,
unbindListeners
} = dragState;
unbindListeners();
// Cancel any pending rAF so a stale frame doesn't write styles after teardown.
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
const ownerDoc = view.dom.ownerDocument;
ownerDoc.body.style.userSelect = '';
ownerDoc.body.style.cursor = '';
const 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);
}
};
const 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.
*/
const 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.
*/
const createColumnDividerWidget = (view, sectionPos, columnIndex // index of the column to the RIGHT of this divider
) => {
const ownerDoc = view.dom.ownerDocument;
// Outer container: wide transparent hit area for easy grabbing, zero flex footprint
const divider = ownerDoc.createElement('div');
divider.classList.add(layoutColumnDividerClassName);
divider.contentEditable = 'false';
// Rail: styled via layoutColumnDividerStyles in layout.ts
const rail = ownerDoc.createElement('div');
rail.classList.add(layoutColumnDividerRailClassName);
divider.appendChild(rail);
// Thumb: styled via layoutColumnDividerStyles in layout.ts
const thumb = ownerDoc.createElement('div');
thumb.classList.add(layoutColumnDividerThumbClassName);
rail.appendChild(thumb);
const leftColIndex = columnIndex - 1;
bind(divider, {
type: 'mousedown',
listener: e => {
var _ownerDoc$defaultView;
e.preventDefault();
e.stopPropagation();
const sectionNode = view.state.doc.nodeAt(sectionPos);
if (!sectionNode) {
return;
}
// Get the initial widths of the two adjacent columns
let leftCol = null;
let rightCol = null;
sectionNode.forEach((child, _offset, index) => {
if (index === leftColIndex) {
leftCol = child;
}
if (index === leftColIndex + 1) {
rightCol = child;
}
});
if (!leftCol || !rightCol) {
return;
}
const 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.
const leftColEl = sectionElement.querySelector(`[data-layout-column-index="${leftColIndex}"]`);
const rightColEl = sectionElement.querySelector(`[data-layout-column-index="${leftColIndex + 1}"]`);
if (!leftColEl || !rightColEl) {
return;
}
const unbindMove = bind(ownerDoc, {
type: 'mousemove',
listener: onDragMouseMove
});
const 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.
const unbindBlur = bind((_ownerDoc$defaultView = ownerDoc.defaultView) !== null && _ownerDoc$defaultView !== void 0 ? _ownerDoc$defaultView : window, {
type: 'blur',
listener: onDragCancel
});
const 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.
const sectionRect = sectionElement.getBoundingClientRect();
const dividers = sectionElement.querySelectorAll(`.${layoutColumnDividerClassName}`);
let dividersWidth = 0;
dividers.forEach(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).
const computedGap = parseFloat(getComputedStyle(sectionElement).gap || '0');
const childCount = sectionElement.children.length;
const totalGap = childCount > 1 ? computedGap * (childCount - 1) : 0;
const columnsWidth = sectionRect.width - dividersWidth - totalGap;
dragState = {
hasDragged: false,
lastClientX: e.clientX,
rafId: null,
view,
sectionPos,
leftColIndex,
leftColEl,
rightColEl,
startX: e.clientX,
startLeftWidth: leftCol.attrs.width,
startRightWidth: rightCol.attrs.width,
columnsWidth,
sectionElement,
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 const getColumnDividerDecorations = (state, view) => {
const decorations = [];
if (!view) {
return decorations;
}
const {
layoutSection
} = state.schema.nodes;
state.doc.descendants((node, pos) => {
if (node.type === layoutSection) {
// Walk through layout column children and add dividers between them
node.forEach((child, offset, index) => {
// Add a divider widget BEFORE every column except the first
if (index > 0) {
const sectionPos = pos;
const colIndex = index;
const widgetPos = pos + offset + 1; // position at the start of this column
decorations.push(Decoration.widget(widgetPos, () => createColumnDividerWidget(view, sectionPos, colIndex), {
side: -1,
// place before the position
key: `layout-col-divider-${pos}-${index}`,
ignoreSelection: true
}));
}
});
return false; // don't descend into children
}
return true; // continue descending
});
return decorations;
};