From 75370f2e94dd6e068d5299bac0237332c00a58bb Mon Sep 17 00:00:00 2001 From: Robert Monfera Date: Mon, 22 Oct 2018 23:45:19 +0200 Subject: [PATCH 01/23] Canvas element grouping (squashed) fix: don't allow a tilted group feat: allow rotation as long as it's a multiple of 90 degrees same as previous but recursively minor refactor - predicate minor refactor - removed if minor refactor - extracted groupedShape minor refactor - extracted out magic; temporarily only enable 0 degree orientations minor refactor - recurse minor refactor - ignore annotations minor refactor - simplify recursion minor refactor - simplify recursion 2 removed key gestures remove ancestors 1 remove ancestors 2 remove ancestors 3 remove ancestors 4 --- .../components/workpad_page/event_handlers.js | 21 +- .../components/workpad_page/workpad_page.js | 4 +- .../canvas/public/lib/aeroelastic/config.js | 8 + .../public/lib/aeroelastic/functional.js | 14 +- .../canvas/public/lib/aeroelastic/geometry.js | 3 +- .../canvas/public/lib/aeroelastic/gestures.js | 10 +- .../canvas/public/lib/aeroelastic/layout.js | 718 ++++++++++-------- .../canvas/public/lib/aeroelastic/matrix.js | 5 +- .../canvas/public/lib/aeroelastic_kibana.js | 1 + .../canvas/public/state/actions/elements.js | 78 +- .../public/state/middleware/aeroelastic.js | 168 ++-- .../canvas/public/state/reducers/pages.js | 14 +- 12 files changed, 591 insertions(+), 453 deletions(-) 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/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..ae6c5b1cfac48 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 ); @@ -138,7 +134,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 +149,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..b5a6d00597df8 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js @@ -7,6 +7,7 @@ const { select, makeUid } = require('./state'); const { + actionEvent, dragging, dragVector, cursorPosition, @@ -16,7 +17,6 @@ const { mouseDowned, mouseIsDown, optionHeld, - pressedKeys, shiftHeld, } = require('./gestures'); @@ -25,10 +25,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 +41,9 @@ const { const primaryUpdate = state => state.primaryUpdate; const scene = state => state.currentScene; +const configuration = state => { + return state.configuration; +}; /** * Pure calculations @@ -64,17 +64,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 +94,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,9 +132,7 @@ 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 @@ -200,11 +146,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 +161,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 +197,7 @@ const contentShapes = (allShapes, shapes) => shapes.map(contentShape(allShapes)) const selectionState = select( ( prev, + configuration, selectedShapeObjects, hoveredShapes, { down, uid }, @@ -273,18 +220,15 @@ const selectionState = select( down: prev.down, }; } - if (selectedShapeObjects) { - prev.shapes = selectedShapeObjects.slice(); - } + if (selectedShapeObjects) prev.shapes = selectedShapeObjects.slice(); // take action on mouse down only, and if the uid changed (except with directSelect), ie. bail otherwise - if (mouseButtonUp || (uidUnchanged && !directSelect)) { - return { ...prev, down, uid, metaHeld }; - } - const selectFunction = config.singleSelect || !multiselect ? singleSelect : multiSelect; - return selectFunction(prev, hoveredShapes, metaHeld, uid, selectedShapeObjects); + if (mouseButtonUp || (uidUnchanged && !directSelect)) return { ...prev, down, uid, metaHeld }; + const selectFunction = configuration.singleSelect || !multiselect ? singleSelect : multiSelect; + return selectFunction(prev, configuration, hoveredShapes, metaHeld, uid, selectedShapeObjects); } )( selectedShapesPrev, + configuration, selectedShapeObjects, hoveredShapes, mouseButton, @@ -300,20 +244,18 @@ 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 }, alterSnapGesture, }) => { // rotate around a Z-parallel line going through the shape center (ie. around the center) - if (!shape || !directShape) { - return { transforms: [], shapes: [] }; - } + if (!shape || !directShape) return { transforms: [], shapes: [] }; const center = shape.transformMatrix; const centerPosition = matrix.mvMultiply(center, matrix.ORIGIN); const vector = matrix.mvMultiply( @@ -331,55 +273,29 @@ 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) { - return { transforms: [], shapes: [] }; - } + if (!shape || !directShape) return { transforms: [], shapes: [] }; // transform the incoming `transform` so that resizing is aligned with shape orientation const vector = matrix.mvMultiply( matrix.multiply( @@ -394,7 +310,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,12 +318,10 @@ 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) { - return { transforms: [], shapes: [] }; - } + if (!shape || !directShape) return { transforms: [], shapes: [] }; // transform the incoming `transform` so that resizing is aligned with shape orientation const compositeComponent = matrix.compositeComponent(shape.localTransformMatrix); const inv = matrix.invert(compositeComponent); // rid the translate component @@ -418,7 +332,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 +362,7 @@ const directShapeTranslateManipulation = (cumulativeTransforms, directShapes) => }; const rotationAnnotationManipulation = ( + configuration, directTransforms, directShapes, allShapes, @@ -456,7 +371,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 +387,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 +414,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 +575,28 @@ 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, @@ -650,9 +609,7 @@ const shapeCascadeProperties = shapes => shape => { const cascadeProperties = shapes => shapes.map(shapeCascadeProperties(shapes)); const nextShapes = select((preexistingShapes, restated) => { - if (restated && restated.newShapes) { - return restated.newShapes; - } + if (restated && restated.newShapes) return restated.newShapes; // this is the per-shape model update at the current PoC level return preexistingShapes; @@ -660,7 +617,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]; @@ -669,18 +626,18 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { // todo switch to informative variable names for (let i = 0; i < guidedShapes.length; i++) { const d = guidedShapes[i]; - if (d.type === 'annotation') { - continue; - } // fixme avoid this by not letting annotations get in here + if (d.type === 'annotation') continue; // fixme avoid this by not letting annotations get in here // key points of the dragged shape bounding box for (let j = 0; j < shapes.length; j++) { const referenceShape = shapes[j]; - if (referenceShape.type === 'annotation') { - continue; - } // fixme avoid this by not letting annotations get in here - if (referenceShape.parent) { + if (referenceShape.type === 'annotation') continue; // fixme avoid this by not letting annotations get in here + 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; - } // for now, don't snap to grouped elements fixme could snap, but make sure transform is gloabl const s = d.id === referenceShape.id ? { @@ -693,11 +650,9 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { // key points of the stationery shape for (let k = -1; k < 2; k++) { for (let l = -1; l < 2; l++) { - if ((k && !l) || (!k && l)) { - continue; - } // don't worry about midpoints of the edges, only the center + if ((k && !l) || (!k && l)) 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 @@ -705,16 +660,13 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { // moved midpoint on vertical border (extremeVertical === 0 && l !== 0 && extremeHorizontal === k) ) - ) { + ) 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); + if ((m && !n) || (!m && n)) continue; // don't worry about midpoints of the edges, only the center + 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 +676,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 +693,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,51 +733,56 @@ 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; - } + if (!foundShape) return false; if (foundShape.type === 'annotation') { return rotationAnnotation( + configuration, shapes, selectedShapes, shapes.find(s => foundShape.parent === s.id), @@ -834,66 +791,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 +878,41 @@ 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) 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 +920,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 +947,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) ); @@ -1008,12 +990,10 @@ const translateShapeSnap = (horizontalConstraint, verticalConstraint, draggedEle const snapOffsetX = constrainedX ? -horizontalConstraint.signedDistance : 0; const snapOffsetY = constrainedY ? -verticalConstraint.signedDistance : 0; if (constrainedX || constrainedY) { - if (!snapOffsetX && !snapOffsetY) { - return shape; - } + if (!snapOffsetX && !snapOffsetY) return shape; 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 +1023,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 +1056,7 @@ const resizeShapeSnap = ( const snappedShapes = select( ( + configuration, shapes, draggedShape, draggedElement, @@ -1087,16 +1068,22 @@ 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 +1096,7 @@ const snappedShapes = select( return contentShapes.map(snapper); } )( + configuration, transformedShapes, draggedShape, draggedPrimaryShape, @@ -1126,9 +1114,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 +1145,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 +1173,92 @@ 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 = configuration.groupName + '_' + makeUid(); 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 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 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 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 +1271,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 +1334,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 +1363,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 +1379,7 @@ const grouping = select((shapes, selectedShapes) => { selectedShapes: [group], }; } -})(constrainedShapesWithPreexistingAnnotations, selectedShapes); +})(configuration, constrainedShapesWithPreexistingAnnotations, selectedShapes, groupAction); const groupedSelectedShapes = select(({ selectedShapes }) => selectedShapes)(grouping); @@ -1340,14 +1391,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 +1433,27 @@ const bidirectionalCursors = { '315': 'nwse-resize', }; -const cursor = select((shape, draggedPrimaryShape) => { - if (!shape) { - return 'auto'; - } +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 +1466,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 +1475,7 @@ const nextScene = select( .filter(shape => shape.type !== 'annotation') .map(s => s.id); return { + configuration, hoveredShape, selectedShapes: selectedShapeIds, selectedLeafShapes, @@ -1439,6 +1490,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/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index f45e09390ec6f..61d1ae70f5035 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, getElementById, getElements, 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'; @@ -32,9 +33,7 @@ export function getSiblingContext(state, elementId, checkIndex) { // check previous index while we're still above 0 const prevContextIndex = checkIndex - 1; - if (prevContextIndex < 0) { - return {}; - } + if (prevContextIndex < 0) return {}; // walk back up to find the closest cached context available return getSiblingContext(state, elementId, prevContextIndex); @@ -42,9 +41,7 @@ export function getSiblingContext(state, elementId, checkIndex) { function getBareElement(el, includeId = false) { const props = ['position', 'expression', 'filter']; - if (includeId) { - return pick(el, props.concat('id')); - } + if (includeId) return pick(el, props.concat('id')); return cloneDeep(pick(el, props)); } @@ -63,9 +60,7 @@ export const fetchContext = createThunk( const chain = get(element, ['ast', 'chain']); const invalidIndex = chain ? index >= chain.length : true; - if (!element || !chain || invalidIndex) { - throw new Error(`Invalid argument index: ${index}`); - } + if (!element || !chain || invalidIndex) throw new Error(`Invalid argument index: ${index}`); // cache context as the previous index const contextIndex = index - 1; @@ -85,9 +80,7 @@ export const fetchContext = createThunk( // modify the ast chain passed to the interpreter const astChain = element.ast.chain.filter((exp, i) => { - if (prevContextValue != null) { - return i > prevContextIndex && i < index; - } + if (prevContextValue != null) return i > prevContextIndex && i < index; return i < index; }); @@ -202,11 +195,8 @@ export const duplicateElement = createThunk( dispatch(_duplicateElement({ 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)); - } + if (element.filter) dispatch(fetchAllRenderables()); + else dispatch(fetchRenderable(newElement)); // select the new element dispatch(selectElement(newElement.id)); @@ -215,9 +205,20 @@ export const duplicateElement = createThunk( 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 when pros/cons crystallize + const allElements = getElements(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 element = getElementById(state, elementId, pageId); const filterIsApplied = element.filter != null && element.filter.length > 0; return filterIsApplied; }); @@ -228,9 +229,7 @@ export const removeElements = createThunk( })); dispatch(_removeElements(elementIds, pageId)); - if (shouldRefresh) { - dispatch(fetchAllRenderables()); - } + if (shouldRefresh) dispatch(fetchAllRenderables()); } ); @@ -240,9 +239,7 @@ export const setFilter = createThunk( const _setFilter = createAction('setFilter'); dispatch(_setFilter({ filter, elementId, pageId })); - if (doRender === true) { - dispatch(fetchAllRenderables()); - } + if (doRender === true) dispatch(fetchAllRenderables()); } ); @@ -254,9 +251,7 @@ function setExpressionFn({ dispatch, getState }, expression, elementId, pageId, // read updated element from state and fetch renderable const updatedElement = getElementById(getState(), elementId, pageId); - if (doRender === true) { - dispatch(fetchRenderable(updatedElement)); - } + if (doRender === true) dispatch(fetchRenderable(updatedElement)); } const setAst = createThunk('setAst', ({ dispatch }, ast, element, pageId, doRender = true) => { @@ -298,9 +293,7 @@ export const setAstAtIndex = createThunk( const partialAst = { ...newAst, chain: newAst.chain.filter((exp, i) => { - if (contextValue) { - return i > contextIndex; - } + if (contextValue) return i > contextIndex; return i >= index; }), }; @@ -319,9 +312,7 @@ export const setAstAtIndex = createThunk( export const setArgumentAtIndex = createThunk('setArgumentAtIndex', ({ dispatch }, args) => { const { index, argName, value, valueIndex, element, pageId } = args; const selector = ['ast', 'chain', index, 'arguments', argName]; - if (valueIndex != null) { - selector.push(valueIndex); - } + if (valueIndex != null) selector.push(valueIndex); const newElement = set(element, selector, value); const newAst = get(newElement, ['ast', 'chain', index]); @@ -369,22 +360,15 @@ 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) }; - if (element.width) { - newElement.position.width = element.width; - } - if (element.height) { - newElement.position.height = element.height; - } + const newElement = { ...getDefaultElement(), ...getBareElement(element, true) }; + if (element.width) newElement.position.width = element.width; + if (element.height) newElement.position.height = element.height; const _addElement = createAction('addElement'); dispatch(_addElement({ 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)); - } + if (element.filter) dispatch(fetchAllRenderables()); + else dispatch(fetchRenderable(newElement)); // select the new element dispatch(selectElement(newElement.id)); diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index ca50952a7acfb..a757aff32d8dd 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -7,7 +7,8 @@ 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, @@ -23,6 +24,8 @@ import { appReady } from '../actions/app'; import { setWorkpad } from '../actions/workpad'; import { getElements, getPages, getSelectedPage, getSelectedElement } from '../selectors/workpad'; +const isGroupId = id => id.startsWith('group_'); + /** * elementToShape * @@ -52,20 +55,35 @@ const elementToShape = (element, i) => { const z = i; // painter's algo: latest item goes to top // multiplying the angle with -1 as `transform: matrix3d` uses a left-handed coordinate system const angleRadians = (-position.angle / 180) * Math.PI; - const transformMatrix = aero.matrix.multiply( - aero.matrix.translate(cx, cy, z), - aero.matrix.rotateZ(angleRadians) - ); + const localTransformMatrix = + position.localTransformMatrix || + aero.matrix.multiply(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, - transformMatrix, + type: isGroup ? 'group' : 'rectangleElement', + subtype: isGroup ? 'persistentGroup' : '', + parent, + localTransformMatrix: localTransformMatrix, + transformMatrix: localTransformMatrix, 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, + localTransformMatrix: shape.localTransformMatrix, + }; +}; + 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,20 +102,16 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns width: elemPos.width, height: elemPos.height, angle: Math.round(elemPos.angle), + parent: elemPos.parent || null, + localTransformMatrix: + elemPos.localTransformMatrix || + (element && elementToShape(element).localTransformMatrix), }; // 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; - } // recompose.shallowEqual discerns between 0 and -0 + if (1 / newProps.angle === -Infinity) newProps.angle = 0; // recompose.shallowEqual discerns between 0 and -0 return shallowEqual(oldProps, newProps) ? null @@ -105,21 +119,30 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns } }) .filter(identity); - if (repositionings.length) { - setMultiplePositions(repositionings); - } + if (repositionings.length) setMultiplePositions(repositionings); }; 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 const onChangeCallback = ({ state }) => { const nextScene = state.currentScene; - if (!nextScene.gestureEnd) { - return; - } // only update redux on gesture end + if (!nextScene.gestureEnd) return; // only update redux on gesture end // TODO: check for gestureEnd on element selection // read current data out of redux @@ -127,38 +150,88 @@ export const aeroelastic = ({ dispatch, getState }) => { const elements = getElements(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 = getElements(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] }); @@ -195,9 +268,7 @@ export const aeroelastic = ({ dispatch, getState }) => { let lastPageRemoved = false; if (action.type === removePage.toString()) { const preRemoveState = getState(); - if (getPages(preRemoveState).length <= 1) { - lastPageRemoved = true; - } + if (getPages(preRemoveState).length <= 1) lastPageRemoved = true; aero.removeStore(action.payload); } @@ -218,9 +289,7 @@ export const aeroelastic = ({ dispatch, getState }) => { case duplicatePage.toString(): const newPage = getSelectedPage(getState()); createStore(newPage); - if (action.type === duplicatePage.toString()) { - dispatch(fetchAllRenderables()); - } + if (action.type === duplicatePage.toString()) dispatch(fetchAllRenderables()); populateWithElements(newPage); break; @@ -236,11 +305,8 @@ export const aeroelastic = ({ dispatch, getState }) => { case selectElement.toString(): // without this condition, a mouse release anywhere will trigger it, leading to selection of whatever is // underneath the pointer (maybe nothing) when the mouse is released - if (action.payload) { - selectShape(prevPage, action.payload); - } else { - unselectShape(prevPage); - } + if (action.payload) selectShape(prevPage, action.payload); + else unselectShape(prevPage); break; @@ -255,13 +321,9 @@ export const aeroelastic = ({ dispatch, getState }) => { // TODO: add a better check for elements changing, including their position, ids, etc. const shouldResetState = prevPage !== page || !shallowEqual(prevElements.map(id), elements.map(id)); - if (shouldResetState) { - populateWithElements(page); - } + if (shouldResetState) populateWithElements(page); - if (action.type !== setMultiplePositions.toString()) { - unselectShape(prevPage); - } + if (action.type !== setMultiplePositions.toString()) unselectShape(prevPage); break; } diff --git a/x-pack/plugins/canvas/public/state/reducers/pages.js b/x-pack/plugins/canvas/public/state/reducers/pages.js index c58e078b37f9c..74fb33c9e763d 100644 --- a/x-pack/plugins/canvas/public/state/reducers/pages.js +++ b/x-pack/plugins/canvas/public/state/reducers/pages.js @@ -9,6 +9,7 @@ import { set, del, insert } from 'object-path-immutable'; import { getId } from '../../lib/get_id'; import { routerProvider } from '../../lib/router_provider'; import { getDefaultPage } from '../defaults'; +import { arrayToMap } from '../../lib/aeroelastic/functional'; import * as actions from '../actions/pages'; function setPageIndex(workpadState, index) { @@ -29,10 +30,21 @@ 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 getParent = element => element.position.parent; + const groupIdMap = arrayToMap(page.elements.filter(getParent).map(getParent)); + const postfix = getId(''); // will just return a '-e5983ef1-9c7d-4b87-b70a-f34ea199e50c' or so + // remove the possibly preexisting postfix (otherwise it can keep growing...), then append the new postfix + const uniquify = id => (groupIdMap[id] ? id.split('-')[0] + postfix : getId('element')); + // 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) return { ...page, id: getId('page'), - elements: page.elements.map(element => ({ ...element, id: getId('element') })), + elements: page.elements.map(element => ({ + ...element, + id: uniquify(element.id), + position: { ...element.position, parent: getParent(element) && uniquify(getParent(element)) }, + })), }; } From 2f3a51b714e1774fb5b070eab321b90946a0dc41 Mon Sep 17 00:00:00 2001 From: monfera Date: Sat, 15 Dec 2018 00:10:02 +0100 Subject: [PATCH 02/23] lint --- x-pack/plugins/canvas/public/lib/aeroelastic/layout.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js index b5a6d00597df8..a115f512f83ae 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js @@ -1237,9 +1237,7 @@ const resizeChild = groupScale => s => { const resizeGroup = (shapes, rootElement) => { const idMap = {}; - for (let i = 0; i < shapes.length; i++) { - idMap[shapes[i].id] = shapes[i]; - } + for (let i = 0; i < shapes.length; i++) idMap[shapes[i].id] = shapes[i]; const depths = {}; const ancestorsLength = shape => (shape.parent ? ancestorsLength(idMap[shape.parent]) + 1 : 0); From 754a022ef29834a451701f5cf99e9a43f0516b91 Mon Sep 17 00:00:00 2001 From: monfera Date: Sun, 16 Dec 2018 14:54:08 +0100 Subject: [PATCH 03/23] separate elements and groups in storage --- .../plugins/canvas/public/state/defaults.js | 2 + .../public/state/middleware/aeroelastic.js | 11 +-- .../canvas/public/state/reducers/elements.js | 82 +++++++++---------- .../canvas/public/state/selectors/workpad.js | 20 ++++- 4 files changed, 66 insertions(+), 49 deletions(-) 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/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index a757aff32d8dd..12991b66f87d5 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -22,7 +22,7 @@ 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('group_'); @@ -81,6 +81,7 @@ const shapeToElement = shape => { angle: (Math.round(matrixToAngle(shape.transformMatrix)) * 180) / Math.PI, parent: shape.parent || null, localTransformMatrix: shape.localTransformMatrix, + type: shape.type === 'group' ? 'group' : 'element', }; }; @@ -147,7 +148,7 @@ 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; @@ -224,7 +225,7 @@ export const aeroelastic = ({ dispatch, getState }) => { ); const populateWithElements = page => { - const newShapes = getElements(getState(), 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)); @@ -244,7 +245,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; @@ -316,7 +317,7 @@ export const aeroelastic = ({ dispatch, getState }) => { 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..cd7394c51f673 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -9,24 +9,30 @@ 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, elementId) => + workpadState.pages.find(p => p.id === pageId).groups.find(e => e.id === elementId) + ? '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 getElementIndexById(page, elementId, location) { + return page[location].findIndex(element => element.id === elementId); } function assignElementProperties(workpadState, pageId, elementId, props) { const pageIndex = getPageIndexById(workpadState, pageId); - const elementsPath = ['pages', pageIndex, 'elements']; + const location = getLocationFromIds(workpadState, pageId, elementId); + const elementsPath = ['pages', pageIndex, location]; const elementIndex = get(workpadState, elementsPath, []).findIndex( element => element.id === elementId ); - if (pageIndex === -1 || elementIndex === -1) { - return workpadState; - } + if (pageIndex === -1 || elementIndex === -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 @@ -35,34 +41,26 @@ function assignElementProperties(workpadState, pageId, elementId, props) { return assign(cleanWorkpadState, elementsPath.concat(elementIndex), props); } -function moveElementLayer(workpadState, pageId, elementId, movement) { +function moveElementLayer(workpadState, pageId, elementId, movement, location) { const pageIndex = getPageIndexById(workpadState, pageId); - const elementIndex = getElementIndexById(workpadState.pages[pageIndex], elementId); - const elements = get(workpadState, ['pages', pageIndex, 'elements']); + const elementIndex = getElementIndexById(workpadState.pages[pageIndex], elementId, location); + const elements = get(workpadState, ['pages', pageIndex, location]); const from = elementIndex; const to = (function() { - if (movement < Infinity && movement > -Infinity) { - return elementIndex + movement; - } - if (movement === Infinity) { - return elements.length - 1; - } - if (movement === -Infinity) { - return 0; - } + if (movement < Infinity && movement > -Infinity) return elementIndex + movement; + if (movement === Infinity) return elements.length - 1; + if (movement === -Infinity) return 0; throw new Error('Invalid element layer movement'); })(); - if (to > elements.length - 1 || to < 0) { - return workpadState; - } + if (to > elements.length - 1 || to < 0) return workpadState; // Common const newElements = elements.slice(0); newElements.splice(to, 0, newElements.splice(from, 1)[0]); - return set(workpadState, ['pages', pageIndex, 'elements'], newElements); + return set(workpadState, ['pages', pageIndex, location], newElements); } export const elementsReducer = handleActions( @@ -83,38 +81,36 @@ export const elementsReducer = handleActions( workpadState ), [actions.elementLayer]: (workpadState, { payload: { pageId, elementId, movement } }) => { - return moveElementLayer(workpadState, pageId, elementId, movement); + const location = getLocationFromIds(workpadState, pageId, elementId); + return moveElementLayer(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); + if (pageIndex < 0) return workpadState; + return push(workpadState, ['pages', pageIndex, getLocation(element.position.type)], element); }, [actions.duplicateElement]: (workpadState, { payload: { pageId, element } }) => { const pageIndex = getPageIndexById(workpadState, pageId); - if (pageIndex < 0) { - return workpadState; - } - - return push(workpadState, ['pages', pageIndex, 'elements'], element); + if (pageIndex < 0) return workpadState; + return push(workpadState, ['pages', pageIndex, getLocation(element.position.type)], element); }, [actions.removeElements]: (workpadState, { payload: { pageId, elementIds } }) => { const pageIndex = getPageIndexById(workpadState, pageId); - if (pageIndex < 0) { - return workpadState; - } + if (pageIndex < 0) 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 - ); + .map(elementId => { + const location = getLocationFromIds(workpadState, pageId, elementId); + return { + location, + index: getElementIndexById(workpadState.pages[pageIndex], elementId, location), + }; + }) + .sort((a, b) => b.index - a.index); // deleting from end toward beginning, otherwise indices will become off - todo fuse loops! + + return elementIndices.reduce((state, { location, index }) => { + return del(state, ['pages', pageIndex, location, index]); + }, workpadState); }, }, {} diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.js b/x-pack/plugins/canvas/public/state/selectors/workpad.js index 3e291d68bc224..b32d5eea27c32 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.js +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.js @@ -102,7 +102,25 @@ 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); +} + +// 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 = get(page, 'elements').concat(get(page, 'groups')); + + 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) { From e7c90b3e20d0f3f60c9e45f9f4444763660ab3d5 Mon Sep 17 00:00:00 2001 From: monfera Date: Sun, 16 Dec 2018 23:01:31 +0100 Subject: [PATCH 04/23] renamed `element...` to `node...` (except exported names and action payload props, for now) --- .../canvas/public/state/reducers/elements.js | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js index cd7394c51f673..aebe97b712f05 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -11,8 +11,8 @@ import * as actions from '../actions/elements'; const getLocation = type => (type === 'group' ? 'groups' : 'elements'); -const getLocationFromIds = (workpadState, pageId, elementId) => - workpadState.pages.find(p => p.id === pageId).groups.find(e => e.id === elementId) +const getLocationFromIds = (workpadState, pageId, nodeId) => + workpadState.pages.find(p => p.id === pageId).groups.find(e => e.id === nodeId) ? 'groups' : 'elements'; @@ -20,47 +20,45 @@ function getPageIndexById(workpadState, pageId) { return get(workpadState, 'pages', []).findIndex(page => page.id === pageId); } -function getElementIndexById(page, elementId, location) { - return page[location].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 location = getLocationFromIds(workpadState, pageId, elementId); - const elementsPath = ['pages', pageIndex, location]; - 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) return workpadState; + 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, location) { +function moveNodeLayer(workpadState, pageId, nodeId, movement, location) { const pageIndex = getPageIndexById(workpadState, pageId); - const elementIndex = getElementIndexById(workpadState.pages[pageIndex], elementId, location); - const elements = get(workpadState, ['pages', pageIndex, location]); - 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; - if (movement === Infinity) return elements.length - 1; + if (movement < Infinity && movement > -Infinity) return nodeIndex + movement; + if (movement === Infinity) return nodes.length - 1; if (movement === -Infinity) return 0; throw new Error('Invalid element layer movement'); })(); - if (to > elements.length - 1 || to < 0) return workpadState; + 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, location], newElements); + return set(workpadState, ['pages', pageIndex, location], newNodes); } export const elementsReducer = handleActions( @@ -68,21 +66,21 @@ 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 }), workpadState ), [actions.elementLayer]: (workpadState, { payload: { pageId, elementId, movement } }) => { const location = getLocationFromIds(workpadState, pageId, elementId); - return moveElementLayer(workpadState, pageId, elementId, movement, location); + return moveNodeLayer(workpadState, pageId, elementId, movement, location); }, [actions.addElement]: (workpadState, { payload: { pageId, element } }) => { const pageIndex = getPageIndexById(workpadState, pageId); @@ -98,17 +96,17 @@ export const elementsReducer = handleActions( const pageIndex = getPageIndexById(workpadState, pageId); if (pageIndex < 0) return workpadState; - const elementIndices = elementIds - .map(elementId => { - const location = getLocationFromIds(workpadState, pageId, elementId); + const nodeIndices = elementIds + .map(nodeId => { + const location = getLocationFromIds(workpadState, pageId, nodeId); return { location, - index: getElementIndexById(workpadState.pages[pageIndex], elementId, 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 elementIndices.reduce((state, { location, index }) => { + return nodeIndices.reduce((state, { location, index }) => { return del(state, ['pages', pageIndex, location, index]); }, workpadState); }, From 9778e2e8d9bc65cd42340c271f7f513dc58c8de3 Mon Sep 17 00:00:00 2001 From: monfera Date: Sun, 16 Dec 2018 23:36:24 +0100 Subject: [PATCH 05/23] be able to remove a group --- x-pack/plugins/canvas/public/state/actions/elements.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index 61d1ae70f5035..36c1d9dba045c 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -10,7 +10,7 @@ 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, getElements, getSelectedPageIndex } from '../selectors/workpad'; +import { getPages, getElementById, getNodes, getSelectedPageIndex } from '../selectors/workpad'; import { getValue as getResolvedArgsValue } from '../selectors/resolved_args'; import { getDefaultElement } from '../defaults'; import { notify } from '../../lib/notify'; @@ -208,8 +208,8 @@ export const removeElements = createThunk( ({ dispatch, getState }, rootElementIds, pageId) => { const state = getState(); - // todo consider doing the group membership collation in aeroelastic when pros/cons crystallize - const allElements = getElements(state, pageId); + // 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'); From d1e57e68832224bb4a899e1d69f1a992e6ed7cfc Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 17 Dec 2018 00:01:15 +0100 Subject: [PATCH 06/23] fixing group deletion --- x-pack/plugins/canvas/public/state/actions/elements.js | 8 ++++---- x-pack/plugins/canvas/public/state/selectors/workpad.js | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index 36c1d9dba045c..5d5b9c43c4fab 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -10,7 +10,7 @@ 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, getNodes, 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'; @@ -218,8 +218,8 @@ export const removeElements = createThunk( ); const shouldRefresh = elementIds.some(elementId => { - const element = getElementById(state, 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; }); @@ -250,7 +250,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)); } diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.js b/x-pack/plugins/canvas/public/state/selectors/workpad.js index b32d5eea27c32..af78898f8c32e 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.js +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.js @@ -131,12 +131,19 @@ export function getNodes(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; From 8815027c5d85bc639bfc4192be8f43cce622af90 Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 17 Dec 2018 00:21:05 +0100 Subject: [PATCH 07/23] not re-persisting unnecessarily --- x-pack/plugins/canvas/public/state/middleware/aeroelastic.js | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index 12991b66f87d5..3c83a35a60f74 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -103,6 +103,7 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns width: elemPos.width, height: elemPos.height, angle: Math.round(elemPos.angle), + type: elemPos.type, parent: elemPos.parent || null, localTransformMatrix: elemPos.localTransformMatrix || From c8bda80b1751b543e5d25b2cd0cb181c4e1b63dd Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 17 Dec 2018 00:42:42 +0100 Subject: [PATCH 08/23] persist / unpersist group right on the keyboard action --- x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js index ae6c5b1cfac48..c933e54b705f1 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js @@ -71,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 From cf376429c888796284802f2be66cccb9f48e9722 Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 17 Dec 2018 02:08:29 +0100 Subject: [PATCH 09/23] solving inverted cascading for backward compatibility --- .../canvas/public/lib/aeroelastic/layout.js | 16 +++++++++++++--- .../public/state/middleware/aeroelastic.js | 13 ++++--------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js index a115f512f83ae..3c36e33b3aee6 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js @@ -134,9 +134,19 @@ const mouseTransformGesture = select(tuple => 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 diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index 3c83a35a60f74..1cfb917643e37 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -55,8 +55,8 @@ const elementToShape = (element, i) => { const z = i; // painter's algo: latest item goes to top // multiplying the angle with -1 as `transform: matrix3d` uses a left-handed coordinate system const angleRadians = (-position.angle / 180) * Math.PI; - const localTransformMatrix = - position.localTransformMatrix || + const transformMatrix = + //position.localTransformMatrix || aero.matrix.multiply(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 @@ -65,8 +65,7 @@ const elementToShape = (element, i) => { type: isGroup ? 'group' : 'rectangleElement', subtype: isGroup ? 'persistentGroup' : '', parent, - localTransformMatrix: localTransformMatrix, - transformMatrix: localTransformMatrix, + transformMatrix, a, // we currently specify half-width, half-height as it leads to b, // more regular math (like ellipsis radii rather than diameters) }; @@ -78,9 +77,8 @@ const shapeToElement = shape => { top: shape.transformMatrix[13] - shape.b, width: shape.a * 2, height: shape.b * 2, - angle: (Math.round(matrixToAngle(shape.transformMatrix)) * 180) / Math.PI, + angle: Math.round((matrixToAngle(shape.transformMatrix) * 180) / Math.PI), parent: shape.parent || null, - localTransformMatrix: shape.localTransformMatrix, type: shape.type === 'group' ? 'group' : 'element', }; }; @@ -105,9 +103,6 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns angle: Math.round(elemPos.angle), type: elemPos.type, parent: elemPos.parent || null, - localTransformMatrix: - elemPos.localTransformMatrix || - (element && elementToShape(element).localTransformMatrix), }; // cast shape into element-like object to compare From c35088e27505cf3437bf5835563bed51fe83c959 Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 17 Dec 2018 02:13:45 +0100 Subject: [PATCH 10/23] fix failing test case --- x-pack/plugins/canvas/public/state/reducers/elements.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js index aebe97b712f05..ee8631344fbe2 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -11,10 +11,10 @@ import * as actions from '../actions/elements'; const getLocation = type => (type === 'group' ? 'groups' : 'elements'); -const getLocationFromIds = (workpadState, pageId, nodeId) => - workpadState.pages.find(p => p.id === pageId).groups.find(e => e.id === nodeId) - ? 'groups' - : 'elements'; +const getLocationFromIds = (workpadState, pageId, nodeId) => { + const groups = workpadState.pages.find(p => p.id === pageId).groups || []; + return groups.find(e => e.id === nodeId) ? 'groups' : 'elements'; +}; function getPageIndexById(workpadState, pageId) { return get(workpadState, 'pages', []).findIndex(page => page.id === pageId); From f427511bdd23ac4c9a27f323d54e8af282961ed7 Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 17 Dec 2018 16:43:06 +0100 Subject: [PATCH 11/23] page cloning with group trees of arbitrary depth --- .../public/components/workpad_page/index.js | 20 +++++++++++++++---- .../canvas/public/state/reducers/pages.js | 20 ++++++++++++------- 2 files changed, 29 insertions(+), 11 deletions(-) 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..b7b185a44d2a8 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js @@ -12,14 +12,15 @@ import { aeroelastic } from '../../lib/aeroelastic_kibana'; import { setClipboardData, getClipboardData } from '../../lib/clipboard'; import { removeElements, duplicateElement } 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), }; }; @@ -81,11 +82,22 @@ export const WorkpadPage = compose( 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 selectedElementIds = flatten(selectedPrimaryShapes.map(recurseGroupTree)); const selectedElements = []; const elements = shapes.map(shape => { let element = null; diff --git a/x-pack/plugins/canvas/public/state/reducers/pages.js b/x-pack/plugins/canvas/public/state/reducers/pages.js index 74fb33c9e763d..27e9964a85b40 100644 --- a/x-pack/plugins/canvas/public/state/reducers/pages.js +++ b/x-pack/plugins/canvas/public/state/reducers/pages.js @@ -27,24 +27,30 @@ function addPage(workpadState, payload, srcIndex = workpadState.pages.length - 1 return insert(workpadState, 'pages', payload || getDefaultPage(), srcIndex + 1); } +const getParent = element => element.position.parent; + 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 getParent = element => element.position.parent; - const groupIdMap = arrayToMap(page.elements.filter(getParent).map(getParent)); + const elements = page.elements; + const groups = page.groups; + const nodes = elements.concat(groups); + const groupIdMap = arrayToMap(nodes.filter(getParent).map(getParent)); const postfix = getId(''); // will just return a '-e5983ef1-9c7d-4b87-b70a-f34ea199e50c' or so // remove the possibly preexisting postfix (otherwise it can keep growing...), then append the new postfix const uniquify = id => (groupIdMap[id] ? id.split('-')[0] + postfix : getId('element')); // 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) + const newNodes = nodes.map(element => ({ + ...element, + id: uniquify(element.id), + position: { ...element.position, parent: getParent(element) && uniquify(getParent(element)) }, + })); return { ...page, id: getId('page'), - elements: page.elements.map(element => ({ - ...element, - id: uniquify(element.id), - position: { ...element.position, parent: getParent(element) && uniquify(getParent(element)) }, - })), + groups: newNodes.filter(n => n.position.type === 'group'), + elements: newNodes.filter(n => n.position.type !== 'group'), }; } From b8cf38083d048c3236e075b2b3b4559692f06141 Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 17 Dec 2018 18:09:52 +0100 Subject: [PATCH 12/23] extracted out cloneSubgraphs --- .../canvas/public/state/reducers/pages.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/canvas/public/state/reducers/pages.js b/x-pack/plugins/canvas/public/state/reducers/pages.js index 27e9964a85b40..f53dd44342fa5 100644 --- a/x-pack/plugins/canvas/public/state/reducers/pages.js +++ b/x-pack/plugins/canvas/public/state/reducers/pages.js @@ -29,12 +29,7 @@ function addPage(workpadState, payload, srcIndex = workpadState.pages.length - 1 const getParent = element => element.position.parent; -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 cloneSubgraphs = nodes => { const groupIdMap = arrayToMap(nodes.filter(getParent).map(getParent)); const postfix = getId(''); // will just return a '-e5983ef1-9c7d-4b87-b70a-f34ea199e50c' or so // remove the possibly preexisting postfix (otherwise it can keep growing...), then append the new postfix @@ -46,6 +41,16 @@ function clonePage(page) { id: uniquify(element.id), position: { ...element.position, parent: getParent(element) && uniquify(getParent(element)) }, })); + return newNodes; +}; + +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'), From f0a3c978be0170ee95f20d73fbeef7e9a3c2c3bd Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 17 Dec 2018 19:27:58 +0100 Subject: [PATCH 13/23] basic copy-paste that handles a grouping tree of arbitrary depth --- .../public/components/workpad_page/index.js | 33 +++++++------------ .../canvas/public/lib/clone_subgraphs.js | 25 ++++++++++++++ .../canvas/public/state/actions/elements.js | 19 +++++++++++ .../public/state/middleware/aeroelastic.js | 2 ++ .../canvas/public/state/reducers/elements.js | 5 +++ .../canvas/public/state/reducers/pages.js | 19 +---------- 6 files changed, 64 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/canvas/public/lib/clone_subgraphs.js 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 b7b185a44d2a8..a02843daa2096 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js @@ -10,7 +10,8 @@ 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 { getNodes, isWriteable } from '../../state/selectors/workpad'; import { flatten } from '../../lib/aeroelastic/functional'; @@ -26,16 +27,14 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = dispatch => { return { - duplicateElement: pageId => selectedElement => - dispatch(duplicateElement(selectedElement, pageId)), + rawDuplicateElement: pageId => selectedElement => + dispatch(rawDuplicateElement(selectedElement, pageId)), removeElements: pageId => elementIds => dispatch(removeElements(elementIds, pageId)), }; }; const getRootElementId = (lookup, id) => { - if (!lookup.has(id)) { - return null; - } + if (!lookup.has(id)) return null; const element = lookup.get(id); return element.parent && element.parent.subtype !== 'adHocGroup' @@ -50,16 +49,12 @@ export const WorkpadPage = compose( ), withProps(({ isSelected, animation }) => { function getClassName() { - if (animation) { - return animation.name; - } + if (animation) return animation.name; return isSelected ? 'canvasPage--isActive' : 'canvasPage--isInactive'; } function getAnimationStyle() { - if (!animation) { - return {}; - } + if (!animation) return {}; return { animationDirection: animation.direction, // TODO: Make this configurable @@ -79,8 +74,8 @@ export const WorkpadPage = compose( setUpdateCount, page, elements: pageElements, + rawDuplicateElement, removeElements, - duplicateElement, }) => { const { shapes, selectedPrimaryShapes = [], cursor } = aeroelastic.getStore( page.id @@ -103,9 +98,8 @@ export const WorkpadPage = compose( let element = null; if (elementLookup.has(shape.id)) { element = elementLookup.get(shape.id); - if (selectedElementIds.indexOf(shape.id) > -1) { + if (selectedElementIds.indexOf(shape.id) > -1) selectedElements.push({ ...element, id: shape.id }); - } } // instead of just combining `element` with `shape`, we make property transfer explicit return element ? { ...shape, filter: element.filter } : shape; @@ -120,9 +114,7 @@ export const WorkpadPage = compose( }, remove: () => { // currently, handle the removal of one element, exploiting multiselect subsequently - if (selectedElementIds.length) { - removeElements(page.id)(selectedElementIds); - } + if (selectedElementIds.length) removeElements(page.id)(selectedElementIds); }, copyElements: () => { if (selectedElements.length) { @@ -139,9 +131,8 @@ export const WorkpadPage = compose( }, pasteElements: () => { const elements = JSON.parse(getClipboardData()); - if (elements) { - elements.map(element => duplicateElement(page.id)(element)); - } + const clonedElements = elements && cloneSubgraphs(elements); + if (clonedElements) clonedElements.map(element => rawDuplicateElement(page.id)(element)); }, }; } 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..65e54aa1037c3 --- /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'; + +const getParent = element => element.position.parent; + +export const cloneSubgraphs = nodes => { + const groupIdMap = arrayToMap(nodes.filter(getParent).map(getParent)); + const postfix = getId(''); // will just return a '-e5983ef1-9c7d-4b87-b70a-f34ea199e50c' or so + // remove the possibly preexisting postfix (otherwise it can keep growing...), then append the new postfix + const uniquify = id => (groupIdMap[id] ? id.split('-')[0] + postfix : getId('element')); + // 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) + const newNodes = nodes.map(element => ({ + ...element, + id: uniquify(element.id), + position: { ...element.position, parent: getParent(element) && uniquify(getParent(element)) }, + })); + 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 5d5b9c43c4fab..5f5b4a0509ee4 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -203,6 +203,25 @@ export const duplicateElement = createThunk( } ); +export const rawDuplicateElement = createThunk( + 'rawDuplicateElement', + ({ dispatch, type }, element, pageId) => { + const newElement = cloneDeep(element); + // move the 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 + //dispatch(selectElement(newElement.id)); + } +); + export const removeElements = createThunk( 'removeElements', ({ dispatch, getState }, rootElementIds, pageId) => { diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index 1cfb917643e37..5bc920b52dfa8 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -13,6 +13,7 @@ import { addElement, removeElements, duplicateElement, + rawDuplicateElement, elementLayer, setMultiplePositions, fetchAllRenderables, @@ -310,6 +311,7 @@ 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()); diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js index ee8631344fbe2..31d48264f323a 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -92,6 +92,11 @@ export const elementsReducer = handleActions( if (pageIndex < 0) return workpadState; return push(workpadState, ['pages', pageIndex, getLocation(element.position.type)], 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)], element); + }, [actions.removeElements]: (workpadState, { payload: { pageId, elementIds } }) => { const pageIndex = getPageIndexById(workpadState, pageId); if (pageIndex < 0) return workpadState; diff --git a/x-pack/plugins/canvas/public/state/reducers/pages.js b/x-pack/plugins/canvas/public/state/reducers/pages.js index f53dd44342fa5..2ad01faafbd37 100644 --- a/x-pack/plugins/canvas/public/state/reducers/pages.js +++ b/x-pack/plugins/canvas/public/state/reducers/pages.js @@ -6,10 +6,10 @@ 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'; -import { arrayToMap } from '../../lib/aeroelastic/functional'; import * as actions from '../actions/pages'; function setPageIndex(workpadState, index) { @@ -27,23 +27,6 @@ function addPage(workpadState, payload, srcIndex = workpadState.pages.length - 1 return insert(workpadState, 'pages', payload || getDefaultPage(), srcIndex + 1); } -const getParent = element => element.position.parent; - -const cloneSubgraphs = nodes => { - const groupIdMap = arrayToMap(nodes.filter(getParent).map(getParent)); - const postfix = getId(''); // will just return a '-e5983ef1-9c7d-4b87-b70a-f34ea199e50c' or so - // remove the possibly preexisting postfix (otherwise it can keep growing...), then append the new postfix - const uniquify = id => (groupIdMap[id] ? id.split('-')[0] + postfix : getId('element')); - // 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) - const newNodes = nodes.map(element => ({ - ...element, - id: uniquify(element.id), - position: { ...element.position, parent: getParent(element) && uniquify(getParent(element)) }, - })); - return newNodes; -}; - 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 From b9de698bfda652efa4ee097b151a336eed8caf0b Mon Sep 17 00:00:00 2001 From: monfera Date: Mon, 17 Dec 2018 21:20:23 +0100 Subject: [PATCH 14/23] fix: when legacy dataset doesn't have `groups`, it should avoid an `undefined` --- x-pack/plugins/canvas/public/state/selectors/workpad.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.js b/x-pack/plugins/canvas/public/state/selectors/workpad.js index af78898f8c32e..bb7429460a092 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.js +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.js @@ -116,7 +116,7 @@ export function getNodes(state, pageId, withAst = true) { if (!id) return []; const page = getPageById(state, id); - const elements = get(page, 'elements').concat(get(page, 'groups')); + const elements = get(page, 'elements').concat(get(page, 'groups') || []); if (!elements) return []; From 175edb4c016a2dfae52df386c8229f69ce61c91d Mon Sep 17 00:00:00 2001 From: monfera Date: Tue, 18 Dec 2018 01:26:28 +0100 Subject: [PATCH 15/23] PR feedback: regularize group IDs --- .../canvas/public/lib/aeroelastic/layout.js | 5 +++-- .../plugins/canvas/public/lib/clone_subgraphs.js | 15 +++++++-------- .../canvas/public/state/middleware/aeroelastic.js | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js index 3c36e33b3aee6..4891dc173342d 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js @@ -4,7 +4,8 @@ * 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, @@ -1192,7 +1193,7 @@ const idsMatch = selectedShapes => shape => selectedShapes.find(idMatch(shape)); const axisAlignedBoundingBoxShape = (configuration, shapesToBox) => { const axisAlignedBoundingBox = getAABB(shapesToBox); const { a, b, localTransformMatrix, rigTransform } = projectAABB(axisAlignedBoundingBox); - const id = configuration.groupName + '_' + makeUid(); + const id = getId(configuration.groupName); const aabbShape = { id, type: configuration.groupName, diff --git a/x-pack/plugins/canvas/public/lib/clone_subgraphs.js b/x-pack/plugins/canvas/public/lib/clone_subgraphs.js index 65e54aa1037c3..c9e5ad6967592 100644 --- a/x-pack/plugins/canvas/public/lib/clone_subgraphs.js +++ b/x-pack/plugins/canvas/public/lib/clone_subgraphs.js @@ -7,19 +7,18 @@ import { arrayToMap } from './aeroelastic/functional'; import { getId } from './get_id'; -const getParent = element => element.position.parent; - export const cloneSubgraphs = nodes => { - const groupIdMap = arrayToMap(nodes.filter(getParent).map(getParent)); - const postfix = getId(''); // will just return a '-e5983ef1-9c7d-4b87-b70a-f34ea199e50c' or so - // remove the possibly preexisting postfix (otherwise it can keep growing...), then append the new postfix - const uniquify = id => (groupIdMap[id] ? id.split('-')[0] + postfix : getId('element')); + 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.entries(idMap).forEach(([key]) => (idMap[key] = getId(key.split('-')[0]))); // new group names to which we can map const newNodes = nodes.map(element => ({ ...element, - id: uniquify(element.id), - position: { ...element.position, parent: getParent(element) && uniquify(getParent(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/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index 5bc920b52dfa8..1cbe12c09c4b7 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -25,7 +25,7 @@ import { appReady } from '../actions/app'; import { setWorkpad } from '../actions/workpad'; import { getNodes, getPages, getSelectedPage, getSelectedElement } from '../selectors/workpad'; -const isGroupId = id => id.startsWith('group_'); +const isGroupId = id => id.startsWith(defaultConfiguration.groupName); /** * elementToShape From d0dd92f9fdec1c0f3cf2451302f840556e117d50 Mon Sep 17 00:00:00 2001 From: monfera Date: Tue, 18 Dec 2018 09:08:10 +0100 Subject: [PATCH 16/23] lint: curlies --- .../public/components/workpad_page/index.js | 23 +++-- .../canvas/public/lib/aeroelastic/layout.js | 99 ++++++++++++++----- .../canvas/public/state/actions/elements.js | 68 +++++++++---- .../public/state/middleware/aeroelastic.js | 53 +++++++--- .../canvas/public/state/reducers/elements.js | 36 +++++-- .../canvas/public/state/selectors/workpad.js | 16 ++- 6 files changed, 218 insertions(+), 77 deletions(-) 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 a02843daa2096..1642ebf41b34f 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js @@ -34,7 +34,9 @@ const mapDispatchToProps = dispatch => { }; const getRootElementId = (lookup, id) => { - if (!lookup.has(id)) return null; + if (!lookup.has(id)) { + return null; + } const element = lookup.get(id); return element.parent && element.parent.subtype !== 'adHocGroup' @@ -49,12 +51,16 @@ export const WorkpadPage = compose( ), withProps(({ isSelected, animation }) => { function getClassName() { - if (animation) return animation.name; + if (animation) { + return animation.name; + } return isSelected ? 'canvasPage--isActive' : 'canvasPage--isInactive'; } function getAnimationStyle() { - if (!animation) return {}; + if (!animation) { + return {}; + } return { animationDirection: animation.direction, // TODO: Make this configurable @@ -98,8 +104,9 @@ export const WorkpadPage = compose( let element = null; if (elementLookup.has(shape.id)) { element = elementLookup.get(shape.id); - if (selectedElementIds.indexOf(shape.id) > -1) + if (selectedElementIds.indexOf(shape.id) > -1) { selectedElements.push({ ...element, id: shape.id }); + } } // instead of just combining `element` with `shape`, we make property transfer explicit return element ? { ...shape, filter: element.filter } : shape; @@ -114,7 +121,9 @@ export const WorkpadPage = compose( }, remove: () => { // currently, handle the removal of one element, exploiting multiselect subsequently - if (selectedElementIds.length) removeElements(page.id)(selectedElementIds); + if (selectedElementIds.length) { + removeElements(page.id)(selectedElementIds); + } }, copyElements: () => { if (selectedElements.length) { @@ -132,7 +141,9 @@ export const WorkpadPage = compose( pasteElements: () => { const elements = JSON.parse(getClipboardData()); const clonedElements = elements && cloneSubgraphs(elements); - if (clonedElements) clonedElements.map(element => rawDuplicateElement(page.id)(element)); + if (clonedElements) { + clonedElements.map(element => rawDuplicateElement(page.id)(element)); + } }, }; } diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js index 4891dc173342d..563e0f817047d 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js @@ -136,10 +136,14 @@ const mouseTransformGesture = select(tuple => const transformGestures = mouseTransformGesture; const restateShapesEvent = select(action => { - if (!action || action.type !== 'restateShapesEvent') return null; + if (!action || action.type !== 'restateShapesEvent') { + return null; + } const shapes = action.payload.newShapes; const local = shape => { - if (!shape.parent) return shape.transformMatrix; + if (!shape.parent) { + return shape.transformMatrix; + } return matrix.multiply( matrix.invert(shapes.find(s => s.id === shape.parent).transformMatrix), shape.transformMatrix @@ -231,9 +235,13 @@ const selectionState = select( down: prev.down, }; } - if (selectedShapeObjects) prev.shapes = selectedShapeObjects.slice(); + if (selectedShapeObjects) { + prev.shapes = selectedShapeObjects.slice(); + } // take action on mouse down only, and if the uid changed (except with directSelect), ie. bail otherwise - if (mouseButtonUp || (uidUnchanged && !directSelect)) return { ...prev, down, uid, metaHeld }; + if (mouseButtonUp || (uidUnchanged && !directSelect)) { + return { ...prev, down, uid, metaHeld }; + } const selectFunction = configuration.singleSelect || !multiselect ? singleSelect : multiSelect; return selectFunction(prev, configuration, hoveredShapes, metaHeld, uid, selectedShapeObjects); } @@ -266,7 +274,9 @@ const rotationManipulation = configuration => ({ alterSnapGesture, }) => { // rotate around a Z-parallel line going through the shape center (ie. around the center) - if (!shape || !directShape) return { transforms: [], shapes: [] }; + if (!shape || !directShape) { + return { transforms: [], shapes: [] }; + } const center = shape.transformMatrix; const centerPosition = matrix.mvMultiply(center, matrix.ORIGIN); const vector = matrix.mvMultiply( @@ -306,7 +316,9 @@ const minimumSize = (min, { a, b, baseAB }, vector) => { 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) return { transforms: [], shapes: [] }; + if (!shape || !directShape) { + return { transforms: [], shapes: [] }; + } // transform the incoming `transform` so that resizing is aligned with shape orientation const vector = matrix.mvMultiply( matrix.multiply( @@ -332,7 +344,9 @@ const centeredResizeManipulation = configuration => ({ gesture, shape, directSha 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) return { transforms: [], shapes: [] }; + if (!shape || !directShape) { + return { transforms: [], shapes: [] }; + } // transform the incoming `transform` so that resizing is aligned with shape orientation const compositeComponent = matrix.compositeComponent(shape.localTransformMatrix); const inv = matrix.invert(compositeComponent); // rid the translate component @@ -587,7 +601,9 @@ const snappedA = shape => shape.a + (shape.snapResizeVector ? shape.snapResizeVe const snappedB = shape => shape.b + (shape.snapResizeVector ? shape.snapResizeVector[1] : 0); const cascadeUnsnappedTransforms = (shapes, shape) => { - if (!shape.parent) return shape.localTransformMatrix; // boost for common case of toplevel 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.localTransformMatrix; @@ -601,7 +617,9 @@ const cascadeTransforms = (shapes, shape) => { s.snapDeltaMatrix ? matrix.multiply(s.localTransformMatrix, s.snapDeltaMatrix) : s.localTransformMatrix; - if (!shape.parent) return cascade(shape); // boost for common case of toplevel shape + 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); @@ -620,7 +638,9 @@ const shapeCascadeProperties = shapes => shape => { const cascadeProperties = shapes => shapes.map(shapeCascadeProperties(shapes)); const nextShapes = select((preexistingShapes, restated) => { - if (restated && restated.newShapes) return restated.newShapes; + if (restated && restated.newShapes) { + return restated.newShapes; + } // this is the per-shape model update at the current PoC level return preexistingShapes; @@ -637,18 +657,25 @@ const alignmentGuides = (configuration, shapes, guidedShapes, draggedShape) => { // todo switch to informative variable names for (let i = 0; i < guidedShapes.length; i++) { const d = guidedShapes[i]; - if (d.type === 'annotation') continue; // fixme avoid this by not letting annotations get in here + if (d.type === 'annotation') { + continue; + } // fixme avoid this by not letting annotations get in here // key points of the dragged shape bounding box for (let j = 0; j < shapes.length; j++) { const referenceShape = shapes[j]; - if (referenceShape.type === 'annotation') continue; // fixme avoid this by not letting annotations get in here - if (!configuration.intraGroupManipulation && referenceShape.parent) continue; // for now, don't snap to grouped elements fixme could snap, but make sure transform is gloabl + if (referenceShape.type === 'annotation') { + continue; + } // fixme avoid this by not letting annotations get in here + 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 ? { @@ -661,7 +688,9 @@ const alignmentGuides = (configuration, shapes, guidedShapes, draggedShape) => { // key points of the stationery shape for (let k = -1; k < 2; k++) { for (let l = -1; l < 2; l++) { - if ((k && !l) || (!k && l)) continue; // don't worry about midpoints of the edges, only the center + if ((k && !l) || (!k && l)) { + continue; + } // don't worry about midpoints of the edges, only the center if ( draggedShape.subtype === configuration.resizeHandleName && !( @@ -671,12 +700,15 @@ const alignmentGuides = (configuration, shapes, guidedShapes, draggedShape) => { // moved midpoint on vertical border (extremeVertical === 0 && l !== 0 && extremeHorizontal === k) ) - ) + ) { continue; + } 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 + if ((m && !n) || (!m && n)) { + continue; + } // don't worry about midpoints of the edges, only the center const S = landmarkPoint(s.a, s.b, cascadeUnsnappedTransforms(shapes, s), m, n); for (let dim = 0; dim < 2; dim++) { const orthogonalDimension = 1 - dim; @@ -789,7 +821,9 @@ const hoverAnnotations = select( const rotationAnnotation = (configuration, shapes, selectedShapes, shape, i) => { const foundShape = shapes.find(s => shape.id === s.id); - if (!foundShape) return false; + if (!foundShape) { + return false; + } if (foundShape.type === 'annotation') { return rotationAnnotation( @@ -918,7 +952,9 @@ function resizeAnnotation(configuration, shapes, selectedShapes, shape) { (foundShape.subtype === configuration.resizeHandleName ? shapes.find(s => shape.parent === s.id) : foundShape); - if (!foundShape) return []; + if (!foundShape) { + return []; + } if (foundShape.subtype === configuration.resizeHandleName) { // preserve any interactive annotation when handling @@ -1001,7 +1037,9 @@ const translateShapeSnap = (horizontalConstraint, verticalConstraint, draggedEle const snapOffsetX = constrainedX ? -horizontalConstraint.signedDistance : 0; const snapOffsetY = constrainedY ? -verticalConstraint.signedDistance : 0; if (constrainedX || constrainedY) { - if (!snapOffsetX && !snapOffsetY) return shape; + if (!snapOffsetX && !snapOffsetY) { + return shape; + } const snapOffset = matrix.translateComponent( matrix.multiply( matrix.rotateZ(matrix.matrixToAngle(draggedElement.localTransformMatrix)), @@ -1086,8 +1124,9 @@ const snappedShapes = select( 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 = configuration.snapConstraint && !relaxed; @@ -1248,11 +1287,15 @@ const resizeChild = groupScale => s => { const resizeGroup = (shapes, rootElement) => { const idMap = {}; - for (let i = 0; i < shapes.length; i++) idMap[shapes[i].id] = shapes[i]; + for (let i = 0; i < shapes.length; i++) { + idMap[shapes[i].id] = shapes[i]; + } 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]); + for (let i = 0; i < shapes.length; i++) { + depths[shapes[i].id] = ancestorsLength(shapes[i]); + } const resizedParents = { [rootElement.id]: rootElement }; const sortedShapes = shapes.slice().sort((a, b) => depths[a.id] - depths[b.id]); @@ -1262,9 +1305,11 @@ const resizeGroup = (shapes, rootElement) => { const parent = resizedParents[shape.parent]; if (parent) { resizedParents[shape.id] = shape; - if (parentResized(parent)) + if (parentResized(parent)) { resizeChild(childScaler(parent, parent.childBaseAB || parent.baseAB))(shape); - else resetChild(shape); + } else { + resetChild(shape); + } } } return sortedShapes; @@ -1443,7 +1488,9 @@ const bidirectionalCursors = { }; const cursor = select((configuration, shape, draggedPrimaryShape) => { - if (!shape) return 'auto'; + if (!shape) { + return 'auto'; + } switch (shape.subtype) { case configuration.rotationHandleName: return 'crosshair'; diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index 5f5b4a0509ee4..8d623c41b4edf 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -33,7 +33,9 @@ export function getSiblingContext(state, elementId, checkIndex) { // check previous index while we're still above 0 const prevContextIndex = checkIndex - 1; - if (prevContextIndex < 0) return {}; + if (prevContextIndex < 0) { + return {}; + } // walk back up to find the closest cached context available return getSiblingContext(state, elementId, prevContextIndex); @@ -41,7 +43,9 @@ export function getSiblingContext(state, elementId, checkIndex) { function getBareElement(el, includeId = false) { const props = ['position', 'expression', 'filter']; - if (includeId) return pick(el, props.concat('id')); + if (includeId) { + return pick(el, props.concat('id')); + } return cloneDeep(pick(el, props)); } @@ -60,7 +64,9 @@ export const fetchContext = createThunk( const chain = get(element, ['ast', 'chain']); const invalidIndex = chain ? index >= chain.length : true; - if (!element || !chain || invalidIndex) throw new Error(`Invalid argument index: ${index}`); + if (!element || !chain || invalidIndex) { + throw new Error(`Invalid argument index: ${index}`); + } // cache context as the previous index const contextIndex = index - 1; @@ -80,7 +86,9 @@ export const fetchContext = createThunk( // modify the ast chain passed to the interpreter const astChain = element.ast.chain.filter((exp, i) => { - if (prevContextValue != null) return i > prevContextIndex && i < index; + if (prevContextValue != null) { + return i > prevContextIndex && i < index; + } return i < index; }); @@ -195,8 +203,11 @@ export const duplicateElement = createThunk( dispatch(_duplicateElement({ 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)); + if (element.filter) { + dispatch(fetchAllRenderables()); + } else { + dispatch(fetchRenderable(newElement)); + } // select the new element dispatch(selectElement(newElement.id)); @@ -214,8 +225,11 @@ export const rawDuplicateElement = createThunk( 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)); + if (element.filter) { + dispatch(fetchAllRenderables()); + } else { + dispatch(fetchRenderable(newElement)); + } // select the new element //dispatch(selectElement(newElement.id)); @@ -230,8 +244,9 @@ export const removeElements = createThunk( // 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) + 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 ); @@ -248,7 +263,9 @@ export const removeElements = createThunk( })); dispatch(_removeElements(elementIds, pageId)); - if (shouldRefresh) dispatch(fetchAllRenderables()); + if (shouldRefresh) { + dispatch(fetchAllRenderables()); + } } ); @@ -258,7 +275,9 @@ export const setFilter = createThunk( const _setFilter = createAction('setFilter'); dispatch(_setFilter({ filter, elementId, pageId })); - if (doRender === true) dispatch(fetchAllRenderables()); + if (doRender === true) { + dispatch(fetchAllRenderables()); + } } ); @@ -270,7 +289,9 @@ function setExpressionFn({ dispatch, getState }, expression, elementId, pageId, // read updated element from state and fetch renderable const updatedElement = getNodeById(getState(), elementId, pageId); - if (doRender === true) dispatch(fetchRenderable(updatedElement)); + if (doRender === true) { + dispatch(fetchRenderable(updatedElement)); + } } const setAst = createThunk('setAst', ({ dispatch }, ast, element, pageId, doRender = true) => { @@ -312,7 +333,9 @@ export const setAstAtIndex = createThunk( const partialAst = { ...newAst, chain: newAst.chain.filter((exp, i) => { - if (contextValue) return i > contextIndex; + if (contextValue) { + return i > contextIndex; + } return i >= index; }), }; @@ -331,7 +354,9 @@ export const setAstAtIndex = createThunk( export const setArgumentAtIndex = createThunk('setArgumentAtIndex', ({ dispatch }, args) => { const { index, argName, value, valueIndex, element, pageId } = args; const selector = ['ast', 'chain', index, 'arguments', argName]; - if (valueIndex != null) selector.push(valueIndex); + if (valueIndex != null) { + selector.push(valueIndex); + } const newElement = set(element, selector, value); const newAst = get(newElement, ['ast', 'chain', index]); @@ -380,14 +405,21 @@ export const deleteArgumentAtIndex = createThunk('deleteArgumentAtIndex', ({ dis */ export const addElement = createThunk('addElement', ({ dispatch }, pageId, element) => { const newElement = { ...getDefaultElement(), ...getBareElement(element, true) }; - if (element.width) newElement.position.width = element.width; - if (element.height) newElement.position.height = element.height; + if (element.width) { + newElement.position.width = element.width; + } + if (element.height) { + newElement.position.height = element.height; + } const _addElement = createAction('addElement'); dispatch(_addElement({ 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)); + if (element.filter) { + dispatch(fetchAllRenderables()); + } else { + dispatch(fetchRenderable(newElement)); + } // select the new element dispatch(selectElement(newElement.id)); diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index 1cbe12c09c4b7..27066c17ec372 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -109,7 +109,9 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns // cast shape into element-like object to compare const newProps = shapeToElement(shape); - if (1 / newProps.angle === -Infinity) newProps.angle = 0; // recompose.shallowEqual discerns between 0 and -0 + if (1 / newProps.angle === -Infinity) { + newProps.angle = 0; + } // recompose.shallowEqual discerns between 0 and -0 return shallowEqual(oldProps, newProps) ? null @@ -117,21 +119,26 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns } }) .filter(identity); - if (repositionings.length) setMultiplePositions(repositionings); + if (repositionings.length) { + setMultiplePositions(repositionings); + } }; 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) + 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; + if (g.parent && !idMap[g.parent]) { + g.parent = null; + } }); }; @@ -140,7 +147,9 @@ export const aeroelastic = ({ dispatch, getState }) => { const onChangeCallback = ({ state }) => { const nextScene = state.currentScene; - if (!nextScene.gestureEnd) return; // only update redux on gesture end + if (!nextScene.gestureEnd) { + return; + } // only update redux on gesture end // TODO: check for gestureEnd on element selection // read current data out of redux @@ -158,7 +167,9 @@ export const aeroelastic = ({ dispatch, getState }) => { persistableGroups.forEach(g => { if ( !persistedGroups.find(p => { - if (!p.id) throw new Error('Element has no id'); + if (!p.id) { + throw new Error('Element has no id'); + } return p.id === g.id; }) ) { @@ -196,15 +207,18 @@ export const aeroelastic = ({ dispatch, getState }) => { // set the selected element on the global store, if one element is selected const selectedShape = nextScene.selectedPrimaryShapes[0]; if (nextScene.selectedShapes.length === 1 && !isGroupId(selectedShape)) { - if (selectedShape !== (selectedElement && selectedElement.id)) + if (selectedShape !== (selectedElement && selectedElement.id)) { dispatch(selectElement(selectedShape)); + } } else { // otherwise, clear the selected element state // 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)); + if (!shape || shape.subtype !== 'adHocGroup') { + dispatch(selectElement(null)); + } } } }; @@ -266,7 +280,9 @@ export const aeroelastic = ({ dispatch, getState }) => { let lastPageRemoved = false; if (action.type === removePage.toString()) { const preRemoveState = getState(); - if (getPages(preRemoveState).length <= 1) lastPageRemoved = true; + if (getPages(preRemoveState).length <= 1) { + lastPageRemoved = true; + } aero.removeStore(action.payload); } @@ -287,7 +303,9 @@ export const aeroelastic = ({ dispatch, getState }) => { case duplicatePage.toString(): const newPage = getSelectedPage(getState()); createStore(newPage); - if (action.type === duplicatePage.toString()) dispatch(fetchAllRenderables()); + if (action.type === duplicatePage.toString()) { + dispatch(fetchAllRenderables()); + } populateWithElements(newPage); break; @@ -303,8 +321,11 @@ export const aeroelastic = ({ dispatch, getState }) => { case selectElement.toString(): // without this condition, a mouse release anywhere will trigger it, leading to selection of whatever is // underneath the pointer (maybe nothing) when the mouse is released - if (action.payload) selectShape(prevPage, action.payload); - else unselectShape(prevPage); + if (action.payload) { + selectShape(prevPage, action.payload); + } else { + unselectShape(prevPage); + } break; @@ -320,9 +341,13 @@ export const aeroelastic = ({ dispatch, getState }) => { // TODO: add a better check for elements changing, including their position, ids, etc. const shouldResetState = prevPage !== page || !shallowEqual(prevElements.map(id), elements.map(id)); - if (shouldResetState) populateWithElements(page); + if (shouldResetState) { + populateWithElements(page); + } - if (action.type !== setMultiplePositions.toString()) unselectShape(prevPage); + if (action.type !== setMultiplePositions.toString()) { + unselectShape(prevPage); + } break; } diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js index 31d48264f323a..d9771143d35eb 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -30,7 +30,9 @@ function assignNodeProperties(workpadState, pageId, nodeId, props) { const nodesPath = ['pages', pageIndex, location]; const nodeIndex = get(workpadState, nodesPath, []).findIndex(node => node.id === nodeId); - if (pageIndex === -1 || nodeIndex === -1) return workpadState; + 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 @@ -46,13 +48,21 @@ function moveNodeLayer(workpadState, pageId, nodeId, movement, location) { const from = nodeIndex; const to = (function() { - if (movement < Infinity && movement > -Infinity) return nodeIndex + movement; - if (movement === Infinity) return nodes.length - 1; - if (movement === -Infinity) return 0; + if (movement < Infinity && movement > -Infinity) { + return nodeIndex + movement; + } + if (movement === Infinity) { + return nodes.length - 1; + } + if (movement === -Infinity) { + return 0; + } throw new Error('Invalid element layer movement'); })(); - if (to > nodes.length - 1 || to < 0) return workpadState; + if (to > nodes.length - 1 || to < 0) { + return workpadState; + } // Common const newNodes = nodes.slice(0); @@ -84,22 +94,30 @@ export const elementsReducer = handleActions( }, [actions.addElement]: (workpadState, { payload: { pageId, element } }) => { const pageIndex = getPageIndexById(workpadState, pageId); - if (pageIndex < 0) return workpadState; + if (pageIndex < 0) { + return workpadState; + } return push(workpadState, ['pages', pageIndex, getLocation(element.position.type)], element); }, [actions.duplicateElement]: (workpadState, { payload: { pageId, element } }) => { const pageIndex = getPageIndexById(workpadState, pageId); - if (pageIndex < 0) return workpadState; + if (pageIndex < 0) { + return workpadState; + } return push(workpadState, ['pages', pageIndex, getLocation(element.position.type)], element); }, [actions.rawDuplicateElement]: (workpadState, { payload: { pageId, element } }) => { const pageIndex = getPageIndexById(workpadState, pageId); - if (pageIndex < 0) return workpadState; + if (pageIndex < 0) { + return workpadState; + } return push(workpadState, ['pages', pageIndex, getLocation(element.position.type)], element); }, [actions.removeElements]: (workpadState, { payload: { pageId, elementIds } }) => { const pageIndex = getPageIndexById(workpadState, pageId); - if (pageIndex < 0) return workpadState; + if (pageIndex < 0) { + return workpadState; + } const nodeIndices = elementIds .map(nodeId => { diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.js b/x-pack/plugins/canvas/public/state/selectors/workpad.js index bb7429460a092..026f2f59019f3 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.js +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.js @@ -105,7 +105,9 @@ export function getElements(state, pageId, withAst = true) { // 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'])); + if (!withAst) { + return elements.map(el => omit(el, ['ast'])); + } return elements.map(appendAst); } @@ -113,12 +115,16 @@ export function getElements(state, pageId, withAst = true) { // todo unify or DRY up with `getElements` export function getNodes(state, pageId, withAst = true) { const id = pageId || getSelectedPage(state); - if (!id) return []; + if (!id) { + return []; + } const page = getPageById(state, id); const elements = get(page, 'elements').concat(get(page, 'groups') || []); - if (!elements) return []; + if (!elements) { + return []; + } // explicitly strip the ast, basically a fix for corrupted workpads // due to https://github.com/elastic/kibana-canvas/issues/260 @@ -141,7 +147,9 @@ export function getElementById(state, id, pageId) { 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); + if (group) { + return appendAst(group); + } } export function getResolvedArgs(state, elementId, path) { From 973e71218dfa0c133096eb694f55321cb3cef8f8 Mon Sep 17 00:00:00 2001 From: monfera Date: Tue, 18 Dec 2018 13:30:59 +0100 Subject: [PATCH 17/23] schemaVersion bump --- x-pack/plugins/canvas/public/state/initial_state.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/canvas/public/state/initial_state.js b/x-pack/plugins/canvas/public/state/initial_state.js index 1dd9d59609786..70b1a761d7af1 100644 --- a/x-pack/plugins/canvas/public/state/initial_state.js +++ b/x-pack/plugins/canvas/public/state/initial_state.js @@ -26,7 +26,7 @@ export const getInitialState = path => { // See the resolved_args reducer for more information. }, persistent: { - schemaVersion: 1, + schemaVersion: 2, workpad: getDefaultWorkpad(), }, }; From 23b7bb59846467e22080d1e97c58b7e21f8eea84 Mon Sep 17 00:00:00 2001 From: monfera Date: Tue, 18 Dec 2018 13:44:21 +0100 Subject: [PATCH 18/23] copy/paste: restore selection and 10px offset of newly pasted element --- .../public/components/workpad_page/index.js | 17 ++++++++++------- .../canvas/public/lib/clone_subgraphs.js | 1 + .../canvas/public/state/actions/elements.js | 14 +++++++++----- .../canvas/public/state/initial_state.js | 2 +- 4 files changed, 21 insertions(+), 13 deletions(-) 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 1642ebf41b34f..0b4ec038adee3 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js @@ -27,8 +27,8 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = dispatch => { return { - rawDuplicateElement: pageId => selectedElement => - dispatch(rawDuplicateElement(selectedElement, pageId)), + rawDuplicateElement: pageId => (selectedElement, root) => + dispatch(rawDuplicateElement(selectedElement, pageId, root)), removeElements: pageId => elementIds => dispatch(removeElements(elementIds, pageId)), }; }; @@ -127,22 +127,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()); - const clonedElements = elements && cloneSubgraphs(elements); + 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 => rawDuplicateElement(page.id)(element)); + clonedElements.map((element, index) => + rawDuplicateElement(page.id)(element, indices.indexOf(index) >= 0) + ); } }, }; diff --git a/x-pack/plugins/canvas/public/lib/clone_subgraphs.js b/x-pack/plugins/canvas/public/lib/clone_subgraphs.js index c9e5ad6967592..db188099e607d 100644 --- a/x-pack/plugins/canvas/public/lib/clone_subgraphs.js +++ b/x-pack/plugins/canvas/public/lib/clone_subgraphs.js @@ -12,6 +12,7 @@ export const cloneSubgraphs = nodes => { // 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.entries(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], diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index 8d623c41b4edf..0fcfd2035e91e 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -216,11 +216,13 @@ export const duplicateElement = createThunk( export const rawDuplicateElement = createThunk( 'rawDuplicateElement', - ({ dispatch, type }, element, pageId) => { + ({ dispatch, type }, element, pageId, root) => { const newElement = cloneDeep(element); - // move the element so users can see that it was added - //newElement.position.top = newElement.position.top + 10; - //newElement.position.left = newElement.position.left + 10; + // move the root element so users can see that it was added + if (root) { + newElement.position.top = newElement.position.top + 10; + newElement.position.left = newElement.position.left + 10; + } const _rawDuplicateElement = createAction(type); dispatch(_rawDuplicateElement({ pageId, element: newElement })); @@ -232,7 +234,9 @@ export const rawDuplicateElement = createThunk( } // select the new element - //dispatch(selectElement(newElement.id)); + if (root) { + window.setTimeout(() => dispatch(selectElement(newElement.id))); + } } ); diff --git a/x-pack/plugins/canvas/public/state/initial_state.js b/x-pack/plugins/canvas/public/state/initial_state.js index 70b1a761d7af1..b6e54af9ecbb6 100644 --- a/x-pack/plugins/canvas/public/state/initial_state.js +++ b/x-pack/plugins/canvas/public/state/initial_state.js @@ -22,7 +22,7 @@ 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: { From a1ddd79e5926ae5d5c06a22b729eb1ac331ac6ca Mon Sep 17 00:00:00 2001 From: monfera Date: Tue, 18 Dec 2018 17:26:47 +0100 Subject: [PATCH 19/23] - fix regression with ad hoc groups - fix copy/paste offsetting --- .../canvas/public/components/workpad_page/index.js | 12 +++++++++++- .../plugins/canvas/public/state/actions/elements.js | 6 ++---- 2 files changed, 13 insertions(+), 5 deletions(-) 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 0b4ec038adee3..c48326896a000 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js @@ -98,7 +98,17 @@ export const WorkpadPage = compose( ), ]; }; - const selectedElementIds = flatten(selectedPrimaryShapes.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; diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index 0fcfd2035e91e..960a0ddc6db40 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -219,10 +219,8 @@ export const rawDuplicateElement = createThunk( ({ dispatch, type }, element, pageId, root) => { const newElement = cloneDeep(element); // move the root element so users can see that it was added - if (root) { - newElement.position.top = newElement.position.top + 10; - newElement.position.left = newElement.position.left + 10; - } + newElement.position.top = newElement.position.top + 10; + newElement.position.left = newElement.position.left + 10; const _rawDuplicateElement = createAction(type); dispatch(_rawDuplicateElement({ pageId, element: newElement })); From d0a77c87c5471d8d7e9da8c9635a822e2fa51b2a Mon Sep 17 00:00:00 2001 From: monfera Date: Tue, 18 Dec 2018 18:53:31 +0100 Subject: [PATCH 20/23] PR feedback: don't persist node `type` and group `expression` --- .../canvas/public/state/reducers/elements.js | 38 +++++++++++++++++-- .../canvas/public/state/selectors/workpad.js | 13 ++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js index d9771143d35eb..90b1af89b3080 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -71,6 +71,22 @@ function moveNodeLayer(workpadState, pageId, nodeId, movement, location) { 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. @@ -85,7 +101,9 @@ export const elementsReducer = handleActions( [actions.setMultiplePositions]: (workpadState, { payload }) => payload.repositionedElements.reduce( (previousWorkpadState, { position, pageId, elementId }) => - assignNodeProperties(previousWorkpadState, pageId, elementId, { position }), + assignNodeProperties(previousWorkpadState, pageId, elementId, { + position: trimPosition(position), + }), workpadState ), [actions.elementLayer]: (workpadState, { payload: { pageId, elementId, movement } }) => { @@ -97,21 +115,33 @@ export const elementsReducer = handleActions( if (pageIndex < 0) { return workpadState; } - return push(workpadState, ['pages', pageIndex, getLocation(element.position.type)], 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, getLocation(element.position.type)], 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)], element); + return push( + workpadState, + ['pages', pageIndex, getLocation(element.position.type)], + trimElement(element) + ); }, [actions.removeElements]: (workpadState, { payload: { pageId, elementIds } }) => { const pageIndex = getPageIndexById(workpadState, pageId); diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.js b/x-pack/plugins/canvas/public/state/selectors/workpad.js index 026f2f59019f3..235dab6375107 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.js +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.js @@ -112,6 +112,17 @@ export function getElements(state, pageId, withAst = true) { 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); @@ -120,7 +131,7 @@ export function getNodes(state, pageId, withAst = true) { } const page = getPageById(state, id); - const elements = get(page, 'elements').concat(get(page, 'groups') || []); + const elements = getNodesOfPage(page); if (!elements) { return []; From 20d575db24dbf92cf05a8b685d245ab4d53e74ef Mon Sep 17 00:00:00 2001 From: joe fleming Date: Tue, 18 Dec 2018 14:02:58 -0700 Subject: [PATCH 21/23] chore: remove commented out code --- .../plugins/canvas/public/state/middleware/aeroelastic.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index 27066c17ec372..3d66a8f4d85b8 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -56,9 +56,10 @@ const elementToShape = (element, i) => { const z = i; // painter's algo: latest item goes to top // multiplying the angle with -1 as `transform: matrix3d` uses a left-handed coordinate system const angleRadians = (-position.angle / 180) * Math.PI; - const transformMatrix = - //position.localTransformMatrix || - aero.matrix.multiply(aero.matrix.translate(cx, cy, z), aero.matrix.rotateZ(angleRadians)); + const transformMatrix = aero.matrix.multiply( + 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 { From 6ee1bebf7c84f9fb057113e9e372ed350a777900 Mon Sep 17 00:00:00 2001 From: joe fleming Date: Tue, 18 Dec 2018 14:12:34 -0700 Subject: [PATCH 22/23] chore: switch Object.entries to Object.keys --- x-pack/plugins/canvas/public/lib/clone_subgraphs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/canvas/public/lib/clone_subgraphs.js b/x-pack/plugins/canvas/public/lib/clone_subgraphs.js index db188099e607d..101492657b47b 100644 --- a/x-pack/plugins/canvas/public/lib/clone_subgraphs.js +++ b/x-pack/plugins/canvas/public/lib/clone_subgraphs.js @@ -11,7 +11,7 @@ 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.entries(idMap).forEach(([key]) => (idMap[key] = getId(key.split('-')[0]))); // new group names to which we can map + 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, From ee0dad3a22e9c8b0651973bc6378510d9db28ec3 Mon Sep 17 00:00:00 2001 From: joe fleming Date: Tue, 18 Dec 2018 14:41:33 -0700 Subject: [PATCH 23/23] fix: handle undefined value this might be caused by a race condition or something. this fix is probably just covering up some other bug :( --- x-pack/plugins/canvas/public/state/reducers/elements.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js index 90b1af89b3080..3aecaeb7c2d69 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -12,7 +12,8 @@ import * as actions from '../actions/elements'; const getLocation = type => (type === 'group' ? 'groups' : 'elements'); const getLocationFromIds = (workpadState, pageId, nodeId) => { - const groups = workpadState.pages.find(p => p.id === pageId).groups || []; + const page = workpadState.pages.find(p => p.id === pageId); + const groups = page == null ? [] : page.groups || []; return groups.find(e => e.id === nodeId) ? 'groups' : 'elements'; };