diff --git a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.examples.tsx b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.examples.tsx
index f70c00a7785f3..a852571a3ea65 100644
--- a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.examples.tsx
+++ b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/__examples__/sidebar_header.examples.tsx
@@ -22,7 +22,16 @@ const handlers = {
createCustomElement: action('createCustomElement'),
groupNodes: action('groupNodes'),
ungroupNodes: action('ungroupNodes'),
+ alignLeft: action('alignLeft'),
+ alignMiddle: action('alignMiddle'),
+ alignRight: action('alignRight'),
+ alignTop: action('alignTop'),
+ alignCenter: action('alignCenter'),
+ alignBottom: action('alignBottom'),
+ distributeHorizontally: action('distributeHorizontally'),
+ distributeVertically: action('distributeVertically'),
};
+
storiesOf('components/Sidebar/SidebarHeader', module)
.addDecorator(story =>
{story()}
)
.add('default', () => )
diff --git a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/index.js b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/index.js
index 3f0b911b34631..f60aad1fa3e72 100644
--- a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/index.js
+++ b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/index.js
@@ -14,6 +14,7 @@ import {
clipboardHandlerCreators,
basicHandlerCreators,
groupHandlerCreators,
+ alignmentDistributionHandlerCreators,
} from '../../lib/element_handler_creators';
import { crawlTree } from '../workpad_page/integration_utils';
import { selectToplevelNodes } from './../../state/actions/transient';
@@ -62,5 +63,6 @@ export const SidebarHeader = compose(
withHandlers(basicHandlerCreators),
withHandlers(clipboardHandlerCreators),
withHandlers(layerHandlerCreators),
- withHandlers(groupHandlerCreators)
+ withHandlers(groupHandlerCreators),
+ withHandlers(alignmentDistributionHandlerCreators)
)(Component);
diff --git a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx
index 9da42be5e99db..d505cbc9d71be 100644
--- a/x-pack/legacy/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx
+++ b/x-pack/legacy/plugins/canvas/public/components/sidebar_header/sidebar_header.tsx
@@ -89,6 +89,38 @@ interface Props {
* ungroups selected group
*/
ungroupNodes: () => void;
+ /**
+ * left align selected elements
+ */
+ alignLeft: () => void;
+ /**
+ * center align selected elements
+ */
+ alignCenter: () => void;
+ /**
+ * right align selected elements
+ */
+ alignRight: () => void;
+ /**
+ * top align selected elements
+ */
+ alignTop: () => void;
+ /**
+ * middle align selected elements
+ */
+ alignMiddle: () => void;
+ /**
+ * bottom align selected elements
+ */
+ alignBottom: () => void;
+ /**
+ * horizontally distribute selected elements
+ */
+ distributeHorizontally: () => void;
+ /**
+ * vertically distribute selected elements
+ */
+ distributeVertically: () => void;
}
interface State {
@@ -98,6 +130,11 @@ interface State {
isModalVisible: boolean;
}
+interface MenuTuple {
+ menuItem: EuiContextMenuPanelItemDescriptor;
+ panel: EuiContextMenuPanelDescriptor;
+}
+
const contextMenuButton = (handleClick: (event: MouseEvent) => void) => (
{
selectedNodes: PropTypes.array,
groupNodes: PropTypes.func.isRequired,
ungroupNodes: PropTypes.func.isRequired,
+ alignLeft: PropTypes.func.isRequired,
+ alignCenter: PropTypes.func.isRequired,
+ alignRight: PropTypes.func.isRequired,
+ alignTop: PropTypes.func.isRequired,
+ alignMiddle: PropTypes.func.isRequired,
+ alignBottom: PropTypes.func.isRequired,
+ distributeHorizontally: PropTypes.func.isRequired,
+ distributeVertically: PropTypes.func.isRequired,
};
public static defaultProps = {
@@ -229,10 +274,7 @@ export class SidebarHeader extends Component {
);
};
- private _getLayerMenuItems = (): {
- menuItem: EuiContextMenuPanelItemDescriptor;
- panel: EuiContextMenuPanelDescriptor;
- } => {
+ private _getLayerMenuItems = (): MenuTuple => {
const { bringToFront, bringForward, sendBackward, sendToBack } = this.props;
return {
@@ -266,6 +308,74 @@ export class SidebarHeader extends Component {
};
};
+ private _getAlignmentMenuItems = (close: (fn: () => void) => () => void): MenuTuple => {
+ const { alignLeft, alignCenter, alignRight, alignTop, alignMiddle, alignBottom } = this.props;
+
+ return {
+ menuItem: { name: 'Align elements', className: 'canvasContextMenu', panel: 2 },
+ panel: {
+ id: 2,
+ title: 'Alignment',
+ items: [
+ {
+ name: 'Left',
+ icon: 'editorItemAlignLeft',
+ onClick: close(alignLeft),
+ },
+ {
+ name: 'Center',
+ icon: 'editorItemAlignCenter',
+ onClick: close(alignCenter),
+ },
+ {
+ name: 'Right',
+ icon: 'editorItemAlignRight',
+ onClick: close(alignRight),
+ },
+ {
+ name: 'Top',
+ icon: 'editorItemAlignTop',
+ onClick: close(alignTop),
+ },
+ {
+ name: 'Middle',
+ icon: 'editorItemAlignMiddle',
+ onClick: close(alignMiddle),
+ },
+ {
+ name: 'Bottom',
+ icon: 'editorItemAlignBottom',
+ onClick: close(alignBottom),
+ },
+ ],
+ },
+ };
+ };
+
+ private _getDistributionMenuItems = (close: (fn: () => void) => () => void): MenuTuple => {
+ const { distributeHorizontally, distributeVertically } = this.props;
+
+ return {
+ menuItem: { name: 'Distribute elements', className: 'canvasContextMenu', panel: 3 },
+ panel: {
+ id: 3,
+ title: 'Distribution',
+ items: [
+ {
+ name: 'Horizontal',
+ icon: 'editorDistributeHorizontal',
+ onClick: close(distributeHorizontally),
+ },
+ {
+ name: 'Vertical',
+ icon: 'editorDistributeVertical',
+ onClick: close(distributeVertically),
+ },
+ ],
+ },
+ };
+ };
+
private _getGroupMenuItems = (
close: (fn: () => void) => () => void
): EuiContextMenuPanelItemDescriptor[] => {
@@ -341,12 +451,21 @@ export class SidebarHeader extends Component {
},
];
+ const fillMenu = ({ menuItem, panel }: MenuTuple) => {
+ items.push(menuItem); // add Order menu item to first panel
+ panels.push(panel); // add nested panel for layers controls
+ };
+
if (showLayerControls) {
- const { menuItem, panel } = this._getLayerMenuItems();
- // add Order menu item to first panel
- items.push(menuItem);
- // add nested panel for layers controls
- panels.push(panel);
+ fillMenu(this._getLayerMenuItems());
+ }
+
+ if (this.props.selectedNodes.length > 1) {
+ fillMenu(this._getAlignmentMenuItems(close));
+ }
+
+ if (this.props.selectedNodes.length > 2) {
+ fillMenu(this._getDistributionMenuItems(close));
}
items.push({
diff --git a/x-pack/legacy/plugins/canvas/public/lib/aeroelastic/layout.js b/x-pack/legacy/plugins/canvas/public/lib/aeroelastic/layout.js
index 26e4ab3cd4b72..c534b7a6058e5 100644
--- a/x-pack/legacy/plugins/canvas/public/lib/aeroelastic/layout.js
+++ b/x-pack/legacy/plugins/canvas/public/lib/aeroelastic/layout.js
@@ -26,12 +26,15 @@ import {
cascadeProperties,
draggingShape,
getAdHocChildrenAnnotations,
+ getAlignAction,
+ getAlignDistributeTransformIntents,
getAlignmentGuideAnnotations,
getAlterSnapGesture,
getAnnotatedShapes,
getConfiguration,
getConstrainedShapesWithPreexistingAnnotations,
getCursor,
+ getDistributeAction,
getDraggedPrimaryShape,
getFocusedShape,
getGroupAction,
@@ -89,9 +92,7 @@ const mouseTransformState = select(getMouseTransformState)(
dragVector
);
-const mouseTransformGesture = select(getMouseTransformGesture)(mouseTransformState);
-
-const transformGestures = mouseTransformGesture;
+const directManipulationTransformGestures = select(getMouseTransformGesture)(mouseTransformState);
const selectedShapeObjects = select(getSelectedShapeObjects)(scene, shapes);
@@ -117,9 +118,9 @@ const symmetricManipulation = optionHeld; // as in comparable software applicati
const resizeManipulator = select(getResizeManipulator)(configuration, symmetricManipulation);
-const transformIntents = select(getTransformIntents)(
+const directManipulationTransformIntents = select(getTransformIntents)(
configuration,
- transformGestures,
+ directManipulationTransformGestures,
selectedShapes,
shapes,
cursorPosition,
@@ -127,6 +128,22 @@ const transformIntents = select(getTransformIntents)(
resizeManipulator
);
+const alignAction = select(getAlignAction)(actionEvent);
+const distributeAction = select(getDistributeAction)(actionEvent);
+
+const alignDistributeTransformIntents = select(getAlignDistributeTransformIntents)(
+ alignAction,
+ distributeAction,
+ shapes,
+ selectedShapes
+);
+
+const commandTransformIntents = alignDistributeTransformIntents; // will expand in the future, eg. nudge
+
+const transformIntents = select((directIntents, commandIntents) =>
+ directIntents.concat(commandIntents)
+)(directManipulationTransformIntents, commandTransformIntents);
+
const transformedShapes = select(applyLocalTransforms)(shapes, transformIntents);
const draggedPrimaryShape = select(getDraggedPrimaryShape)(shapes, draggedShape);
diff --git a/x-pack/legacy/plugins/canvas/public/lib/aeroelastic/layout_functions.js b/x-pack/legacy/plugins/canvas/public/lib/aeroelastic/layout_functions.js
index 720ebe79d8139..e31e55b89751d 100644
--- a/x-pack/legacy/plugins/canvas/public/lib/aeroelastic/layout_functions.js
+++ b/x-pack/legacy/plugins/canvas/public/lib/aeroelastic/layout_functions.js
@@ -72,6 +72,8 @@ const connectorVertices = [
[[-1, 0], [-1, -1]],
];
+const cornerVertices = [[-1, -1], [1, -1], [-1, 1], [1, 1]];
+
const resizeMultiplierHorizontal = { left: -1, center: 0, right: 1 };
const resizeMultiplierVertical = { top: -1, center: 0, bottom: 1 };
@@ -89,6 +91,38 @@ const bidirectionalCursors = {
'315': 'nwse-resize',
};
+const identityAABB = () => [[Infinity, Infinity], [-Infinity, -Infinity]];
+
+const extend = ([[xMin, yMin], [xMax, yMax]], [x0, y0], [x1, y1]) => [
+ [Math.min(xMin, x0, x1), Math.min(yMin, y0, y1)],
+ [Math.max(xMax, x0, x1), Math.max(yMax, y0, y1)],
+];
+
+const shapeAABB = (shape, prevOuter) =>
+ cornerVertices.reduce((prevInner, xyVertex) => {
+ const cornerPoint = normalize(
+ mvMultiply(shape.transformMatrix, [shape.a * xyVertex[0], shape.b * xyVertex[1], 0, 1])
+ );
+ return extend(prevInner, cornerPoint, cornerPoint);
+ }, prevOuter);
+
+const shapesAABB = shapes =>
+ shapes.reduce(
+ (prevOuter, shape) => extend(prevOuter, ...shapeAABB(shape, prevOuter)),
+ identityAABB()
+ );
+
+const projectAABB = ([[xMin, yMin], [xMax, yMax]]) => {
+ const a = (xMax - xMin) / 2;
+ const b = (yMax - yMin) / 2;
+ const xTranslate = xMin + a;
+ const yTranslate = yMin + b;
+ const zTranslate = 0;
+ const localTransformMatrix = translate(xTranslate, yTranslate, zTranslate);
+ const rigTransform = translate(-xTranslate, -yTranslate, -zTranslate);
+ return { a, b, localTransformMatrix, rigTransform };
+};
+
// returns the currently dragged shape, or a falsey value otherwise
export const draggingShape = ({ draggedShape, shapes }, hoveredShape, down, mouseDowned) => {
const dragInProgress =
@@ -331,6 +365,97 @@ const fromScreen = currentTransform => transform => {
}
};
+const horizontalToIndex = horizontal => (horizontal ? 0 : 1);
+
+const anchorAABB = (aabb, anchorDirection, horizontal) => {
+ const dimension = horizontalToIndex(horizontal);
+ if (anchorDirection === 0) {
+ return (aabb[0][dimension] + aabb[1][dimension]) / 2; // midpoint
+ } else {
+ const index = (anchorDirection + 1) / 2; // {-1, 1} -> {0, 1} for array lookup
+ return aabb[index][dimension];
+ }
+};
+
+export const getAlignDistributeTransformIntents = (
+ alignAction,
+ distributeAction,
+ shapes,
+ selectedShapes
+) => {
+ // at most, only one of them can happen
+ if (selectedShapes.length !== 1 || selectedShapes[0].subtype !== 'adHocGroup') {
+ return [];
+ }
+
+ const group = selectedShapes[0];
+ const children = shapes.filter(s => s.parent === group.id && s.type !== 'annotation');
+
+ if (alignAction && children.length > 1) {
+ const { controlledAnchor, horizontal } = alignAction;
+ const groupBoundingBox = shapeAABB(group, identityAABB());
+ const groupAnchor = anchorAABB(groupBoundingBox, controlledAnchor, horizontal);
+ const results = children.map(c => {
+ const childBoundingBox = shapeAABB(c, identityAABB());
+ const childAnchor = anchorAABB(childBoundingBox, controlledAnchor, horizontal);
+ const delta = groupAnchor - childAnchor;
+ return {
+ cumulativeTransforms: [translate(horizontal ? delta : 0, horizontal ? 0 : delta, 0)],
+ shapes: [c.id],
+ };
+ });
+ return results;
+ } else if (distributeAction && children.length > 2) {
+ const { horizontal } = distributeAction;
+ const { a: A, b: B } = group;
+ const groupBoundingBox = shapeAABB(group, identityAABB());
+ const groupAnchor = anchorAABB(groupBoundingBox, -1, horizontal);
+ const dimension = horizontalToIndex(horizontal);
+ const childrenBoxes2D = children.map(c => shapeAABB(c, identityAABB()));
+ const childrenAnchors = childrenBoxes2D.map(childBoundingBox =>
+ anchorAABB(childBoundingBox, -1, horizontal)
+ );
+ const childrenBoxes1D = childrenBoxes2D.map(box2D => [
+ box2D[0][dimension],
+ box2D[1][dimension],
+ ]);
+ const childrenCenters = childrenBoxes1D.map(box1D => (box1D[1] + box1D[0]) / 2);
+ const childrenSizes = childrenBoxes1D.map(box1D => box1D[1] - box1D[0]);
+ const totalChildrenSize = childrenSizes.reduce((a, b) => a + b, 0);
+ const groupSize = horizontal ? 2 * A : 2 * B;
+ const totalFreeSpace = groupSize - totalChildrenSize;
+ const gapCount = children.length - 1;
+ const gap = totalFreeSpace / gapCount;
+ const childrenIndex = [...Array(children.length)].map((_, i) => i);
+ const sortedChildrenIndex = childrenIndex.sort(
+ (i, j) => childrenCenters[i] - childrenCenters[j]
+ );
+ const reduction = sortedChildrenIndex.reduce(
+ ({ cursor, deltas }, i) => {
+ const size = childrenSizes[i];
+ const originalLeft = childrenAnchors[i];
+ const desiredLeft = cursor;
+ const delta = desiredLeft - originalLeft;
+ const nextLeft = cursor + size + gap;
+ return {
+ cursor: nextLeft,
+ deltas: [...deltas, delta],
+ };
+ },
+ { cursor: groupAnchor, deltas: [] }
+ );
+ const results = reduction.deltas.map((delta, ii) => {
+ const i = sortedChildrenIndex[ii];
+ return {
+ cumulativeTransforms: [translate(horizontal ? delta : 0, horizontal ? 0 : delta, 0)],
+ shapes: [children[i].id],
+ };
+ });
+ return results;
+ }
+ return [];
+};
+
const shapeApplyLocalTransforms = intents => shape => {
const transformIntents = flatten(
intents
@@ -856,38 +981,6 @@ const resizeShapeSnap = (
}
};
-const extend = ([[xMin, yMin], [xMax, yMax]], [x0, y0], [x1, y1]) => [
- [Math.min(xMin, x0, x1), Math.min(yMin, y0, y1)],
- [Math.max(xMax, x0, x1), Math.max(yMax, y0, y1)],
-];
-
-const cornerVertices = [[-1, -1], [1, -1], [-1, 1], [1, 1]];
-
-const getAABB = shapes =>
- shapes.reduce(
- (prevOuter, shape) => {
- const shapeBounds = cornerVertices.reduce((prevInner, xyVertex) => {
- const cornerPoint = normalize(
- mvMultiply(shape.transformMatrix, [shape.a * xyVertex[0], shape.b * xyVertex[1], 0, 1])
- );
- return extend(prevInner, cornerPoint, cornerPoint);
- }, prevOuter);
- return extend(prevOuter, ...shapeBounds);
- },
- [[Infinity, Infinity], [-Infinity, -Infinity]]
- );
-
-const projectAABB = ([[xMin, yMin], [xMax, yMax]]) => {
- const a = (xMax - xMin) / 2;
- const b = (yMax - yMin) / 2;
- const xTranslate = xMin + a;
- const yTranslate = yMin + b;
- const zTranslate = 0; // todo fix hack that ensures that grouped elements continue to be selectable
- const localTransformMatrix = translate(xTranslate, yTranslate, zTranslate);
- const rigTransform = translate(-xTranslate, -yTranslate, -zTranslate);
- return { a, b, localTransformMatrix, rigTransform };
-};
-
const dissolveGroups = (groupsToDissolve, shapes, selectedShapes) => {
return {
shapes: shapes
@@ -923,7 +1016,7 @@ const idMatch = shape => s => s.id === shape.id;
const idsMatch = selectedShapes => shape => selectedShapes.find(idMatch(shape));
const axisAlignedBoundingBoxShape = (config, shapesToBox) => {
- const axisAlignedBoundingBox = getAABB(shapesToBox);
+ const axisAlignedBoundingBox = shapesAABB(shapesToBox);
const { a, b, localTransformMatrix, rigTransform } = projectAABB(axisAlignedBoundingBox);
const id = getId(config.groupName, shapesToBox.map(s => s.id).join('|'));
const aabbShape = {
@@ -1344,6 +1437,56 @@ export const getGroupAction = (action, mouseIsDown) => {
return !mouseIsDown && (event === 'group' || event === 'ungroup') ? event : null;
};
+const alignments = {
+ // in the future, we might want to snap eg. the element center to the left edge
+ // controlling anchor is which side (-1: lower, eg. left; 0: central) of the container
+ // we want to snap to; controlled anchor specifies which side of the element snaps
+ alignLeft: {
+ type: 'alignLeftAction',
+ horizontal: true,
+ controlledAnchor: -1,
+ controllingAnchor: -1,
+ },
+ alignCenter: {
+ type: 'alignCenterAction',
+ horizontal: true,
+ controlledAnchor: 0,
+ controllingAnchor: 0,
+ },
+ alignRight: {
+ type: 'alignRightAction',
+ horizontal: true,
+ controlledAnchor: 1,
+ controllingAnchor: 1,
+ },
+ alignTop: {
+ type: 'alignTopAction',
+ horizontal: false,
+ controlledAnchor: -1,
+ controllingAnchor: -1,
+ },
+ alignMiddle: {
+ type: 'alignMiddleAction',
+ horizontal: false,
+ controlledAnchor: 0,
+ controllingAnchor: 0,
+ },
+ alignBottom: {
+ type: 'alignBottomAction',
+ horizontal: false,
+ controlledAnchor: 1,
+ controllingAnchor: 1,
+ },
+};
+
+const distributions = {
+ distributeHorizontally: { type: 'distributeHorizontallyAction', horizontal: true },
+ distributeVertically: { type: 'distributeVerticallyAction', horizontal: false },
+};
+
+export const getAlignAction = action => alignments[action && action.event] || null;
+export const getDistributeAction = action => distributions[action && action.event] || null;
+
export const getGroupedSelectedShapes = ({ selectedShapes }) => selectedShapes;
export const getGroupedSelectedPrimaryShapeIds = selectedShapes => selectedShapes.map(primaryShape);
diff --git a/x-pack/legacy/plugins/canvas/public/lib/element_handler_creators.ts b/x-pack/legacy/plugins/canvas/public/lib/element_handler_creators.ts
index c698001e75fc0..348c357be3076 100644
--- a/x-pack/legacy/plugins/canvas/public/lib/element_handler_creators.ts
+++ b/x-pack/legacy/plugins/canvas/public/lib/element_handler_creators.ts
@@ -100,6 +100,25 @@ export const basicHandlerCreators = {
},
};
+// handlers for alignment and distribution
+export const alignmentDistributionHandlerCreators = Object.assign(
+ {},
+ ...[
+ 'alignLeft',
+ 'alignCenter',
+ 'alignRight',
+ 'alignTop',
+ 'alignMiddle',
+ 'alignBottom',
+ 'distributeHorizontally',
+ 'distributeVertically',
+ ].map((event: string) => ({
+ [event]: ({ commit }: Props) => (): void => {
+ commit('actionEvent', { event });
+ },
+ }))
+);
+
// handlers for group and ungroup
export const groupHandlerCreators = {
groupNodes: ({ commit }: Props) => (): void => {