diff --git a/x-pack/plugins/canvas/public/components/workpad_page/event_handlers.js b/x-pack/plugins/canvas/public/components/workpad_page/event_handlers.js index e37c703de4ba6..1e9f3bc588adf 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/event_handlers.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/event_handlers.js @@ -95,7 +95,7 @@ const handleMouseDown = (commit, e, isEditable) => { const keyCode = key => (key === 'Meta' ? 'MetaLeft' : 'Key' + key.toUpperCase()); -const isNotTextInput = ({ tagName, type }) => { +const isTextInput = ({ tagName, type }) => { // input types that aren't variations of text input const nonTextInputs = [ 'button', @@ -111,11 +111,11 @@ const isNotTextInput = ({ tagName, type }) => { switch (tagName.toLowerCase()) { case 'input': - return nonTextInputs.includes(type); + return !nonTextInputs.includes(type); case 'textarea': - return false; - default: return true; + default: + return false; } }; @@ -125,7 +125,7 @@ const handleKeyDown = (commit, e, isEditable, remove) => { const { key, target } = e; if (isEditable) { - if (isNotTextInput(target) && (key === 'Backspace' || key === 'Delete')) { + if ((key === 'Backspace' || key === 'Delete') && !isTextInput(target)) { e.preventDefault(); remove(); } else if (!modifierKey(key)) { @@ -137,6 +137,16 @@ const handleKeyDown = (commit, e, isEditable, remove) => { } }; +const handleKeyPress = (commit, e, isEditable) => { + const { key, target } = e; + const upcaseKey = key && key.toUpperCase(); + if (isEditable && !isTextInput(target) && 'GU'.indexOf(upcaseKey) !== -1) { + commit('actionEvent', { + event: upcaseKey === 'G' ? 'group' : 'ungroup', + }); + } +}; + const handleKeyUp = (commit, { key }, isEditable) => { if (isEditable && !modifierKey(key)) { commit('keyboardEvent', { @@ -150,6 +160,7 @@ export const withEventHandlers = withHandlers({ onMouseDown: props => e => handleMouseDown(props.commit, e, props.isEditable), onMouseMove: props => e => handleMouseMove(props.commit, e, props.isEditable), onKeyDown: props => e => handleKeyDown(props.commit, e, props.isEditable, props.remove), + onKeyPress: props => e => handleKeyPress(props.commit, e, props.isEditable), onKeyUp: props => e => handleKeyUp(props.commit, e, props.isEditable), onWheel: props => e => handleWheel(props.commit, e, props.isEditable), resetHandler: () => () => resetHandler(), diff --git a/x-pack/plugins/canvas/public/components/workpad_page/index.js b/x-pack/plugins/canvas/public/components/workpad_page/index.js index be63a36409e6e..c48326896a000 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js @@ -10,23 +10,25 @@ import { compose, withState, withProps } from 'recompose'; import { notify } from '../../lib/notify'; import { aeroelastic } from '../../lib/aeroelastic_kibana'; import { setClipboardData, getClipboardData } from '../../lib/clipboard'; -import { removeElements, duplicateElement } from '../../state/actions/elements'; +import { cloneSubgraphs } from '../../lib/clone_subgraphs'; +import { removeElements, rawDuplicateElement } from '../../state/actions/elements'; import { getFullscreen, canUserWrite } from '../../state/selectors/app'; -import { getElements, isWriteable } from '../../state/selectors/workpad'; +import { getNodes, isWriteable } from '../../state/selectors/workpad'; +import { flatten } from '../../lib/aeroelastic/functional'; import { withEventHandlers } from './event_handlers'; import { WorkpadPage as Component } from './workpad_page'; const mapStateToProps = (state, ownProps) => { return { isEditable: !getFullscreen(state) && isWriteable(state) && canUserWrite(state), - elements: getElements(state, ownProps.page.id), + elements: getNodes(state, ownProps.page.id), }; }; const mapDispatchToProps = dispatch => { return { - duplicateElement: pageId => selectedElement => - dispatch(duplicateElement(selectedElement, pageId)), + rawDuplicateElement: pageId => (selectedElement, root) => + dispatch(rawDuplicateElement(selectedElement, pageId, root)), removeElements: pageId => elementIds => dispatch(removeElements(elementIds, pageId)), }; }; @@ -78,14 +80,35 @@ export const WorkpadPage = compose( setUpdateCount, page, elements: pageElements, + rawDuplicateElement, removeElements, - duplicateElement, }) => { - const { shapes, selectedLeafShapes = [], cursor } = aeroelastic.getStore( + const { shapes, selectedPrimaryShapes = [], cursor } = aeroelastic.getStore( page.id ).currentScene; const elementLookup = new Map(pageElements.map(element => [element.id, element])); - const selectedElementIds = selectedLeafShapes; + const recurseGroupTree = shapeId => { + return [ + shapeId, + ...flatten( + shapes + .filter(s => s.parent === shapeId && s.type !== 'annotation') + .map(s => s.id) + .map(recurseGroupTree) + ), + ]; + }; + const selectedPrimaryShapeObjects = selectedPrimaryShapes.map(id => + shapes.find(s => s.id === id) + ); + const selectedPersistentPrimaryShapes = flatten( + selectedPrimaryShapeObjects.map(shape => + shape.subtype === 'adHocGroup' + ? shapes.filter(s => s.parent === shape.id && s.type !== 'annotation').map(s => s.id) + : [shape.id] + ) + ); + const selectedElementIds = flatten(selectedPersistentPrimaryShapes.map(recurseGroupTree)); const selectedElements = []; const elements = shapes.map(shape => { let element = null; @@ -114,21 +137,25 @@ export const WorkpadPage = compose( }, copyElements: () => { if (selectedElements.length) { - setClipboardData(selectedElements); + setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes }); notify.success('Copied element to clipboard'); } }, cutElements: () => { if (selectedElements.length) { - setClipboardData(selectedElements); + setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes }); removeElements(page.id)(selectedElementIds); notify.success('Copied element to clipboard'); } }, pasteElements: () => { - const elements = JSON.parse(getClipboardData()); - if (elements) { - elements.map(element => duplicateElement(page.id)(element)); + const { selectedElements, rootShapes } = JSON.parse(getClipboardData()); + const indices = rootShapes.map(r => selectedElements.findIndex(s => s.id === r)); + const clonedElements = selectedElements && cloneSubgraphs(selectedElements); + if (clonedElements) { + clonedElements.map((element, index) => + rawDuplicateElement(page.id)(element, indices.indexOf(index) >= 0) + ); } }, }; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.js b/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.js index 30cccb1d398c5..0f9c81ca25e2b 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.js @@ -65,6 +65,7 @@ export class WorkpadPage extends PureComponent { isEditable, onDoubleClick, onKeyDown, + onKeyPress, onKeyUp, onMouseDown, onMouseMove, @@ -108,6 +109,7 @@ export class WorkpadPage extends PureComponent { onMouseUp={onMouseUp} onMouseDown={onMouseDown} onKeyDown={onKeyDown} + onKeyPress={onKeyPress} onKeyUp={onKeyUp} onDoubleClick={onDoubleClick} onAnimationEnd={onAnimationEnd} @@ -150,7 +152,7 @@ export class WorkpadPage extends PureComponent { default: return []; } - } else if (element.subtype !== 'adHocGroup') { + } else if (element.type !== 'group') { return ; } }) diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/config.js b/x-pack/plugins/canvas/public/lib/aeroelastic/config.js index 3abc805099d50..da8c8f879ab20 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/config.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/config.js @@ -17,13 +17,17 @@ const groupName = 'group'; const groupResize = true; const guideDistance = 3; const hoverAnnotationName = 'hoverAnnotation'; +const hoverLift = 100; const intraGroupManipulation = false; +const intraGroupSnapOnly = false; +const persistentGroupName = 'persistentGroup'; const resizeAnnotationOffset = 0; const resizeAnnotationOffsetZ = 0.1; // causes resize markers to be slightly above the shape plane const resizeAnnotationSize = 10; const resizeAnnotationConnectorOffset = 0; //resizeAnnotationSize //+ 2 const resizeConnectorName = 'resizeConnector'; const rotateAnnotationOffset = 12; +const rotationEpsilon = 0.001; const rotationHandleName = 'rotationHandle'; const rotationHandleSize = 14; const resizeHandleName = 'resizeHandle'; @@ -43,8 +47,11 @@ module.exports = { groupResize, guideDistance, hoverAnnotationName, + hoverLift, intraGroupManipulation, + intraGroupSnapOnly, minimumElementSize, + persistentGroupName, resizeAnnotationOffset, resizeAnnotationOffsetZ, resizeAnnotationSize, @@ -52,6 +59,7 @@ module.exports = { resizeConnectorName, resizeHandleName, rotateAnnotationOffset, + rotationEpsilon, rotateSnapInPixels, rotationHandleName, rotationHandleSize, diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js b/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js index 2df87fecdb562..1d5b3919c92cb 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js @@ -87,13 +87,21 @@ const not = fun => (...args) => !fun(...args); const removeDuplicates = (idFun, a) => a.filter((d, i) => a.findIndex(s => idFun(s) === idFun(d)) === i); -const epsilon = 1 / 1000; -const applyTolerance = d => Math.round(d / epsilon) * epsilon; +const arrayToMap = a => Object.assign({}, ...a.map(d => ({ [d]: true }))); + +const subMultitree = (pk, fk, elements, roots) => { + const getSubgraphs = roots => { + const children = flatten(roots.map(r => elements.filter(e => fk(e) === pk(r)))); + return [...roots, ...(children.length && getSubgraphs(children, elements))]; + }; + return getSubgraphs(roots); +}; module.exports = { - applyTolerance, + arrayToMap, disjunctiveUnion, flatten, + subMultitree, identity, log, map, diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js b/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js index 271e66efcdf5c..331e95c97cc03 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js @@ -85,8 +85,7 @@ const shapesAt = (shapes, { x, y }) => const getExtremum = (transformMatrix, a, b) => matrix.normalize(matrix.mvMultiply(transformMatrix, [a, b, 0, 1])); -const landmarkPoint = ({ localTransformMatrix, a, b }, k, l) => - getExtremum(localTransformMatrix, k * a, l * b); +const landmarkPoint = (a, b, transformMatrix, k, l) => getExtremum(transformMatrix, k * a, l * b); module.exports = { landmarkPoint, diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js index d527ad3c16587..c933e54b705f1 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js @@ -47,10 +47,6 @@ const metaHeld = select(appleKeyboard ? e => e.metaKey : e => e.altKey)(keyFromM const optionHeld = select(appleKeyboard ? e => e.altKey : e => e.ctrlKey)(keyFromMouse); const shiftHeld = select(e => e.shiftKey)(keyFromMouse); -// retaining this for now to avoid removing dependent inactive code `keyTransformGesture` from layout.js -// todo remove this, and `keyTransformGesture` from layout.js and do accessibility outside the layout engine -const pressedKeys = () => ({}); - const cursorPosition = selectReduce((previous, position) => position || previous, { x: 0, y: 0 })( rawCursorPosition ); @@ -75,7 +71,12 @@ const mouseIsDown = selectReduce( false )(mouseButtonEvent); -const gestureEnd = select(next => next && next.event === 'mouseUp')(mouseButtonEvent); +const gestureEnd = select( + action => + action && + (action.type === 'actionEvent' || + (action.type === 'mouseEvent' && action.payload.event === 'mouseUp')) +)(primaryUpdate); /** * mouseButtonStateTransitions @@ -138,7 +139,12 @@ const dragVector = select(({ buttonState, downX, downY }, { x, y }) => ({ y1: y, }))(mouseButtonState, cursorPosition); +const actionEvent = select(action => (action.type === 'actionEvent' ? action.payload : null))( + primaryUpdate +); + module.exports = { + actionEvent, dragging, dragVector, cursorPosition, @@ -148,6 +154,5 @@ module.exports = { mouseDowned, mouseIsDown, optionHeld, - pressedKeys, shiftHeld, }; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js index 8994eab961d34..563e0f817047d 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -const { select, makeUid } = require('./state'); +const { select } = require('./state'); +const { getId } = require('./../../lib/get_id'); const { + actionEvent, dragging, dragVector, cursorPosition, @@ -16,7 +18,6 @@ const { mouseDowned, mouseIsDown, optionHeld, - pressedKeys, shiftHeld, } = require('./gestures'); @@ -25,10 +26,7 @@ const { shapesAt, landmarkPoint } = require('./geometry'); const matrix = require('./matrix'); const matrix2d = require('./matrix2d'); -const config = require('./config'); - const { - applyTolerance, disjunctiveUnion, identity, flatten, @@ -44,6 +42,9 @@ const { const primaryUpdate = state => state.primaryUpdate; const scene = state => state.currentScene; +const configuration = state => { + return state.configuration; +}; /** * Pure calculations @@ -64,17 +65,17 @@ const draggingShape = ({ draggedShape, shapes }, hoveredShape, down, mouseDowned const shapes = select(scene => scene.shapes)(scene); -const hoveredShapes = select((shapes, cursorPosition) => +const hoveredShapes = select((configuration, shapes, cursorPosition) => shapesAt( shapes.filter( // second AND term excludes intra-group element hover (and therefore drag & drop), todo: remove this current limitation s => (s.type !== 'annotation' || s.interactive) && - (config.intraGroupManipulation || !s.parent || s.type === 'annotation') + (configuration.intraGroupManipulation || !s.parent || s.type === 'annotation') ), cursorPosition ) -)(shapes, cursorPosition); +)(configuration, shapes, cursorPosition); const depthIndex = 0; const hoveredShape = select(hoveredShapes => @@ -94,58 +95,6 @@ const focusedShapes = select((shapes, focusedShape) => shapes.filter(shape => focusedShape && shape.id === focusedShape.id) )(shapes, focusedShape); -const keyTransformGesture = select(keys => - config.shortcuts - ? Object.keys(keys) - .map(keypress => { - switch (keypress) { - case 'KeyW': - return { transform: matrix.translate(0, -5, 0) }; - case 'KeyA': - return { transform: matrix.translate(-5, 0, 0) }; - case 'KeyS': - return { transform: matrix.translate(0, 5, 0) }; - case 'KeyD': - return { transform: matrix.translate(5, 0, 0) }; - case 'KeyF': - return { transform: matrix.translate(0, 0, -20) }; - case 'KeyC': - return { transform: matrix.translate(0, 0, 20) }; - case 'KeyX': - return { transform: matrix.rotateX(Math.PI / 45) }; - case 'KeyY': - return { transform: matrix.rotateY(Math.PI / 45 / 1.3) }; - case 'KeyZ': - return { transform: matrix.rotateZ(Math.PI / 45 / 1.6) }; - case 'KeyI': - return { transform: matrix.scale(1, 1.05, 1) }; - case 'KeyJ': - return { transform: matrix.scale(1 / 1.05, 1, 1) }; - case 'KeyK': - return { transform: matrix.scale(1, 1 / 1.05, 1) }; - case 'KeyL': - return { transform: matrix.scale(1.05, 1, 1) }; - case 'KeyP': - return { transform: matrix.perspective(2000) }; - case 'KeyR': - return { transform: matrix.shear(0.1, 0) }; - case 'KeyT': - return { transform: matrix.shear(-0.1, 0) }; - case 'KeyU': - return { transform: matrix.shear(0, 0.1) }; - case 'KeyH': - return { transform: matrix.shear(0, -0.1) }; - case 'KeyM': - return { transform: matrix.UNITMATRIX, sizes: [1.0, 0, 0, 0, 1.0, 0, 10, 0, 1] }; - case 'Backspace': - case 'Delete': - return { transform: matrix.UNITMATRIX, delete: true }; - } - }) - .filter(identity) - : [] -)(pressedKeys); - const alterSnapGesture = select(metaHeld => (metaHeld ? ['relax'] : []))(metaHeld); const multiselectModifier = shiftHeld; // todo abstract out keybindings @@ -184,13 +133,25 @@ const mouseTransformGesture = select(tuple => .map(({ transform, cumulativeTransform }) => ({ transform, cumulativeTransform })) )(mouseTransformState); -const transformGestures = select((keyTransformGesture, mouseTransformGesture) => - keyTransformGesture.concat(mouseTransformGesture) -)(keyTransformGesture, mouseTransformGesture); +const transformGestures = mouseTransformGesture; -const restateShapesEvent = select(action => - action && action.type === 'restateShapesEvent' ? action.payload : null -)(primaryUpdate); +const restateShapesEvent = select(action => { + if (!action || action.type !== 'restateShapesEvent') { + return null; + } + const shapes = action.payload.newShapes; + const local = shape => { + if (!shape.parent) { + return shape.transformMatrix; + } + return matrix.multiply( + matrix.invert(shapes.find(s => s.id === shape.parent).transformMatrix), + shape.transformMatrix + ); + }; + const newShapes = shapes.map(s => ({ ...s, localTransformMatrix: local(s) })); + return { newShapes, uid: action.payload.uid }; +})(primaryUpdate); // directSelect is an API entry point (via the `shapeSelect` action) that lets the client directly specify what thing // is selected, as otherwise selection is driven by gestures and knowledge of element positions @@ -200,11 +161,11 @@ const directSelect = select(action => const selectedShapeObjects = select(scene => scene.selectedShapeObjects || [])(scene); -const singleSelect = (prev, hoveredShapes, metaHeld, uid) => { +const singleSelect = (prev, configuration, hoveredShapes, metaHeld, uid) => { // cycle from top ie. from zero after the cursor position changed ie. !sameLocation const down = true; // this function won't be called otherwise const depthIndex = - config.depthSelect && metaHeld + configuration.depthSelect && metaHeld ? (prev.depthIndex + (down && !prev.down ? 1 : 0)) % hoveredShapes.length : 0; return { @@ -215,7 +176,7 @@ const singleSelect = (prev, hoveredShapes, metaHeld, uid) => { }; }; -const multiSelect = (prev, hoveredShapes, metaHeld, uid, selectedShapeObjects) => { +const multiSelect = (prev, configuration, hoveredShapes, metaHeld, uid, selectedShapeObjects) => { const shapes = hoveredShapes.length > 0 ? disjunctiveUnion(shape => shape.id, selectedShapeObjects, hoveredShapes.slice(0, 1)) // ie. depthIndex of 0, if any @@ -251,6 +212,7 @@ const contentShapes = (allShapes, shapes) => shapes.map(contentShape(allShapes)) const selectionState = select( ( prev, + configuration, selectedShapeObjects, hoveredShapes, { down, uid }, @@ -280,11 +242,12 @@ const selectionState = select( if (mouseButtonUp || (uidUnchanged && !directSelect)) { return { ...prev, down, uid, metaHeld }; } - const selectFunction = config.singleSelect || !multiselect ? singleSelect : multiSelect; - return selectFunction(prev, hoveredShapes, metaHeld, uid, selectedShapeObjects); + const selectFunction = configuration.singleSelect || !multiselect ? singleSelect : multiSelect; + return selectFunction(prev, configuration, hoveredShapes, metaHeld, uid, selectedShapeObjects); } )( selectedShapesPrev, + configuration, selectedShapeObjects, hoveredShapes, mouseButton, @@ -300,11 +263,11 @@ const selectedShapes = select(selectionTuple => { const selectedShapeIds = select(shapes => shapes.map(shape => shape.id))(selectedShapes); -const primaryShape = shape => shape.parent || shape.id; // fixme unify with contentShape +const primaryShape = shape => (shape.type === 'annotation' ? shape.parent : shape.id); // fixme unify with contentShape const selectedPrimaryShapeIds = select(shapes => shapes.map(primaryShape))(selectedShapes); -const rotationManipulation = ({ +const rotationManipulation = configuration => ({ shape, directShape, cursorPosition: { x, y }, @@ -331,50 +294,26 @@ const rotationManipulation = ({ ); const relaxed = alterSnapGesture.indexOf('relax') !== -1; const newSnappedAngle = - pixelDifference < config.rotateSnapInPixels && !relaxed ? closest45deg : newAngle; + pixelDifference < configuration.rotateSnapInPixels && !relaxed ? closest45deg : newAngle; const result = matrix.rotateZ(oldAngle - newSnappedAngle); return { transforms: [result], shapes: [shape.id] }; }; -/* upcoming functionality -const centeredScaleManipulation = ({ shape, directShape, cursorPosition: { x, y } }) => { - // scaling such that the center remains in place (ie. the other side of the shape can grow/shrink) - if (!shape || !directShape) return { transforms: [], shapes: [] }; - const center = shape.transformMatrix; - const vector = matrix.mvMultiply( - matrix.multiply(center, directShape.localTransformMatrix), - matrix.ORIGIN - ); - const shapeCenter = matrix.mvMultiply(center, matrix.ORIGIN); - const horizontalRatio = - directShape.horizontalPosition === 'center' - ? 1 - : Math.max(0.5, (x - shapeCenter[0]) / (vector[0] - shapeCenter[0])); - const verticalRatio = - directShape.verticalPosition === 'center' - ? 1 - : Math.max(0.5, (y - shapeCenter[1]) / (vector[1] - shapeCenter[1])); - const result = matrix.scale(horizontalRatio, verticalRatio, 1); - return { transforms: [result], shapes: [shape.id] }; -}; -*/ - const resizeMultiplierHorizontal = { left: -1, center: 0, right: 1 }; const resizeMultiplierVertical = { top: -1, center: 0, bottom: 1 }; const xNames = { '-1': 'left', '0': 'center', '1': 'right' }; const yNames = { '-1': 'top', '0': 'center', '1': 'bottom' }; -const minimumSize = ({ a, b, baseAB }, vector) => { +const minimumSize = (min, { a, b, baseAB }, vector) => { // don't allow an element size of less than the minimumElementSize // todo switch to matrix algebra - const min = config.minimumElementSize; return [ Math.max(baseAB ? min - baseAB[0] : min - a, vector[0]), Math.max(baseAB ? min - baseAB[1] : min - b, vector[1]), ]; }; -const centeredResizeManipulation = ({ gesture, shape, directShape }) => { +const centeredResizeManipulation = configuration => ({ gesture, shape, directShape }) => { const transform = gesture.cumulativeTransform; // scaling such that the center remains in place (ie. the other side of the shape can grow/shrink) if (!shape || !directShape) { @@ -394,7 +333,7 @@ const centeredResizeManipulation = ({ gesture, shape, directShape }) => { 0, ]; const orientedVector = matrix2d.componentProduct(vector, orientationMask); - const cappedOrientedVector = minimumSize(shape, orientedVector); + const cappedOrientedVector = minimumSize(configuration.minimumElementSize, shape, orientedVector); return { cumulativeTransforms: [], cumulativeSizes: [gesture.sizes || matrix2d.translate(...cappedOrientedVector)], @@ -402,7 +341,7 @@ const centeredResizeManipulation = ({ gesture, shape, directShape }) => { }; }; -const asymmetricResizeManipulation = ({ gesture, shape, directShape }) => { +const asymmetricResizeManipulation = configuration => ({ gesture, shape, directShape }) => { const transform = gesture.cumulativeTransform; // scaling such that the center remains in place (ie. the other side of the shape can grow/shrink) if (!shape || !directShape) { @@ -418,7 +357,7 @@ const asymmetricResizeManipulation = ({ gesture, shape, directShape }) => { 0, ]; const orientedVector = matrix2d.componentProduct(vector, orientationMask); - const cappedOrientedVector = minimumSize(shape, orientedVector); + const cappedOrientedVector = minimumSize(configuration.minimumElementSize, shape, orientedVector); const antiRotatedVector = matrix.mvMultiply( matrix.multiply( @@ -448,6 +387,7 @@ const directShapeTranslateManipulation = (cumulativeTransforms, directShapes) => }; const rotationAnnotationManipulation = ( + configuration, directTransforms, directShapes, allShapes, @@ -456,7 +396,9 @@ const rotationAnnotationManipulation = ( ) => { const shapeIds = directShapes.map( shape => - shape.type === 'annotation' && shape.subtype === config.rotationHandleName && shape.parent + shape.type === 'annotation' && + shape.subtype === configuration.rotationHandleName && + shape.parent ); const shapes = shapeIds.map(id => id && allShapes.find(shape => shape.id === id)); const tuples = flatten( @@ -470,13 +412,21 @@ const rotationAnnotationManipulation = ( })) ) ); - return tuples.map(rotationManipulation); + return tuples.map(rotationManipulation(configuration)); }; -const resizeAnnotationManipulation = (transformGestures, directShapes, allShapes, manipulator) => { +const resizeAnnotationManipulation = ( + configuration, + transformGestures, + directShapes, + allShapes, + manipulator +) => { const shapeIds = directShapes.map( shape => - shape.type === 'annotation' && shape.subtype === config.resizeHandleName && shape.parent + shape.type === 'annotation' && + shape.subtype === configuration.resizeHandleName && + shape.parent ); const shapes = shapeIds.map(id => id && allShapes.find(shape => shape.id === id)); const tuples = flatten( @@ -489,26 +439,49 @@ const resizeAnnotationManipulation = (transformGestures, directShapes, allShapes const symmetricManipulation = optionHeld; // as in comparable software applications, todo: make configurable -const resizeManipulator = select(toggle => - toggle ? centeredResizeManipulation : asymmetricResizeManipulation -)(symmetricManipulation); +const resizeManipulator = select((configuration, toggle) => + (toggle ? centeredResizeManipulation : asymmetricResizeManipulation)(configuration) +)(configuration, symmetricManipulation); const transformIntents = select( - (transformGestures, directShapes, shapes, cursorPosition, alterSnapGesture, manipulator) => [ + ( + configuration, + transformGestures, + directShapes, + shapes, + cursorPosition, + alterSnapGesture, + manipulator + ) => [ ...directShapeTranslateManipulation( transformGestures.map(g => g.cumulativeTransform), directShapes ), ...rotationAnnotationManipulation( + configuration, transformGestures.map(g => g.transform), directShapes, shapes, cursorPosition, alterSnapGesture ), - ...resizeAnnotationManipulation(transformGestures, directShapes, shapes, manipulator), + ...resizeAnnotationManipulation( + configuration, + transformGestures, + directShapes, + shapes, + manipulator + ), ] -)(transformGestures, selectedShapes, shapes, cursorPosition, alterSnapGesture, resizeManipulator); +)( + configuration, + transformGestures, + selectedShapes, + shapes, + cursorPosition, + alterSnapGesture, + resizeManipulator +); const fromScreen = currentTransform => transform => { const isTranslate = transform[12] !== 0 || transform[13] !== 0; @@ -627,17 +600,32 @@ const getUpstreams = (shapes, shape) => const snappedA = shape => shape.a + (shape.snapResizeVector ? shape.snapResizeVector[0] : 0); const snappedB = shape => shape.b + (shape.snapResizeVector ? shape.snapResizeVector[1] : 0); -const cascadeTransforms = (shapes, shape) => { +const cascadeUnsnappedTransforms = (shapes, shape) => { + if (!shape.parent) { + return shape.localTransformMatrix; + } // boost for common case of toplevel shape const upstreams = getUpstreams(shapes, shape); const upstreamTransforms = upstreams.map(shape => { - return shape.snapDeltaMatrix - ? matrix.multiply(shape.localTransformMatrix, shape.snapDeltaMatrix) - : shape.localTransformMatrix; + return shape.localTransformMatrix; }); const cascadedTransforms = matrix.reduceTransforms(upstreamTransforms); return cascadedTransforms; }; +const cascadeTransforms = (shapes, shape) => { + const cascade = s => + s.snapDeltaMatrix + ? matrix.multiply(s.localTransformMatrix, s.snapDeltaMatrix) + : s.localTransformMatrix; + if (!shape.parent) { + return cascade(shape); + } // boost for common case of toplevel shape + const upstreams = getUpstreams(shapes, shape); + const upstreamTransforms = upstreams.map(cascade); + const cascadedTransforms = matrix.reduceTransforms(upstreamTransforms); + return cascadedTransforms; +}; + const shapeCascadeProperties = shapes => shape => { return { ...shape, @@ -660,7 +648,7 @@ const nextShapes = select((preexistingShapes, restated) => { const transformedShapes = select(applyLocalTransforms)(nextShapes, transformIntents); -const alignmentGuides = (shapes, guidedShapes, draggedShape) => { +const alignmentGuides = (configuration, shapes, guidedShapes, draggedShape) => { const result = {}; let counter = 0; const extremeHorizontal = resizeMultiplierHorizontal[draggedShape.horizontalPosition]; @@ -678,9 +666,16 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { if (referenceShape.type === 'annotation') { continue; } // fixme avoid this by not letting annotations get in here - if (referenceShape.parent) { + if (!configuration.intraGroupManipulation && referenceShape.parent) { continue; } // for now, don't snap to grouped elements fixme could snap, but make sure transform is gloabl + if ( + configuration.intraGroupSnapOnly && + d.parent !== referenceShape.parent && + d.parent !== referenceShape.id /* allow parent */ + ) { + continue; + } const s = d.id === referenceShape.id ? { @@ -697,7 +692,7 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { continue; } // don't worry about midpoints of the edges, only the center if ( - draggedShape.subtype === config.resizeHandleName && + draggedShape.subtype === configuration.resizeHandleName && !( (extremeHorizontal === k && extremeVertical === l) || // moved corner // moved midpoint on horizontal border @@ -708,13 +703,13 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { ) { continue; } - const D = landmarkPoint(d, k, l); + const D = landmarkPoint(d.a, d.b, cascadeUnsnappedTransforms(shapes, d), k, l); for (let m = -1; m < 2; m++) { for (let n = -1; n < 2; n++) { if ((m && !n) || (!m && n)) { continue; } // don't worry about midpoints of the edges, only the center - const S = landmarkPoint(s, m, n); + const S = landmarkPoint(s.a, s.b, cascadeUnsnappedTransforms(shapes, s), m, n); for (let dim = 0; dim < 2; dim++) { const orthogonalDimension = 1 - dim; const dd = D[dim]; @@ -724,7 +719,7 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { const distance = Math.abs(signedDistance); const currentClosest = result[key]; if ( - Math.round(distance) <= config.guideDistance && + Math.round(distance) <= configuration.guideDistance && (!currentClosest || distance <= currentClosest.distance) ) { const orthogonalValues = [ @@ -741,7 +736,7 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { localTransformMatrix: matrix.translate( dim ? midPoint : ss, dim ? ss : midPoint, - config.atopZ + configuration.atopZ ), a: dim ? radius : 0.5, b: dim ? 0.5 : radius, @@ -781,44 +776,50 @@ const draggedPrimaryShape = select( draggedShape && shapes.find(shape => shape.id === primaryShape(draggedShape)) )(shapes, draggedShape); -const alignmentGuideAnnotations = select((shapes, draggedPrimaryShape, draggedShape) => { - const guidedShapes = draggedPrimaryShape - ? [shapes.find(s => s.id === draggedPrimaryShape.id)].filter(identity) - : []; - return guidedShapes.length - ? alignmentGuides(shapes, guidedShapes, draggedShape).map(shape => ({ - ...shape, - id: config.alignmentGuideName + '_' + shape.id, - type: 'annotation', - subtype: config.alignmentGuideName, - interactive: false, - backgroundColor: 'magenta', - })) - : []; -})(transformedShapes, draggedPrimaryShape, draggedShape); - -const hoverAnnotations = select((hoveredShape, selectedPrimaryShapeIds, draggedShape) => { - return hoveredShape && - hoveredShape.type !== 'annotation' && - selectedPrimaryShapeIds.indexOf(hoveredShape.id) === -1 && - !draggedShape - ? [ - { - ...hoveredShape, - id: config.hoverAnnotationName + '_' + hoveredShape.id, +const alignmentGuideAnnotations = select( + (configuration, shapes, draggedPrimaryShape, draggedShape) => { + const guidedShapes = draggedPrimaryShape + ? [shapes.find(s => s.id === draggedPrimaryShape.id)].filter(identity) + : []; + return guidedShapes.length + ? alignmentGuides(configuration, shapes, guidedShapes, draggedShape).map(shape => ({ + ...shape, + id: configuration.alignmentGuideName + '_' + shape.id, type: 'annotation', - subtype: config.hoverAnnotationName, + subtype: configuration.alignmentGuideName, interactive: false, - localTransformMatrix: matrix.multiply( - hoveredShape.localTransformMatrix, - matrix.translate(0, 0, 100) - ), - }, - ] - : []; -})(hoveredShape, selectedPrimaryShapeIds, draggedShape); + backgroundColor: 'magenta', + parent: null, + })) + : []; + } +)(configuration, transformedShapes, draggedPrimaryShape, draggedShape); + +const hoverAnnotations = select( + (configuration, hoveredShape, selectedPrimaryShapeIds, draggedShape) => { + return hoveredShape && + hoveredShape.type !== 'annotation' && + selectedPrimaryShapeIds.indexOf(hoveredShape.id) === -1 && + !draggedShape + ? [ + { + ...hoveredShape, + id: configuration.hoverAnnotationName + '_' + hoveredShape.id, + type: 'annotation', + subtype: configuration.hoverAnnotationName, + interactive: false, + localTransformMatrix: matrix.multiply( + hoveredShape.localTransformMatrix, + matrix.translate(0, 0, configuration.hoverLift) + ), + parent: null, // consider linking to proper parent, eg. for more regular typing (ie. all shapes have all props) + }, + ] + : []; + } +)(configuration, hoveredShape, selectedPrimaryShapeIds, draggedShape); -const rotationAnnotation = (shapes, selectedShapes, shape, i) => { +const rotationAnnotation = (configuration, shapes, selectedShapes, shape, i) => { const foundShape = shapes.find(s => shape.id === s.id); if (!foundShape) { return false; @@ -826,6 +827,7 @@ const rotationAnnotation = (shapes, selectedShapes, shape, i) => { if (foundShape.type === 'annotation') { return rotationAnnotation( + configuration, shapes, selectedShapes, shapes.find(s => foundShape.parent === s.id), @@ -834,66 +836,75 @@ const rotationAnnotation = (shapes, selectedShapes, shape, i) => { } const b = snappedB(foundShape); const centerTop = matrix.translate(0, -b, 0); - const pixelOffset = matrix.translate(0, -config.rotateAnnotationOffset, config.atopZ); + const pixelOffset = matrix.translate( + 0, + -configuration.rotateAnnotationOffset, + configuration.atopZ + ); const transform = matrix.multiply(centerTop, pixelOffset); return { - id: config.rotationHandleName + '_' + i, + id: configuration.rotationHandleName + '_' + i, type: 'annotation', - subtype: config.rotationHandleName, + subtype: configuration.rotationHandleName, interactive: true, parent: foundShape.id, localTransformMatrix: transform, backgroundColor: 'rgb(0,0,255,0.3)', - a: config.rotationHandleSize, - b: config.rotationHandleSize, + a: configuration.rotationHandleSize, + b: configuration.rotationHandleSize, }; }; -const resizePointAnnotations = (parent, a, b) => ([x, y, cursorAngle]) => { - const markerPlace = matrix.translate(x * a, y * b, config.resizeAnnotationOffsetZ); +const resizePointAnnotations = (configuration, parent, a, b) => ([x, y, cursorAngle]) => { + const markerPlace = matrix.translate(x * a, y * b, configuration.resizeAnnotationOffsetZ); const pixelOffset = matrix.translate( - -x * config.resizeAnnotationOffset, - -y * config.resizeAnnotationOffset, - config.atopZ + 10 + -x * configuration.resizeAnnotationOffset, + -y * configuration.resizeAnnotationOffset, + configuration.atopZ + 10 ); const transform = matrix.multiply(markerPlace, pixelOffset); const xName = xNames[x]; const yName = yNames[y]; return { - id: [config.resizeHandleName, xName, yName, parent].join('_'), + id: [configuration.resizeHandleName, xName, yName, parent].join('_'), type: 'annotation', - subtype: config.resizeHandleName, + subtype: configuration.resizeHandleName, horizontalPosition: xName, verticalPosition: yName, cursorAngle, interactive: true, - parent, + parent: parent.id, localTransformMatrix: transform, backgroundColor: 'rgb(0,255,0,1)', - a: config.resizeAnnotationSize, - b: config.resizeAnnotationSize, + a: configuration.resizeAnnotationSize, + b: configuration.resizeAnnotationSize, }; }; -const resizeEdgeAnnotations = (parent, a, b) => ([[x0, y0], [x1, y1]]) => { +const resizeEdgeAnnotations = (configuration, parent, a, b) => ([[x0, y0], [x1, y1]]) => { const x = a * mean(x0, x1); const y = b * mean(y0, y1); - const markerPlace = matrix.translate(x, y, config.atopZ - 10); + const markerPlace = matrix.translate(x, y, configuration.atopZ - 10); const transform = markerPlace; // no offset etc. at the moment const horizontal = y0 === y1; const length = horizontal ? a * Math.abs(x1 - x0) : b * Math.abs(y1 - y0); - const sectionHalfLength = Math.max(0, length / 2 - config.resizeAnnotationConnectorOffset); + const sectionHalfLength = Math.max(0, length / 2 - configuration.resizeAnnotationConnectorOffset); const width = 0.5; return { - id: [config.resizeConnectorName, xNames[x0], yNames[y0], xNames[x1], yNames[y1], parent].join( - '_' - ), + id: [ + configuration.resizeConnectorName, + xNames[x0], + yNames[y0], + xNames[x1], + yNames[y1], + parent, + ].join('_'), type: 'annotation', - subtype: config.resizeConnectorName, + subtype: configuration.resizeConnectorName, interactive: true, - parent, + parent: parent.id, localTransformMatrix: transform, - backgroundColor: config.devColor, + backgroundColor: configuration.devColor, a: horizontal ? sectionHalfLength : width, b: horizontal ? width : sectionHalfLength, }; @@ -912,21 +923,43 @@ const connectorVertices = [ const cornerVertices = [[-1, -1], [1, -1], [-1, 1], [1, 1]]; -function resizeAnnotation(shapes, selectedShapes, shape) { +const groupedShape = properShape => shape => shape.parent === properShape.id; + +const magic = (configuration, shape, shapes) => { + const epsilon = configuration.rotationEpsilon; + const integralOf = Math.PI * 2; + const isIntegerMultiple = shape => { + const zRotation = matrix.matrixToAngle(shape.localTransformMatrix); + const ratio = zRotation / integralOf; + return Math.abs(Math.round(ratio) - ratio) < epsilon; + }; + + function recurse(shape) { + return shapes.filter(groupedShape(shape)).every(resizableChild); + } + + function resizableChild(shape) { + return isIntegerMultiple(shape) && recurse(shape); + } + + return recurse(shape); +}; + +function resizeAnnotation(configuration, shapes, selectedShapes, shape) { const foundShape = shapes.find(s => shape.id === s.id); const properShape = foundShape && - (foundShape.subtype === config.resizeHandleName + (foundShape.subtype === configuration.resizeHandleName ? shapes.find(s => shape.parent === s.id) : foundShape); if (!foundShape) { return []; } - if (foundShape.subtype === config.resizeHandleName) { + if (foundShape.subtype === configuration.resizeHandleName) { // preserve any interactive annotation when handling const result = foundShape.interactive - ? resizeAnnotationsFunction({ + ? resizeAnnotationsFunction(configuration, { shapes, selectedShapes: [shapes.find(s => shape.parent === s.id)], }) @@ -934,25 +967,21 @@ function resizeAnnotation(shapes, selectedShapes, shape) { return result; } if (foundShape.type === 'annotation') { - return resizeAnnotation(shapes, selectedShapes, shapes.find(s => foundShape.parent === s.id)); + return resizeAnnotation( + configuration, + shapes, + selectedShapes, + shapes.find(s => foundShape.parent === s.id) + ); } // fixme left active: snap wobble. right active: opposite side wobble. const a = snappedA(properShape); const b = snappedB(properShape); - const groupedShape = shape => - shape.parent === properShape.id && - shape.type !== 'annotation' && - shape.subtype !== config.adHocGroupName; - // fixme broaden resizableChild to other multiples of 90 degrees - const resizableChild = shape => - shallowEqual( - matrix.compositeComponent(shape.localTransformMatrix).map(applyTolerance), - matrix.UNITMATRIX - ); const allowResize = properShape.type !== 'group' || - (config.groupResize && shapes.filter(groupedShape).every(resizableChild)); + (configuration.groupResize && + magic(configuration, properShape, shapes.filter(s => s.type !== 'annotation'))); const resizeVertices = allowResize ? [ [-1, -1, 315], @@ -965,17 +994,17 @@ function resizeAnnotation(shapes, selectedShapes, shape) { [-1, 0, 270], // edge midpoints ] : []; - const resizePoints = resizeVertices.map(resizePointAnnotations(shape.id, a, b)); - const connectors = connectorVertices.map(resizeEdgeAnnotations(shape.id, a, b)); + const resizePoints = resizeVertices.map(resizePointAnnotations(configuration, shape, a, b)); + const connectors = connectorVertices.map(resizeEdgeAnnotations(configuration, shape, a, b)); return [...resizePoints, ...connectors]; } -function resizeAnnotationsFunction({ shapes, selectedShapes }) { +function resizeAnnotationsFunction(configuration, { shapes, selectedShapes }) { const shapesToAnnotate = selectedShapes; return flatten( shapesToAnnotate .map(shape => { - return resizeAnnotation(shapes, selectedShapes, shape); + return resizeAnnotation(configuration, shapes, selectedShapes, shape); }) .filter(identity) ); @@ -1013,7 +1042,7 @@ const translateShapeSnap = (horizontalConstraint, verticalConstraint, draggedEle } const snapOffset = matrix.translateComponent( matrix.multiply( - matrix.rotateZ((matrix.matrixToAngle(draggedElement.localTransformMatrix) / 180) * Math.PI), + matrix.rotateZ(matrix.matrixToAngle(draggedElement.localTransformMatrix)), matrix.translate(snapOffsetX, snapOffsetY, 0) ) ); @@ -1043,7 +1072,7 @@ const resizeShapeSnap = ( const snapOffsetY = constrainedY ? -verticalConstraint.signedDistance : 0; if (constrainedX || constrainedY) { const multiplier = symmetric ? 1 : 0.5; - const angle = (matrix.matrixToAngle(draggedElement.localTransformMatrix) / 180) * Math.PI; + const angle = matrix.matrixToAngle(draggedElement.localTransformMatrix); const horizontalSign = -resizeMultiplierHorizontal[horizontalPosition]; // fixme unify sign const verticalSign = resizeMultiplierVertical[verticalPosition]; // todo turn it into matrix algebra via matrix2d.js @@ -1076,6 +1105,7 @@ const resizeShapeSnap = ( const snappedShapes = select( ( + configuration, shapes, draggedShape, draggedElement, @@ -1087,16 +1117,23 @@ const snappedShapes = select( const subtype = draggedShape && draggedShape.subtype; // snapping doesn't come into play if there's no dragging, or it's not a resize drag or translate drag on a // leaf element or a group element: - if (subtype && [config.resizeHandleName, config.adHocGroupName].indexOf(subtype) === -1) { + if ( + subtype && + [ + configuration.resizeHandleName, + configuration.adHocGroupName, + configuration.persistentGroupName, + ].indexOf(subtype) === -1 + ) { return contentShapes; } const constraints = alignmentGuideAnnotations; // fixme split concept of snap constraints and their annotations const relaxed = alterSnapGesture.indexOf('relax') !== -1; - const constrained = config.snapConstraint && !relaxed; + const constrained = configuration.snapConstraint && !relaxed; const horizontalConstraint = constrained && directionalConstraint(constraints, isHorizontal); const verticalConstraint = constrained && directionalConstraint(constraints, isVertical); const snapper = - subtype === config.resizeHandleName + subtype === configuration.resizeHandleName ? resizeShapeSnap( horizontalConstraint, verticalConstraint, @@ -1109,6 +1146,7 @@ const snappedShapes = select( return contentShapes.map(snapper); } )( + configuration, transformedShapes, draggedShape, draggedPrimaryShape, @@ -1126,9 +1164,6 @@ const extend = ([[xMin, yMin], [xMax, yMax]], [x0, y0], [x1, y1]) => [ [Math.max(xMax, x0, x1), Math.max(yMax, y0, y1)], ]; -const isAdHocGroup = shape => - shape.type === config.groupName && shape.subtype === config.adHocGroupName; - // fixme put it into geometry.js const getAABB = shapes => shapes.reduce( @@ -1160,21 +1195,22 @@ const projectAABB = ([[xMin, yMin], [xMax, yMax]]) => { return { a, b, localTransformMatrix, rigTransform }; }; -const dissolveGroups = (preexistingAdHocGroups, shapes, selectedShapes) => { +const dissolveGroups = (groupsToDissolve, shapes, selectedShapes) => { return { shapes: shapes - .filter(shape => !isAdHocGroup(shape)) + .filter(s => !groupsToDissolve.find(g => s.id === g.id)) .map(shape => { - const preexistingAdHocGroupParent = preexistingAdHocGroups.find( + const preexistingGroupParent = groupsToDissolve.find( groupShape => groupShape.id === shape.parent ); // if linked, dissociate from ad hoc group parent - return preexistingAdHocGroupParent + return preexistingGroupParent ? { ...shape, parent: null, localTransformMatrix: matrix.multiply( - preexistingAdHocGroupParent.localTransformMatrix, // reinstate the group offset onto the child + // pulling preexistingGroupParent from `shapes` to get fresh matrices + shapes.find(s => s.id === preexistingGroupParent.id).localTransformMatrix, // reinstate the group offset onto the child shape.localTransformMatrix ), } @@ -1187,80 +1223,96 @@ const dissolveGroups = (preexistingAdHocGroups, shapes, selectedShapes) => { // returns true if the shape is not a child of one of the shapes const hasNoParentWithin = shapes => shape => !shapes.some(g => shape.parent === g.id); -const childOfAdHocGroup = shape => shape.parent && shape.parent.startsWith(config.adHocGroupName); - -const isOrBelongsToAdHocGroup = shape => isAdHocGroup(shape) || childOfAdHocGroup(shape); - const asYetUngroupedShapes = (preexistingAdHocGroups, selectedShapes) => selectedShapes.filter(hasNoParentWithin(preexistingAdHocGroups)); const idMatch = shape => s => s.id === shape.id; const idsMatch = selectedShapes => shape => selectedShapes.find(idMatch(shape)); -const axisAlignedBoundingBoxShape = shapesToBox => { +const axisAlignedBoundingBoxShape = (configuration, shapesToBox) => { const axisAlignedBoundingBox = getAABB(shapesToBox); const { a, b, localTransformMatrix, rigTransform } = projectAABB(axisAlignedBoundingBox); - const id = config.adHocGroupName + '_' + makeUid(); + const id = getId(configuration.groupName); const aabbShape = { id, - type: config.groupName, - subtype: config.adHocGroupName, + type: configuration.groupName, + subtype: configuration.adHocGroupName, a, b, localTransformMatrix, rigTransform, + parent: null, }; return aabbShape; }; -const resizeGroup = (shapes, selectedShapes, elements) => { - if (!elements.length) { - return { shapes, selectedShapes }; +const resetChild = s => { + if (s.childBaseAB) { + s.childBaseAB = null; + s.baseLocalTransformMatrix = null; } - const e = elements[0]; - if (e.subtype !== 'adHocGroup') { - return { shapes, selectedShapes }; +}; + +const childScaler = ({ a, b }, baseAB) => { + // a scaler of 0, encountered when element is shrunk to zero size, would result in a non-invertible transform matrix + const epsilon = 1e-6; + const groupScaleX = Math.max(a / baseAB[0], epsilon); + const groupScaleY = Math.max(b / baseAB[1], epsilon); + const groupScale = matrix.scale(groupScaleX, groupScaleY, 1); + return groupScale; +}; + +const resizeChild = groupScale => s => { + const childBaseAB = s.childBaseAB || [s.a, s.b]; + const impliedScale = matrix.scale(...childBaseAB, 1); + const inverseImpliedScale = matrix.invert(impliedScale); + const baseLocalTransformMatrix = s.baseLocalTransformMatrix || s.localTransformMatrix; + const normalizedBaseLocalTransformMatrix = matrix.multiply( + baseLocalTransformMatrix, + impliedScale + ); + const T = matrix.multiply(groupScale, normalizedBaseLocalTransformMatrix); + const backScaler = groupScale.map(d => Math.abs(d)); + const inverseBackScaler = matrix.invert(backScaler); + const abTuple = matrix.mvMultiply(matrix.multiply(backScaler, impliedScale), [1, 1, 1, 1]); + s.localTransformMatrix = matrix.multiply( + T, + matrix.multiply(inverseImpliedScale, inverseBackScaler) + ); + s.a = abTuple[0]; + s.b = abTuple[1]; + s.childBaseAB = childBaseAB; + s.baseLocalTransformMatrix = baseLocalTransformMatrix; +}; + +const resizeGroup = (shapes, rootElement) => { + const idMap = {}; + for (let i = 0; i < shapes.length; i++) { + idMap[shapes[i].id] = shapes[i]; } - if (!e.baseAB) { - return { - shapes: shapes.map(s => ({ ...s, childBaseAB: null, baseLocalTransformMatrix: null })), - selectedShapes, - }; + + const depths = {}; + const ancestorsLength = shape => (shape.parent ? ancestorsLength(idMap[shape.parent]) + 1 : 0); + for (let i = 0; i < shapes.length; i++) { + depths[shapes[i].id] = ancestorsLength(shapes[i]); } - const groupScaleX = e.a / e.baseAB[0]; - const groupScaleY = e.b / e.baseAB[1]; - const groupScale = matrix.scale(groupScaleX, groupScaleY, 1); - return { - shapes: shapes.map(s => { - if (s.parent !== e.id || s.type === 'annotation') { - return s; + + const resizedParents = { [rootElement.id]: rootElement }; + const sortedShapes = shapes.slice().sort((a, b) => depths[a.id] - depths[b.id]); + const parentResized = s => Boolean(s.childBaseAB || s.baseAB); + for (let i = 0; i < sortedShapes.length; i++) { + const shape = sortedShapes[i]; + const parent = resizedParents[shape.parent]; + if (parent) { + resizedParents[shape.id] = shape; + if (parentResized(parent)) { + resizeChild(childScaler(parent, parent.childBaseAB || parent.baseAB))(shape); + } else { + resetChild(shape); } - const childBaseAB = s.childBaseAB || [s.a, s.b]; - const impliedScale = matrix.scale(...childBaseAB, 1); - const inverseImpliedScale = matrix.invert(impliedScale); - const baseLocalTransformMatrix = s.baseLocalTransformMatrix || s.localTransformMatrix; - const normalizedBaseLocalTransformMatrix = matrix.multiply( - baseLocalTransformMatrix, - impliedScale - ); - const T = matrix.multiply(groupScale, normalizedBaseLocalTransformMatrix); - const backScaler = groupScale.map(d => Math.abs(d)); - const transformShit = matrix.invert(backScaler); - const abTuple = matrix.mvMultiply(matrix.multiply(backScaler, impliedScale), [1, 1, 1, 1]); - return { - ...s, - localTransformMatrix: matrix.multiply( - T, - matrix.multiply(inverseImpliedScale, transformShit) - ), - a: abTuple[0], - b: abTuple[1], - childBaseAB, - baseLocalTransformMatrix, - }; - }), - selectedShapes, - }; + } + } + return sortedShapes; }; const getLeafs = (descendCondition, allShapes, shapes) => @@ -1273,18 +1325,60 @@ const getLeafs = (descendCondition, allShapes, shapes) => ) ); -const grouping = select((shapes, selectedShapes) => { +const preserveCurrentGroups = (shapes, selectedShapes) => ({ shapes, selectedShapes }); + +const groupAction = select(action => { + const event = action && action.event; + return event === 'group' || event === 'ungroup' ? event : null; +})(actionEvent); + +const grouping = select((configuration, shapes, selectedShapes, groupAction) => { + const childOfGroup = shape => shape.parent && shape.parent.startsWith(configuration.groupName); + const isAdHocGroup = shape => + shape.type === configuration.groupName && shape.subtype === configuration.adHocGroupName; const preexistingAdHocGroups = shapes.filter(isAdHocGroup); const matcher = idsMatch(selectedShapes); const selectedFn = shape => matcher(shape) && shape.type !== 'annotation'; const freshSelectedShapes = shapes.filter(selectedFn); const freshNonSelectedShapes = shapes.filter(not(selectedFn)); - const someSelectedShapesAreGrouped = selectedShapes.some(isOrBelongsToAdHocGroup); + const isGroup = shape => shape.type === configuration.groupName; + const isOrBelongsToGroup = shape => isGroup(shape) || childOfGroup(shape); + const someSelectedShapesAreGrouped = selectedShapes.some(isOrBelongsToGroup); const selectionOutsideGroup = !someSelectedShapesAreGrouped; + if (groupAction === 'group') { + const selectedAdHocGroupsToPersist = selectedShapes.filter( + s => s.subtype === configuration.adHocGroupName + ); + return { + shapes: shapes.map(s => + s.subtype === configuration.adHocGroupName + ? { ...s, subtype: configuration.persistentGroupName } + : s + ), + selectedShapes: selectedShapes + .filter(selected => selected.subtype !== configuration.adHocGroupName) + .concat( + selectedAdHocGroupsToPersist.map(shape => ({ + ...shape, + subtype: configuration.persistentGroupName, + })) + ), + }; + } + + if (groupAction === 'ungroup') { + return dissolveGroups( + selectedShapes.filter(s => s.subtype === configuration.persistentGroupName), + shapes, + asYetUngroupedShapes(preexistingAdHocGroups, freshSelectedShapes) + ); + } + // ad hoc groups must dissolve if 1. the user clicks away, 2. has a selection that's not the group, or 3. selected something else if (preexistingAdHocGroups.length && selectionOutsideGroup) { // asYetUngroupedShapes will trivially be the empty set if case 1 is realized: user clicks aside -> selectedShapes === [] + // return preserveCurrentGroups(shapes, selectedShapes); return dissolveGroups( preexistingAdHocGroups, shapes, @@ -1294,20 +1388,26 @@ const grouping = select((shapes, selectedShapes) => { // preserve the current selection if the sole ad hoc group is being manipulated const elements = contentShapes(shapes, selectedShapes); - if (selectedShapes.length === 1 && elements[0].subtype === 'adHocGroup') { - return config.groupResize - ? resizeGroup(shapes, selectedShapes, elements) - : { shapes, selectedShapes }; + if (elements.length === 1 && elements[0].type === 'group') { + return configuration.groupResize + ? { + shapes: [ + ...resizeGroup(shapes.filter(s => s.type !== 'annotation'), elements[0]), + ...shapes.filter(s => s.type === 'annotation'), + ], + selectedShapes, + } + : preserveCurrentGroups(shapes, selectedShapes); } // group items or extend group bounding box (if enabled) if (selectedShapes.length < 2) { // resize the group if needed (ad-hoc group resize is manipulated) - return { shapes, selectedShapes }; + return preserveCurrentGroups(shapes, selectedShapes); } else { // group together the multiple items - const group = axisAlignedBoundingBoxShape(freshSelectedShapes); + const group = axisAlignedBoundingBoxShape(configuration, freshSelectedShapes); const selectedLeafShapes = getLeafs( - shape => shape.subtype === config.adHocGroupName, + shape => shape.subtype === configuration.adHocGroupName, shapes, freshSelectedShapes ); @@ -1317,9 +1417,14 @@ const grouping = select((shapes, selectedShapes) => { localTransformMatrix: matrix.multiply(group.rigTransform, shape.transformMatrix), })); const nonGroupGraphConstituent = s => - s.subtype !== config.adHocGroupName && !parentedSelectedShapes.find(ss => s.id === ss.id); + s.subtype !== configuration.adHocGroupName && + !parentedSelectedShapes.find(ss => s.id === ss.id); const dissociateFromParentIfAny = s => - s.parent && s.parent.startsWith(config.adHocGroupName) ? { ...s, parent: null } : s; + s.parent && + s.parent.startsWith(configuration.groupName) && + preexistingAdHocGroups.find(ahg => ahg.id === s.parent) + ? { ...s, parent: null } + : s; const allTerminalShapes = parentedSelectedShapes.concat( freshNonSelectedShapes.filter(nonGroupGraphConstituent).map(dissociateFromParentIfAny) ); @@ -1328,7 +1433,7 @@ const grouping = select((shapes, selectedShapes) => { selectedShapes: [group], }; } -})(constrainedShapesWithPreexistingAnnotations, selectedShapes); +})(configuration, constrainedShapesWithPreexistingAnnotations, selectedShapes, groupAction); const groupedSelectedShapes = select(({ selectedShapes }) => selectedShapes)(grouping); @@ -1340,14 +1445,14 @@ const groupedSelectedPrimaryShapeIds = select(selectedShapes => selectedShapes.m groupedSelectedShapes ); -const resizeAnnotations = select(resizeAnnotationsFunction)(grouping); +const resizeAnnotations = select(resizeAnnotationsFunction)(configuration, grouping); -const rotationAnnotations = select(({ shapes, selectedShapes }) => { +const rotationAnnotations = select((configuration, { shapes, selectedShapes }) => { const shapesToAnnotate = selectedShapes; return shapesToAnnotate - .map((shape, i) => rotationAnnotation(shapes, selectedShapes, shape, i)) + .map((shape, i) => rotationAnnotation(configuration, shapes, selectedShapes, shape, i)) .filter(identity); -})(grouping); +})(configuration, grouping); const annotatedShapes = select( ( @@ -1382,28 +1487,29 @@ const bidirectionalCursors = { '315': 'nwse-resize', }; -const cursor = select((shape, draggedPrimaryShape) => { +const cursor = select((configuration, shape, draggedPrimaryShape) => { if (!shape) { return 'auto'; } switch (shape.subtype) { - case config.rotationHandleName: + case configuration.rotationHandleName: return 'crosshair'; - case config.resizeHandleName: - const angle = (matrix.matrixToAngle(shape.transformMatrix) + 360) % 360; + case configuration.resizeHandleName: + const angle = ((matrix.matrixToAngle(shape.transformMatrix) * 180) / Math.PI + 360) % 360; const screenProjectedAngle = angle + shape.cursorAngle; const discretizedAngle = (Math.round(screenProjectedAngle / 45) * 45 + 360) % 360; return bidirectionalCursors[discretizedAngle]; default: return draggedPrimaryShape ? 'grabbing' : 'grab'; } -})(focusedShape, draggedPrimaryShape); +})(configuration, focusedShape, draggedPrimaryShape); // this is the core scenegraph update invocation: upon new cursor position etc. emit the new scenegraph // it's _the_ state representation (at a PoC level...) comprising of transient properties eg. draggedShape, and the // collection of shapes themselves const nextScene = select( ( + configuration, hoveredShape, selectedShapeIds, selectedPrimaryShapes, @@ -1416,7 +1522,7 @@ const nextScene = select( selectedShapes ) => { const selectedLeafShapes = getLeafs( - shape => shape.subtype === config.adHocGroupName, + shape => shape.type === configuration.groupName, shapes, selectionState.shapes .map(s => (s.type === 'annotation' ? shapes.find(ss => ss.id === s.parent) : s)) @@ -1425,6 +1531,7 @@ const nextScene = select( .filter(shape => shape.type !== 'annotation') .map(s => s.id); return { + configuration, hoveredShape, selectedShapes: selectedShapeIds, selectedLeafShapes, @@ -1439,6 +1546,7 @@ const nextScene = select( }; } )( + configuration, hoveredShape, groupedSelectedShapeIds, groupedSelectedPrimaryShapeIds, diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.js b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.js index ae9120c9ecd9b..60a17f6ce8e27 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.js @@ -311,11 +311,10 @@ const applyTransforms = (transforms, previousTransformMatrix) => const clamp = (low, high, value) => Math.min(high, Math.max(low, value)); -// todo turn it into returning radians rather than degrees const matrixToAngle = transformMatrix => { // clamping is needed, otherwise inevitable floating point inaccuracies can cause NaN - const z0 = (Math.acos(clamp(-1, 1, transformMatrix[0])) * 180) / Math.PI; - const z1 = (Math.asin(clamp(-1, 1, transformMatrix[1])) * 180) / Math.PI; + const z0 = Math.acos(clamp(-1, 1, transformMatrix[0])); + const z1 = Math.asin(clamp(-1, 1, transformMatrix[1])); return z1 > 0 ? z0 : -z0; }; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js b/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js index 0363736d8b3b1..7ea39fd7e2a2a 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js @@ -26,6 +26,7 @@ export const aeroelastic = { shapeAdditions: nextScene.shapes, primaryUpdate, currentScene: nextScene, + configuration: nextScene.configuration, }))(aero.layout.nextScene, aero.layout.primaryUpdate); stores.get(page).setUpdater(updateScene); diff --git a/x-pack/plugins/canvas/public/lib/clone_subgraphs.js b/x-pack/plugins/canvas/public/lib/clone_subgraphs.js new file mode 100644 index 0000000000000..101492657b47b --- /dev/null +++ b/x-pack/plugins/canvas/public/lib/clone_subgraphs.js @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { arrayToMap } from './aeroelastic/functional'; +import { getId } from './get_id'; + +export const cloneSubgraphs = nodes => { + const idMap = arrayToMap(nodes.map(n => n.id)); + // We simultaneously provide unique id values for all elements (across all pages) + // AND ensure that parent-child relationships are retained (via matching id values within page) + Object.keys(idMap).forEach(key => (idMap[key] = getId(key.split('-')[0]))); // new group names to which we can map + // must return elements in the same order, for several reasons + const newNodes = nodes.map(element => ({ + ...element, + id: idMap[element.id], + position: { + ...element.position, + parent: element.position.parent ? idMap[element.position.parent] : null, + }, + })); + return newNodes; +}; diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index f45e09390ec6f..960a0ddc6db40 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -10,11 +10,12 @@ import { createThunk } from 'redux-thunks'; import { set, del } from 'object-path-immutable'; import { get, pick, cloneDeep, without } from 'lodash'; import { toExpression, safeElementFromExpression } from '@kbn/interpreter/common'; -import { getPages, getElementById, getSelectedPageIndex } from '../selectors/workpad'; +import { getPages, getNodeById, getNodes, getSelectedPageIndex } from '../selectors/workpad'; import { getValue as getResolvedArgsValue } from '../selectors/resolved_args'; import { getDefaultElement } from '../defaults'; import { notify } from '../../lib/notify'; import { runInterpreter } from '../../lib/run_interpreter'; +import { subMultitree } from '../../lib/aeroelastic/functional'; import { selectElement } from './transient'; import * as args from './resolved_args'; @@ -213,12 +214,48 @@ export const duplicateElement = createThunk( } ); +export const rawDuplicateElement = createThunk( + 'rawDuplicateElement', + ({ dispatch, type }, element, pageId, root) => { + const newElement = cloneDeep(element); + // move the root element so users can see that it was added + newElement.position.top = newElement.position.top + 10; + newElement.position.left = newElement.position.left + 10; + const _rawDuplicateElement = createAction(type); + dispatch(_rawDuplicateElement({ pageId, element: newElement })); + + // refresh all elements if there's a filter, otherwise just render the new element + if (element.filter) { + dispatch(fetchAllRenderables()); + } else { + dispatch(fetchRenderable(newElement)); + } + + // select the new element + if (root) { + window.setTimeout(() => dispatch(selectElement(newElement.id))); + } + } +); + export const removeElements = createThunk( 'removeElements', - ({ dispatch, getState }, elementIds, pageId) => { + ({ dispatch, getState }, rootElementIds, pageId) => { + const state = getState(); + + // todo consider doing the group membership collation in aeroelastic, or the Redux reducer, when adding templates + const allElements = getNodes(state, pageId); + const allRoots = rootElementIds.map(id => allElements.find(e => id === e.id)); + if (allRoots.indexOf(undefined) !== -1) { + throw new Error('Some of the elements to be deleted do not exist'); + } + const elementIds = subMultitree(e => e.id, e => e.position.parent, allElements, allRoots).map( + e => e.id + ); + const shouldRefresh = elementIds.some(elementId => { - const element = getElementById(getState(), elementId, pageId); - const filterIsApplied = element.filter != null && element.filter.length > 0; + const element = getNodeById(state, elementId, pageId); + const filterIsApplied = element.filter && element.filter.length > 0; return filterIsApplied; }); @@ -253,7 +290,7 @@ function setExpressionFn({ dispatch, getState }, expression, elementId, pageId, dispatch(_setExpression({ expression, elementId, pageId })); // read updated element from state and fetch renderable - const updatedElement = getElementById(getState(), elementId, pageId); + const updatedElement = getNodeById(getState(), elementId, pageId); if (doRender === true) { dispatch(fetchRenderable(updatedElement)); } @@ -369,7 +406,7 @@ export const deleteArgumentAtIndex = createThunk('deleteArgumentAtIndex', ({ dis payload: element defaults. Eg {expression: 'foo'} */ export const addElement = createThunk('addElement', ({ dispatch }, pageId, element) => { - const newElement = { ...getDefaultElement(), ...getBareElement(element) }; + const newElement = { ...getDefaultElement(), ...getBareElement(element, true) }; if (element.width) { newElement.position.width = element.width; } diff --git a/x-pack/plugins/canvas/public/state/defaults.js b/x-pack/plugins/canvas/public/state/defaults.js index 2a5733bbba918..203523936ffa7 100644 --- a/x-pack/plugins/canvas/public/state/defaults.js +++ b/x-pack/plugins/canvas/public/state/defaults.js @@ -16,6 +16,7 @@ export const getDefaultElement = () => { height: 300, width: 500, angle: 0, + type: 'element', }, expression: ` demodata @@ -34,6 +35,7 @@ export const getDefaultPage = () => { }, transition: {}, elements: [], + groups: [], }; }; diff --git a/x-pack/plugins/canvas/public/state/initial_state.js b/x-pack/plugins/canvas/public/state/initial_state.js index 1dd9d59609786..b6e54af9ecbb6 100644 --- a/x-pack/plugins/canvas/public/state/initial_state.js +++ b/x-pack/plugins/canvas/public/state/initial_state.js @@ -22,11 +22,11 @@ export const getInitialState = path => { // values in resolvedArgs should live under a unique index so they can be looked up. // The ID of the element is a great example. // In there will live an object with a status (string), value (any), and error (Error) property. - // If the state is 'error', the error proprty will be the error object, the value will not change + // If the state is 'error', the error property will be the error object, the value will not change // See the resolved_args reducer for more information. }, persistent: { - schemaVersion: 1, + schemaVersion: 2, workpad: getDefaultWorkpad(), }, }; diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index ca50952a7acfb..3d66a8f4d85b8 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -7,11 +7,13 @@ import { shallowEqual } from 'recompose'; import { aeroelastic as aero } from '../../lib/aeroelastic_kibana'; import { matrixToAngle } from '../../lib/aeroelastic/matrix'; -import { identity } from '../../lib/aeroelastic/functional'; +import { arrayToMap, identity } from '../../lib/aeroelastic/functional'; +import defaultConfiguration from '../../lib/aeroelastic/config'; import { addElement, removeElements, duplicateElement, + rawDuplicateElement, elementLayer, setMultiplePositions, fetchAllRenderables, @@ -21,7 +23,9 @@ import { selectElement } from '../actions/transient'; import { addPage, removePage, duplicatePage } from '../actions/pages'; import { appReady } from '../actions/app'; import { setWorkpad } from '../actions/workpad'; -import { getElements, getPages, getSelectedPage, getSelectedElement } from '../selectors/workpad'; +import { getNodes, getPages, getSelectedPage, getSelectedElement } from '../selectors/workpad'; + +const isGroupId = id => id.startsWith(defaultConfiguration.groupName); /** * elementToShape @@ -56,16 +60,31 @@ const elementToShape = (element, i) => { aero.matrix.translate(cx, cy, z), aero.matrix.rotateZ(angleRadians) ); + const isGroup = isGroupId(element.id); + const parent = (element.position && element.position.parent) || null; // reserved for hierarchical (tree shaped) grouping return { id: element.id, - parent: null, // reserved for hierarchical (tree shaped) grouping, - localTransformMatrix: transformMatrix, + type: isGroup ? 'group' : 'rectangleElement', + subtype: isGroup ? 'persistentGroup' : '', + parent, transformMatrix, a, // we currently specify half-width, half-height as it leads to b, // more regular math (like ellipsis radii rather than diameters) }; }; +const shapeToElement = shape => { + return { + left: shape.transformMatrix[12] - shape.a, + top: shape.transformMatrix[13] - shape.b, + width: shape.a * 2, + height: shape.b * 2, + angle: Math.round((matrixToAngle(shape.transformMatrix) * 180) / Math.PI), + parent: shape.parent || null, + type: shape.type === 'group' ? 'group' : 'element', + }; +}; + const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, unsortedElements) => { const ascending = (a, b) => (a.id < b.id ? -1 : 1); const relevant = s => s.type !== 'annotation' && s.subtype !== 'adHocGroup'; @@ -84,16 +103,12 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns width: elemPos.width, height: elemPos.height, angle: Math.round(elemPos.angle), + type: elemPos.type, + parent: elemPos.parent || null, }; // cast shape into element-like object to compare - const newProps = { - left: shape.transformMatrix[12] - shape.a, - top: shape.transformMatrix[13] - shape.b, - width: shape.a * 2, - height: shape.b * 2, - angle: Math.round(matrixToAngle(shape.transformMatrix)), - }; + const newProps = shapeToElement(shape); if (1 / newProps.angle === -Infinity) { newProps.angle = 0; @@ -111,6 +126,22 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns }; const id = element => element.id; +// check for duplication +const deduped = a => a.filter((d, i) => a.indexOf(d) === i); +const idDuplicateCheck = groups => { + if (deduped(groups.map(g => g.id)).length !== groups.length) { + throw new Error('Duplicate element encountered'); + } +}; + +const missingParentCheck = groups => { + const idMap = arrayToMap(groups.map(g => g.id)); + groups.forEach(g => { + if (g.parent && !idMap[g.parent]) { + g.parent = null; + } + }); +}; export const aeroelastic = ({ dispatch, getState }) => { // When aeroelastic updates an element, we need to dispatch actions to notify redux of the changes @@ -124,41 +155,96 @@ export const aeroelastic = ({ dispatch, getState }) => { // read current data out of redux const page = getSelectedPage(getState()); - const elements = getElements(getState(), page); + const elements = getNodes(getState(), page); const selectedElement = getSelectedElement(getState()); + const shapes = nextScene.shapes; + const persistableGroups = shapes.filter(s => s.subtype === 'persistentGroup'); + const persistedGroups = elements.filter(e => isGroupId(e.id)); + + idDuplicateCheck(persistableGroups); + idDuplicateCheck(persistedGroups); + + persistableGroups.forEach(g => { + if ( + !persistedGroups.find(p => { + if (!p.id) { + throw new Error('Element has no id'); + } + return p.id === g.id; + }) + ) { + const partialElement = { + id: g.id, + filter: undefined, + expression: 'shape fill="rgba(255,255,255,0)" | render', + position: { + ...shapeToElement(g), + }, + }; + dispatch(addElement(page, partialElement)); + } + }); + + const elementsToRemove = persistedGroups.filter( + // list elements for removal if they're not in the persistable set, or if there's no longer an associated element + // the latter of which shouldn't happen, so it's belts and braces + p => + !persistableGroups.find(g => p.id === g.id) || + !elements.find(e => e.position.parent === p.id) + ); + updateGlobalPositions( positions => dispatch(setMultiplePositions(positions.map(p => ({ ...p, pageId: page })))), nextScene, elements ); + if (elementsToRemove.length) { + // remove elements for groups that were ungrouped + dispatch(removeElements(elementsToRemove.map(e => e.id), page)); + } + // set the selected element on the global store, if one element is selected const selectedShape = nextScene.selectedPrimaryShapes[0]; - if (nextScene.selectedShapes.length === 1) { - if (selectedShape && selectedShape !== selectedElement) { + if (nextScene.selectedShapes.length === 1 && !isGroupId(selectedShape)) { + if (selectedShape !== (selectedElement && selectedElement.id)) { dispatch(selectElement(selectedShape)); } } else { // otherwise, clear the selected element state - dispatch(selectElement(null)); + // even for groups - TODO add handling for groups, esp. persistent groups - common styling etc. + if (selectedElement) { + const shape = shapes.find(s => s.id === selectedShape); + // don't reset if eg. we're in the middle of converting an ad hoc group into a persistent one + if (!shape || shape.subtype !== 'adHocGroup') { + dispatch(selectElement(null)); + } + } } }; const createStore = page => aero.createStore( - { shapeAdditions: [], primaryUpdate: null, currentScene: { shapes: [] } }, + { + shapeAdditions: [], + primaryUpdate: null, + currentScene: { shapes: [] }, + configuration: defaultConfiguration, + }, onChangeCallback, page ); - const populateWithElements = page => - aero.commit( - page, - 'restateShapesEvent', - { newShapes: getElements(getState(), page).map(elementToShape) }, - { silent: true } - ); + const populateWithElements = page => { + const newShapes = getNodes(getState(), page) + .map(elementToShape) + // filtering to eliminate residual element of a possible group that had been deleted in Redux + .filter((d, i, a) => !isGroupId(d.id) || a.find(s => s.parent === d.id)); + idDuplicateCheck(newShapes); + missingParentCheck(newShapes); + return aero.commit(page, 'restateShapesEvent', { newShapes }, { silent: true }); + }; const selectShape = (page, id) => { aero.commit(page, 'shapeSelect', { shapes: [id] }); @@ -171,7 +257,7 @@ export const aeroelastic = ({ dispatch, getState }) => { return next => action => { // get information before the state is changed const prevPage = getSelectedPage(getState()); - const prevElements = getElements(getState(), prevPage); + const prevElements = getNodes(getState(), prevPage); if (action.type === setWorkpad.toString()) { const pages = action.payload.pages; @@ -247,10 +333,11 @@ export const aeroelastic = ({ dispatch, getState }) => { case removeElements.toString(): case addElement.toString(): case duplicateElement.toString(): + case rawDuplicateElement.toString(): case elementLayer.toString(): case setMultiplePositions.toString(): const page = getSelectedPage(getState()); - const elements = getElements(getState(), page); + const elements = getNodes(getState(), page); // TODO: add a better check for elements changing, including their position, ids, etc. const shouldResetState = diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js index d000e5bd8efdc..3aecaeb7c2d69 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -9,44 +9,51 @@ import { assign, push, del, set } from 'object-path-immutable'; import { get } from 'lodash'; import * as actions from '../actions/elements'; +const getLocation = type => (type === 'group' ? 'groups' : 'elements'); + +const getLocationFromIds = (workpadState, pageId, nodeId) => { + const page = workpadState.pages.find(p => p.id === pageId); + const groups = page == null ? [] : page.groups || []; + return groups.find(e => e.id === nodeId) ? 'groups' : 'elements'; +}; + function getPageIndexById(workpadState, pageId) { return get(workpadState, 'pages', []).findIndex(page => page.id === pageId); } -function getElementIndexById(page, elementId) { - return page.elements.findIndex(element => element.id === elementId); +function getNodeIndexById(page, nodeId, location) { + return page[location].findIndex(node => node.id === nodeId); } -function assignElementProperties(workpadState, pageId, elementId, props) { +function assignNodeProperties(workpadState, pageId, nodeId, props) { const pageIndex = getPageIndexById(workpadState, pageId); - const elementsPath = ['pages', pageIndex, 'elements']; - const elementIndex = get(workpadState, elementsPath, []).findIndex( - element => element.id === elementId - ); + const location = getLocationFromIds(workpadState, pageId, nodeId); + const nodesPath = ['pages', pageIndex, location]; + const nodeIndex = get(workpadState, nodesPath, []).findIndex(node => node.id === nodeId); - if (pageIndex === -1 || elementIndex === -1) { + if (pageIndex === -1 || nodeIndex === -1) { return workpadState; } // remove any AST value from the element caused by https://github.com/elastic/kibana-canvas/issues/260 // TODO: remove this after a bit of time - const cleanWorkpadState = del(workpadState, elementsPath.concat([elementIndex, 'ast'])); + const cleanWorkpadState = del(workpadState, nodesPath.concat([nodeIndex, 'ast'])); - return assign(cleanWorkpadState, elementsPath.concat(elementIndex), props); + return assign(cleanWorkpadState, nodesPath.concat(nodeIndex), props); } -function moveElementLayer(workpadState, pageId, elementId, movement) { +function moveNodeLayer(workpadState, pageId, nodeId, movement, location) { const pageIndex = getPageIndexById(workpadState, pageId); - const elementIndex = getElementIndexById(workpadState.pages[pageIndex], elementId); - const elements = get(workpadState, ['pages', pageIndex, 'elements']); - const from = elementIndex; + const nodeIndex = getNodeIndexById(workpadState.pages[pageIndex], nodeId, location); + const nodes = get(workpadState, ['pages', pageIndex, location]); + const from = nodeIndex; const to = (function() { if (movement < Infinity && movement > -Infinity) { - return elementIndex + movement; + return nodeIndex + movement; } if (movement === Infinity) { - return elements.length - 1; + return nodes.length - 1; } if (movement === -Infinity) { return 0; @@ -54,52 +61,88 @@ function moveElementLayer(workpadState, pageId, elementId, movement) { throw new Error('Invalid element layer movement'); })(); - if (to > elements.length - 1 || to < 0) { + if (to > nodes.length - 1 || to < 0) { return workpadState; } // Common - const newElements = elements.slice(0); - newElements.splice(to, 0, newElements.splice(from, 1)[0]); + const newNodes = nodes.slice(0); + newNodes.splice(to, 0, newNodes.splice(from, 1)[0]); - return set(workpadState, ['pages', pageIndex, 'elements'], newElements); + return set(workpadState, ['pages', pageIndex, location], newNodes); } +const trimPosition = ({ left, top, width, height, angle, parent }) => ({ + left, + top, + width, + height, + angle, + parent, +}); + +const trimElement = ({ id, position, expression, filter }) => ({ + id, + position: trimPosition(position), + ...(position.type !== 'group' && { expression }), + ...(filter !== void 0 && { filter }), +}); + export const elementsReducer = handleActions( { // TODO: This takes the entire element, which is not necessary, it could just take the id. [actions.setExpression]: (workpadState, { payload }) => { const { expression, pageId, elementId } = payload; - return assignElementProperties(workpadState, pageId, elementId, { expression }); + return assignNodeProperties(workpadState, pageId, elementId, { expression }); }, [actions.setFilter]: (workpadState, { payload }) => { const { filter, pageId, elementId } = payload; - return assignElementProperties(workpadState, pageId, elementId, { filter }); + return assignNodeProperties(workpadState, pageId, elementId, { filter }); }, [actions.setMultiplePositions]: (workpadState, { payload }) => payload.repositionedElements.reduce( (previousWorkpadState, { position, pageId, elementId }) => - assignElementProperties(previousWorkpadState, pageId, elementId, { position }), + assignNodeProperties(previousWorkpadState, pageId, elementId, { + position: trimPosition(position), + }), workpadState ), [actions.elementLayer]: (workpadState, { payload: { pageId, elementId, movement } }) => { - return moveElementLayer(workpadState, pageId, elementId, movement); + const location = getLocationFromIds(workpadState, pageId, elementId); + return moveNodeLayer(workpadState, pageId, elementId, movement, location); }, [actions.addElement]: (workpadState, { payload: { pageId, element } }) => { const pageIndex = getPageIndexById(workpadState, pageId); if (pageIndex < 0) { return workpadState; } - - return push(workpadState, ['pages', pageIndex, 'elements'], element); + return push( + workpadState, + ['pages', pageIndex, getLocation(element.position.type)], + trimElement(element) + ); }, [actions.duplicateElement]: (workpadState, { payload: { pageId, element } }) => { const pageIndex = getPageIndexById(workpadState, pageId); if (pageIndex < 0) { return workpadState; } - - return push(workpadState, ['pages', pageIndex, 'elements'], element); + return push( + workpadState, + ['pages', pageIndex, getLocation(element.position.type)], + trimElement(element) + ); + }, + [actions.rawDuplicateElement]: (workpadState, { payload: { pageId, element } }) => { + const pageIndex = getPageIndexById(workpadState, pageId); + if (pageIndex < 0) { + return workpadState; + } + return push( + workpadState, + ['pages', pageIndex, getLocation(element.position.type)], + trimElement(element) + ); }, [actions.removeElements]: (workpadState, { payload: { pageId, elementIds } }) => { const pageIndex = getPageIndexById(workpadState, pageId); @@ -107,14 +150,19 @@ export const elementsReducer = handleActions( return workpadState; } - const elementIndices = elementIds - .map(elementId => getElementIndexById(workpadState.pages[pageIndex], elementId)) - .sort((a, b) => b - a); // deleting from end toward beginning, otherwise indices will become off - todo fuse loops! - - return elementIndices.reduce( - (state, nextElementIndex) => del(state, ['pages', pageIndex, 'elements', nextElementIndex]), - workpadState - ); + const nodeIndices = elementIds + .map(nodeId => { + const location = getLocationFromIds(workpadState, pageId, nodeId); + return { + location, + index: getNodeIndexById(workpadState.pages[pageIndex], nodeId, location), + }; + }) + .sort((a, b) => b.index - a.index); // deleting from end toward beginning, otherwise indices will become off - todo fuse loops! + + return nodeIndices.reduce((state, { location, index }) => { + return del(state, ['pages', pageIndex, location, index]); + }, workpadState); }, }, {} diff --git a/x-pack/plugins/canvas/public/state/reducers/pages.js b/x-pack/plugins/canvas/public/state/reducers/pages.js index c58e078b37f9c..2ad01faafbd37 100644 --- a/x-pack/plugins/canvas/public/state/reducers/pages.js +++ b/x-pack/plugins/canvas/public/state/reducers/pages.js @@ -6,6 +6,7 @@ import { handleActions } from 'redux-actions'; import { set, del, insert } from 'object-path-immutable'; +import { cloneSubgraphs } from '../../lib/clone_subgraphs'; import { getId } from '../../lib/get_id'; import { routerProvider } from '../../lib/router_provider'; import { getDefaultPage } from '../defaults'; @@ -29,10 +30,15 @@ function addPage(workpadState, payload, srcIndex = workpadState.pages.length - 1 function clonePage(page) { // TODO: would be nice if we could more reliably know which parameters need to get a unique id // this makes a pretty big assumption about the shape of the page object + const elements = page.elements; + const groups = page.groups; + const nodes = elements.concat(groups); + const newNodes = cloneSubgraphs(nodes); return { ...page, id: getId('page'), - elements: page.elements.map(element => ({ ...element, id: getId('element') })), + groups: newNodes.filter(n => n.position.type === 'group'), + elements: newNodes.filter(n => n.position.type !== 'group'), }; } diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.js b/x-pack/plugins/canvas/public/state/selectors/workpad.js index 3e291d68bc224..235dab6375107 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.js +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.js @@ -102,7 +102,42 @@ export function getElements(state, pageId, withAst = true) { return []; } - // explicitely strip the ast, basically a fix for corrupted workpads + // explicitly strip the ast, basically a fix for corrupted workpads + // due to https://github.com/elastic/kibana-canvas/issues/260 + // TODO: remove this once it's been in the wild a bit + if (!withAst) { + return elements.map(el => omit(el, ['ast'])); + } + + return elements.map(appendAst); +} + +const augment = type => n => ({ + ...n, + position: { ...n.position, type }, + ...(type === 'group' && { expression: 'shape fill="rgba(255,255,255,0)" | render' }), // fixme unify with mw/aeroelastic +}); + +const getNodesOfPage = page => + get(page, 'elements') + .map(augment('element')) + .concat((get(page, 'groups') || []).map(augment('group'))); + +// todo unify or DRY up with `getElements` +export function getNodes(state, pageId, withAst = true) { + const id = pageId || getSelectedPage(state); + if (!id) { + return []; + } + + const page = getPageById(state, id); + const elements = getNodesOfPage(page); + + if (!elements) { + return []; + } + + // explicitly strip the ast, basically a fix for corrupted workpads // due to https://github.com/elastic/kibana-canvas/issues/260 // TODO: remove this once it's been in the wild a bit if (!withAst) { @@ -113,12 +148,21 @@ export function getElements(state, pageId, withAst = true) { } export function getElementById(state, id, pageId) { + // do we need to pass a truthy empty array instead of `true`? const element = getElements(state, pageId, []).find(el => el.id === id); if (element) { return appendAst(element); } } +export function getNodeById(state, id, pageId) { + // do we need to pass a truthy empty array instead of `true`? + const group = getNodes(state, pageId, []).find(el => el.id === id); + if (group) { + return appendAst(group); + } +} + export function getResolvedArgs(state, elementId, path) { if (!elementId) { return;