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 => {