@atlaskit/editor-plugin-layout
Version:
Layout plugin for @atlaskit/editor-core
502 lines (476 loc) • 21.6 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 { bind } from 'bind-event-listener';
import { ACTION, ACTION_SUBJECT, ACTION_SUBJECT_ID, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics';
import { Fragment } from '@atlaskit/editor-prosemirror/model';
import { NodeSelection, TextSelection } from '@atlaskit/editor-prosemirror/state';
import { Decoration } from '@atlaskit/editor-prosemirror/view';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { MIN_LAYOUT_COLUMN_WIDTH_PERCENT } from './consts';
/**
* Re-creates a selection at the same positions against the resized document. A resize only
* changes width attrs (not node count, order, or size), so the original positions are still
* valid. Returns `undefined` if no valid selection can be made.
*/
var remapResizeSelection = function remapResizeSelection(selection, doc) {
var docSize = doc.content.size;
var clamp = function clamp(pos) {
return Math.min(Math.max(pos, 0), docSize);
};
if (selection instanceof NodeSelection) {
var _TextSelection$findFr;
var pos = clamp(selection.from);
var nodeAtPos = doc.nodeAt(pos);
if (nodeAtPos && NodeSelection.isSelectable(nodeAtPos)) {
return NodeSelection.create(doc, pos);
}
return (_TextSelection$findFr = TextSelection.findFrom(doc.resolve(pos), 1, true)) !== null && _TextSelection$findFr !== void 0 ? _TextSelection$findFr : undefined;
}
if (selection instanceof TextSelection) {
try {
return TextSelection.create(doc, clamp(selection.from), clamp(selection.to));
} catch (_unused) {
return undefined;
}
}
return undefined;
};
// 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';
// 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, editorAnalyticsAPI) {
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);
}
});
// Capture the selection before the replace below, which otherwise maps the caret out of
// the layout to the following paragraph. Restored at the same positions afterwards.
var shouldPreserveSelection = !fg('platform_editor_layout_column_menu_kill_switch_1');
var selectionBeforeResize = shouldPreserveSelection ? state.selection : null;
tr.replaceWith(sectionPos + 1, sectionPos + sectionNode.nodeSize - 1, Fragment.from(newColumns));
tr.setMeta('layoutColumnResize', true);
tr.setMeta('scrollIntoView', false);
if (selectionBeforeResize) {
var restoredSelection = remapResizeSelection(selectionBeforeResize, tr.doc);
if (restoredSelection) {
tr.setSelection(restoredSelection);
}
}
editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 || editorAnalyticsAPI.attachAnalyticsEvent({
action: ACTION.DRAGGED,
actionSubject: ACTION_SUBJECT.DOCUMENT,
actionSubjectId: ACTION_SUBJECT_ID.LAYOUT_COLUMN,
attributes: {
columnCount: sectionNode.childCount,
leftColumnIndex: leftColIndex,
leftColumnWidth: Number(leftWidth.toFixed(2)),
rightColumnWidth: Number(rightWidth.toFixed(2)),
inputMethod: INPUT_METHOD.DRAG
},
eventType: EVENT_TYPE.TRACK
})(tr);
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_LAYOUT_COLUMN_WIDTH_PERCENT) {
leftWidth = MIN_LAYOUT_COLUMN_WIDTH_PERCENT;
rightWidth = combinedWidth - MIN_LAYOUT_COLUMN_WIDTH_PERCENT;
} else if (rightWidth < MIN_LAYOUT_COLUMN_WIDTH_PERCENT) {
rightWidth = MIN_LAYOUT_COLUMN_WIDTH_PERCENT;
leftWidth = combinedWidth - MIN_LAYOUT_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,
editorAnalyticsAPI = _dragState2.editorAnalyticsAPI;
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, editorAnalyticsAPI);
}
};
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);
};
/**
* Resolves the layout section a divider sits in and which pair of columns it separates.
*
* When `getPos` is supplied (under `platform_editor_reduce_event_listener_count`) this is derived
* from the widget's own live position, which ProseMirror keeps up to date. That is what lets the
* decoration key omit the section position: the key previously embedded it, so inserting a single
* character above a layout changed the key of every divider below and forced ProseMirror to destroy
* and rebuild all of them. Without `getPos` it falls back to the values captured when the widget
* was created.
*/
var resolveDividerTarget = function resolveDividerTarget(view, sectionPos, columnIndex, getPos) {
if (!getPos) {
var sectionNode = view.state.doc.nodeAt(sectionPos);
return sectionNode ? {
sectionNode: sectionNode,
sectionPos: sectionPos,
leftColIndex: columnIndex - 1
} : null;
}
var pos = getPos();
if (pos === undefined) {
return null;
}
// The widget is anchored at the position of the column it precedes, which resolves to a
// position directly inside the layout section.
var $pos = view.state.doc.resolve(pos);
var colIndex = $pos.index();
if ($pos.parent.type !== view.state.schema.nodes.layoutSection || colIndex < 1) {
return null;
}
return {
sectionNode: $pos.parent,
sectionPos: $pos.before(),
leftColIndex: colIndex - 1
};
};
/**
* 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, editorAnalyticsAPI, getPos) {
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 unbindMouseDown = bind(divider, {
type: 'mousedown',
listener: function listener(e) {
var _ownerDoc$defaultView;
e.preventDefault();
e.stopPropagation();
var resolved = resolveDividerTarget(view, sectionPos, columnIndex, getPos);
if (!resolved) {
return;
}
var sectionNode = resolved.sectionNode,
leftColIndex = resolved.leftColIndex,
resolvedSectionPos = resolved.sectionPos;
// 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 = {
editorAnalyticsAPI: editorAnalyticsAPI,
hasDragged: false,
lastClientX: e.clientX,
rafId: null,
view: view,
sectionPos: resolvedSectionPos,
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 {
target: divider,
destroy: unbindMouseDown
};
};
/**
* 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, editorAnalyticsAPI) {
var decorations = [];
if (!view) {
return decorations;
}
var _state$schema$nodes = state.schema.nodes,
layoutSection = _state$schema$nodes.layoutSection,
bodiedSyncBlock = _state$schema$nodes.bodiedSyncBlock;
// Read once per call: `isExperimentEnabled` fires an exposure event, so checking it per divider
// would emit one per divider per `decorations(state)` read.
var isScopedWalkEnabled = isExperimentEnabled('platform_editor_reduce_event_listener_count');
var sectionIndex = 0;
state.doc.descendants(function (node, pos) {
if (node.type === layoutSection) {
// This layout's ordinal in the document, used below as a position-independent key.
var sectionOrdinal = sectionIndex++;
// Walk through layout column children and add dividers between them
node.forEach(function (_, 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
if (!isScopedWalkEnabled) {
decorations.push(Decoration.widget(widgetPos, function () {
return createColumnDividerWidget(view, sectionPos, colIndex, editorAnalyticsAPI).target;
}, {
side: -1,
// place before the position
key: "layout-col-divider-".concat(pos, "-").concat(index),
ignoreSelection: true
}));
} else {
// Keyed by the layout's ordinal rather than its position, so an edit elsewhere in
// the document doesn't invalidate it. A position-derived key changed on every
// keystroke above a layout, making the regenerated DecorationSet compare unequal
// and forcing ProseMirror to destroy and rebuild every divider below the edit. The
// widget resolves its own section from `getPos` (see `resolveDividerTarget`), so it
// stays correct wherever it ends up.
//
// `toDOM` must also stay lazy: most of the `Decoration`s returned here are matched
// by key against the previous set and discarded without ever being rendered.
// Building the widget eagerly would create three elements and bind a `mousedown`
// listener per divider per call, and the discarded ones never receive `destroy`.
var unbindMouseDown;
decorations.push(Decoration.widget(widgetPos, function (widgetView, getPos) {
var _createColumnDividerW = createColumnDividerWidget(widgetView, sectionPos, colIndex, editorAnalyticsAPI, getPos),
target = _createColumnDividerW.target,
destroy = _createColumnDividerW.destroy;
unbindMouseDown = destroy;
return target;
}, {
side: -1,
// place before the position
key: "layout-col-divider-".concat(sectionOrdinal, "-").concat(index),
ignoreSelection: true,
destroy: function destroy() {
var _unbindMouseDown;
(_unbindMouseDown = unbindMouseDown) === null || _unbindMouseDown === void 0 || _unbindMouseDown();
unbindMouseDown = undefined;
}
}));
}
}
});
return false; // don't descend into children
}
// A `layoutSection` is only valid as a direct child of `doc` or `bodiedSyncBlock`, so there
// is nothing to find anywhere else. The unrestricted walk visited every node in the
// document — every table cell, list item and text node — on every `decorations(state)` read.
return isScopedWalkEnabled ? node.type === bodiedSyncBlock : true;
});
return decorations;
};