@atlaskit/editor-plugin-block-controls
Version:
Block controls plugin for @atlaskit/editor-core
330 lines (318 loc) • 13 kB
JavaScript
import { INPUT_METHOD } from '@atlaskit/editor-common/analytics';
import { logException } from '@atlaskit/editor-common/monitoring';
import { Fragment, Node as PMNode } from '@atlaskit/editor-prosemirror/model';
import { NodeSelection } from '@atlaskit/editor-prosemirror/state';
import { fg } from '@atlaskit/platform-feature-flags';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
import { attachMoveNodeAnalytics, fireInsertLayoutAnalytics, getMultiSelectAnalyticsAttributes } from '../pm-plugins/utils/analytics';
import { containsNodeOfType, isFragmentOfType } from '../pm-plugins/utils/check-fragment';
import { maxLayoutColumnSupported } from '../pm-plugins/utils/consts';
import { removeFromSource } from '../pm-plugins/utils/remove-from-source';
import { getMultiSelectionIfPosInside } from '../pm-plugins/utils/selection';
import { isInSameLayout } from '../pm-plugins/utils/validation';
import { DEFAULT_COLUMN_DISTRIBUTIONS } from '../ui/consts';
const createNewLayout = (schema, layoutContents) => {
if (layoutContents.length === 0 || layoutContents.length > maxLayoutColumnSupported()) {
return null;
}
const width = DEFAULT_COLUMN_DISTRIBUTIONS[layoutContents.length];
if (!width) {
return null;
}
const {
layoutSection,
layoutColumn
} = schema.nodes || {};
try {
const layoutContent = Fragment.fromArray(layoutContents.map(layoutContent => {
return layoutColumn.createChecked({
width
}, layoutContent);
}));
const layoutSectionNode = layoutSection.createChecked(undefined, layoutContent);
return layoutSectionNode;
} catch (error) {
logException(error, {
location: 'editor-plugin-block-controls/move-to-layout'
});
}
return null;
};
const moveToExistingLayout = (toLayout, toLayoutPos, sourceContent, from, to, tr, $originalFrom, $originalTo, api, selectMovedNode) => {
const isSameLayout = isInSameLayout($originalFrom, $originalTo);
const sourceContentEndPos = from + sourceContent.size;
const attributes = getMultiSelectAnalyticsAttributes(tr, from, sourceContentEndPos);
const {
nodeTypes: sourceNodeTypes,
hasSelectedMultipleNodes
} = attributes;
if (isSameLayout) {
// reorder columns
tr.delete(from, sourceContentEndPos);
const mappedTo = tr.mapping.map(to);
tr.insert(mappedTo, sourceContent);
if (selectMovedNode) {
tr.setSelection(new NodeSelection(tr.doc.resolve(mappedTo))).scrollIntoView();
}
attachMoveNodeAnalytics(tr, INPUT_METHOD.DRAG_AND_DROP, $originalFrom.depth, sourceNodeTypes, 1, 'layoutSection', true, api, hasSelectedMultipleNodes);
} else if (toLayout.childCount < maxLayoutColumnSupported()) {
removeFromSource(tr, tr.doc.resolve(from), sourceContentEndPos);
insertToDestinationNoWidthUpdate(tr, tr.mapping.map(to), sourceContent);
attachMoveNodeAnalytics(tr, INPUT_METHOD.DRAG_AND_DROP, $originalFrom.depth, sourceNodeTypes, 1, 'layoutSection', false, api, hasSelectedMultipleNodes);
}
return tr;
};
/**
* This function is similar to insertToDestination
* But without update width step, mainly rely on the append transaction from layout.
* @param tr
* @param to
* @param sourceNode
* @returns
*/
const insertToDestinationNoWidthUpdate = (tr, to, sourceContent) => {
const {
layoutColumn
} = tr.doc.type.schema.nodes || {};
let content = null;
try {
var _sourceFragment$first;
const sourceFragment = sourceContent;
content = layoutColumn.createChecked({
width: 0
}, isFragmentOfType(sourceFragment, 'layoutColumn') ? (_sourceFragment$first = sourceFragment.firstChild) === null || _sourceFragment$first === void 0 ? void 0 : _sourceFragment$first.content : sourceFragment);
} catch (error) {
logException(error, {
location: 'editor-plugin-block-controls/move-to-layout'
});
}
if (content) {
tr.insert(to, content);
}
return tr;
};
/**
* Check if the node at `from` can be moved to node at `to` to create/expand a layout.
* Returns the source and destination nodes and positions if it's a valid move, otherwise, undefined
*/
const canMoveToLayout = (api, from, to, tr, moveNodeAtCursorPos) => {
if (from === to) {
return;
}
const {
layoutSection,
layoutColumn,
doc,
bodiedSyncBlock
} = tr.doc.type.schema.nodes || {};
// layout plugin does not exist
if (!layoutSection || !layoutColumn) {
return;
}
const $to = tr.doc.resolve(to);
const allowedParentTypes = [doc, layoutSection];
if (bodiedSyncBlock && editorExperiment('platform_synced_block', true) && editorExperiment('platform_synced_block_patch_6', true, {
exposure: true
})) {
allowedParentTypes.push(bodiedSyncBlock);
}
// drop at invalid position, not top level, or not a layout column
if (!$to.nodeAfter || !allowedParentTypes.includes($to.parent.type)) {
return;
}
const $from = tr.doc.resolve(from);
// invalid from position or dragging a layout
if (!$from.nodeAfter || $from.nodeAfter.type === layoutSection) {
return;
}
let sourceContent = $from.nodeAfter;
let sourceFrom = from;
let sourceTo = from + sourceContent.nodeSize;
if (!moveNodeAtCursorPos) {
const {
anchor,
head
} = getMultiSelectionIfPosInside(api, from);
if (anchor !== undefined && head !== undefined) {
sourceFrom = Math.min(anchor, head);
sourceTo = Math.max(anchor, head);
sourceContent = tr.doc.slice(sourceFrom, sourceTo).content;
// TODO: ED-26959 - this might become expensive for large content, consider removing it if check has been done beforehand
if (containsNodeOfType(sourceContent, 'layoutSection')) {
return;
}
} else {
sourceContent = Fragment.from($from.nodeAfter);
}
}
const toNode = $to.nodeAfter;
return {
toNode,
$to,
sourceContent,
$sourceFrom: tr.doc.resolve(sourceFrom),
sourceTo
};
};
const removeBreakoutMarks = (tr, $from, to) => {
let fromContentWithoutBreakout = null;
const {
breakout
} = tr.doc.type.schema.marks || {};
tr.doc.nodesBetween($from.pos, to, (node, pos, parent) => {
// should never remove breakout from previous layoutSection
if (expValEquals('platform_editor_breakout_resizing', 'isEnabled', true)) {
if (node.type.name === 'layoutSection') {
return false;
}
}
// breakout doesn't exist on nested nodes
if ((parent === null || parent === void 0 ? void 0 : parent.type.name) === 'doc' && node.marks.some(m => m.type === breakout)) {
tr.removeNodeMark(pos, breakout);
}
// descending is not needed as breakout doesn't exist on nested nodes
return false;
});
// resolve again the source content after node updated (remove breakout marks)
fromContentWithoutBreakout = tr.doc.slice($from.pos, to).content;
return fromContentWithoutBreakout;
};
const getBreakoutMode = (content, breakout) => {
if (content instanceof PMNode) {
var _content$marks$find;
return (_content$marks$find = content.marks.find(m => m.type === breakout)) === null || _content$marks$find === void 0 ? void 0 : _content$marks$find.attrs.mode;
} else if (content instanceof Fragment) {
// Find the first breakout mode in the fragment
let firstBreakoutMode;
for (let i = 0; i < content.childCount; i++) {
const child = content.child(i);
const breakoutMark = child.marks.find(m => m.type === breakout);
if (breakoutMark) {
firstBreakoutMode = breakoutMark.attrs.mode;
break;
}
}
return firstBreakoutMode;
}
};
const getBreakoutModeAndWidth = (content, breakout) => {
const findBreakoutMark = node => node.marks.find(m => m.type === breakout);
const extractBreakoutAttributes = mark => mark ? {
breakoutMode: mark.attrs.mode,
breakoutWidth: mark.attrs.width
} : null;
if (content instanceof PMNode) {
return extractBreakoutAttributes(findBreakoutMark(content));
} else if (content instanceof Fragment) {
// Find the first breakout mode in the fragment
for (let i = 0; i < content.childCount; i++) {
const child = content.child(i);
const breakoutMark = findBreakoutMark(child);
if (breakoutMark) {
return extractBreakoutAttributes(breakoutMark);
}
}
}
return null;
};
export const moveToLayout = api => (from, to, options) => ({
tr
}) => {
if (!api) {
return tr;
}
const canMove = canMoveToLayout(api, from, to, tr, options === null || options === void 0 ? void 0 : options.moveNodeAtCursorPos);
if (!canMove) {
return tr;
}
const {
toNode,
$to,
sourceContent,
$sourceFrom,
sourceTo
} = canMove;
const {
layoutSection,
layoutColumn
} = tr.doc.type.schema.nodes || {};
const {
breakout
} = tr.doc.type.schema.marks || {};
// get breakout mode from destination node,
// if not found, get from source node,
let breakoutMode;
let breakoutWidth;
if (expValEquals('platform_editor_breakout_resizing', 'isEnabled', true)) {
({
breakoutMode,
breakoutWidth
} = getBreakoutModeAndWidth(toNode, breakout) || getBreakoutModeAndWidth(sourceContent, breakout) || {});
} else {
breakoutMode = getBreakoutMode(toNode, breakout) || getBreakoutMode(sourceContent, breakout);
}
// we don't want to remove marks when moving/re-ordering layoutSection
const shouldRemoveMarks = $sourceFrom.node().type !== layoutSection;
const fromContentBeforeBreakoutMarksRemoved = tr.doc.slice($sourceFrom.pos, sourceTo).content;
// remove breakout from source content
let fromContentWithoutBreakout = shouldRemoveMarks ? removeBreakoutMarks(tr, $sourceFrom, sourceTo) : fromContentBeforeBreakoutMarksRemoved;
if (!fromContentWithoutBreakout) {
return tr;
}
if (fg('platform_editor_ease_of_use_metrics')) {
var _api$metrics;
api === null || api === void 0 ? void 0 : (_api$metrics = api.metrics) === null || _api$metrics === void 0 ? void 0 : _api$metrics.commands.setContentMoved()({
tr
});
}
if (toNode.type === layoutSection) {
const toPos = options !== null && options !== void 0 && options.moveToEnd ? to + toNode.nodeSize - 1 : to + 1;
return moveToExistingLayout(toNode, to, fromContentWithoutBreakout, $sourceFrom.pos, toPos, tr, $sourceFrom, $to, api, options === null || options === void 0 ? void 0 : options.selectMovedNode);
} else if (toNode.type === layoutColumn) {
const toLayout = $to.parent;
const toLayoutPos = to - $to.parentOffset - 1;
const toPos = options !== null && options !== void 0 && options.moveToEnd ? to + toNode.nodeSize : to;
return moveToExistingLayout(toLayout, toLayoutPos, fromContentWithoutBreakout, $sourceFrom.pos, toPos, tr, $sourceFrom, $to, api, options === null || options === void 0 ? void 0 : options.selectMovedNode);
} else {
let toNodeWithoutBreakout = toNode;
// remove breakout from node;
if (breakout && $to.nodeAfter && $to.nodeAfter.marks.some(m => m.type === breakout)) {
tr.removeNodeMark(to, breakout);
// resolve again the source node after node updated (remove breakout marks)
toNodeWithoutBreakout = tr.doc.resolve(to).nodeAfter || toNode;
}
if (isFragmentOfType(fromContentWithoutBreakout, 'layoutColumn') && fromContentWithoutBreakout.firstChild) {
fromContentWithoutBreakout = fromContentWithoutBreakout.firstChild.content;
}
const layoutContents = options !== null && options !== void 0 && options.moveToEnd ? [toNodeWithoutBreakout, fromContentWithoutBreakout] : [fromContentWithoutBreakout, toNodeWithoutBreakout];
const newLayout = createNewLayout(tr.doc.type.schema, layoutContents);
if (newLayout) {
const attributes = getMultiSelectAnalyticsAttributes(tr, $sourceFrom.pos, sourceTo);
const {
nodeTypes: sourceNodeTypes,
hasSelectedMultipleNodes
} = attributes;
tr = removeFromSource(tr, $sourceFrom, sourceTo);
const mappedTo = tr.mapping.map(to);
tr.delete(mappedTo, mappedTo + toNodeWithoutBreakout.nodeSize).insert(mappedTo, newLayout);
if (expValEquals('platform_editor_breakout_resizing', 'isEnabled', true)) {
breakoutMode && tr.setNodeMarkup(mappedTo, newLayout.type, newLayout.attrs, [breakout.create({
mode: breakoutMode,
width: breakoutWidth
})]);
} else {
breakoutMode && tr.setNodeMarkup(mappedTo, newLayout.type, newLayout.attrs, [breakout.create({
mode: breakoutMode
})]);
}
if (fg('platform_editor_column_count_analytics')) {
// layout created via drag and drop will always be 2 columns
fireInsertLayoutAnalytics(tr, api, sourceNodeTypes, hasSelectedMultipleNodes, 2);
} else {
fireInsertLayoutAnalytics(tr, api, sourceNodeTypes, hasSelectedMultipleNodes);
}
}
return tr;
}
};