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 2fbcecc94d8dd..c95395d91734c 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 @@ -30,31 +30,35 @@ const setupHandler = (commit, target) => { const canvasPage = ancestorElement(target, 'canvasPage'); if (!canvasPage) return; const canvasOrigin = canvasPage.getBoundingClientRect(); - window.onmousemove = ({ clientX, clientY, altKey, metaKey }) => { + window.onmousemove = ({ clientX, clientY, altKey, metaKey, shiftKey }) => { const { x, y } = localMousePosition(canvasOrigin, clientX, clientY); - commit('cursorPosition', { x, y, altKey, metaKey }); + commit('cursorPosition', { x, y, altKey, metaKey, shiftKey }); }; window.onmouseup = e => { e.stopPropagation(); - const { clientX, clientY, altKey, metaKey } = e; + const { clientX, clientY, altKey, metaKey, shiftKey } = e; const { x, y } = localMousePosition(canvasOrigin, clientX, clientY); - commit('mouseEvent', { event: 'mouseUp', x, y, altKey, metaKey }); + commit('mouseEvent', { event: 'mouseUp', x, y, altKey, metaKey, shiftKey }); resetHandler(); }; }; -const handleMouseMove = (commit, { target, clientX, clientY, altKey, metaKey }, isEditable) => { +const handleMouseMove = ( + commit, + { target, clientX, clientY, altKey, metaKey, shiftKey }, + isEditable +) => { // mouse move must be handled even before an initial click if (!window.onmousemove && isEditable) { const { x, y } = localMousePosition(target, clientX, clientY); setupHandler(commit, target); - commit('cursorPosition', { x, y, altKey, metaKey }); + commit('cursorPosition', { x, y, altKey, metaKey, shiftKey }); } }; const handleMouseDown = (commit, e, isEditable) => { e.stopPropagation(); - const { target, clientX, clientY, button, altKey, metaKey } = e; + const { target, clientX, clientY, button, altKey, metaKey, shiftKey } = e; if (button !== 0 || !isEditable) { resetHandler(); return; // left-click and edit mode only @@ -63,7 +67,7 @@ const handleMouseDown = (commit, e, isEditable) => { if (!ancestor) return; const { x, y } = localMousePosition(ancestor, clientX, clientY); setupHandler(commit, ancestor); - commit('mouseEvent', { event: 'mouseDown', x, y, altKey, metaKey }); + commit('mouseEvent', { event: 'mouseDown', x, y, altKey, metaKey, shiftKey }); }; const keyCode = key => (key === 'Meta' ? 'MetaLeft' : 'Key' + key.toUpperCase()); 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 b0f50a438b179..bf3e220ddfbbb 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { compose, withState, withProps } from 'recompose'; import { aeroelastic } from '../../lib/aeroelastic_kibana'; -import { removeElement } from '../../state/actions/elements'; +import { removeElements } from '../../state/actions/elements'; import { getFullscreen, getEditing } from '../../state/selectors/app'; import { getElements } from '../../state/selectors/workpad'; import { withEventHandlers } from './event_handlers'; @@ -23,7 +23,7 @@ const mapStateToProps = (state, ownProps) => { const mapDispatchToProps = dispatch => { return { - removeElement: pageId => elementId => dispatch(removeElement(elementId, pageId)), + removeElements: pageId => elementIds => dispatch(removeElements(elementIds, pageId)), }; }; @@ -31,7 +31,9 @@ const getRootElementId = (lookup, id) => { if (!lookup.has(id)) return null; const element = lookup.get(id); - return element.parent ? getRootElementId(lookup, element.parent) : element.id; + return element.parent && element.parent.subtype !== 'adHocGroup' + ? getRootElementId(lookup, element.parent) + : element.id; }; export const WorkpadPage = compose( @@ -60,10 +62,9 @@ export const WorkpadPage = compose( }; }), withState('updateCount', 'setUpdateCount', 0), // TODO: remove this, see setUpdateCount below - withProps(({ updateCount, setUpdateCount, page, elements: pageElements, removeElement }) => { - const { shapes, selectedShapes = [], cursor } = aeroelastic.getStore(page.id).currentScene; + withProps(({ updateCount, setUpdateCount, page, elements: pageElements, removeElements }) => { + const { shapes, selectedLeafShapes = [], cursor } = aeroelastic.getStore(page.id).currentScene; const elementLookup = new Map(pageElements.map(element => [element.id, element])); - const shapeLookup = new Map(shapes.map(shape => [shape.id, shape])); const elements = shapes.map( shape => elementLookup.has(shape.id) @@ -71,8 +72,7 @@ export const WorkpadPage = compose( { ...shape, filter: elementLookup.get(shape.id).filter } : shape ); - const selectedElements = selectedShapes.map(id => getRootElementId(shapeLookup, id)); - + const selectedElements = selectedLeafShapes; return { elements, cursor, @@ -83,7 +83,7 @@ export const WorkpadPage = compose( }, remove: () => { // currently, handle the removal of one element, exploiting multiselect subsequently - if (selectedElements[0]) removeElement(page.id)(selectedElements[0]); + if (selectedElements.length) removeElements(page.id)(selectedElements); }, }; }), // Updates states; needs to have both local and global 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 2aa28492af522..b94c5b8c2a45f 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 @@ -79,7 +79,7 @@ export const WorkpadPage = ({ default: return []; } - } else { + } else if (element.subtype !== 'adHocGroup') { return ; } }) diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/config.js b/x-pack/plugins/canvas/public/lib/aeroelastic/config.js index fdb00987e80f9..a9fc4072683fa 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/config.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/config.js @@ -8,12 +8,16 @@ * Mock config */ +const adHocGroupName = 'adHocGroup'; const alignmentGuideName = 'alignmentGuide'; const atopZ = 1000; const depthSelect = true; const devColor = 'magenta'; +const groupName = 'group'; +const groupResize = false; const guideDistance = 3; const hoverAnnotationName = 'hoverAnnotation'; +const intraGroupManipulation = false; const resizeAnnotationOffset = 0; const resizeAnnotationOffsetZ = 0.1; // causes resize markers to be slightly above the shape plane const resizeAnnotationSize = 10; @@ -25,17 +29,21 @@ const rotationHandleSize = 14; const resizeHandleName = 'resizeHandle'; const rotateSnapInPixels = 10; const shortcuts = false; -const singleSelect = true; +const singleSelect = false; const snapConstraint = true; const minimumElementSize = 0; // guideDistance / 2 + 1; module.exports = { + adHocGroupName, alignmentGuideName, atopZ, depthSelect, devColor, + groupName, + groupResize, guideDistance, hoverAnnotationName, + intraGroupManipulation, minimumElementSize, resizeAnnotationOffset, resizeAnnotationOffsetZ, diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js b/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js index a3c5c06d0b23f..4f8967503337a 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/functional.js @@ -67,22 +67,18 @@ const disjunctiveUnion = (keyFun, set1, set2) => */ const mean = (a, b) => (a + b) / 2; -/** - * unnest - * - * @param {*[][]} vectorOfVectors - * @returns {*[]} - */ -const unnest = vectorOfVectors => [].concat.apply([], vectorOfVectors); - const shallowEqual = (a, b) => { if (a === b) return true; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; - return true; }; +const not = fun => (...args) => !fun(...args); + +const removeDuplicates = (idFun, a) => + a.filter((d, i) => a.findIndex(s => idFun(s) === idFun(d)) === i); + module.exports = { disjunctiveUnion, flatten, @@ -90,6 +86,7 @@ module.exports = { log, map, mean, + not, + removeDuplicates, shallowEqual, - unnest, }; diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js index 3ff3f5a0a7681..825fabb5d0350 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js @@ -20,26 +20,25 @@ const primaryUpdate = state => state.primaryUpdate; // dispatch the various types of actions const rawCursorPosition = select( - action => (action && action.type === 'cursorPosition' ? action.payload : null) + action => (action.type === 'cursorPosition' ? action.payload : null) )(primaryUpdate); -const mouseButtonEvent = select( - action => (action && action.type === 'mouseEvent' ? action.payload : null) -)(primaryUpdate); +const mouseButtonEvent = select(action => (action.type === 'mouseEvent' ? action.payload : null))( + primaryUpdate +); -const keyboardEvent = select( - action => (action && action.type === 'keyboardEvent' ? action.payload : null) -)(primaryUpdate); +const keyboardEvent = select(action => (action.type === 'keyboardEvent' ? action.payload : null))( + primaryUpdate +); const keyInfoFromMouseEvents = select( - action => - (action && action.type === 'cursorPosition') || action.type === 'mouseEvent' - ? { altKey: action.payload.altKey, metaKey: action.payload.metaKey } - : null + ({ type, payload: { altKey, metaKey, shiftKey } }) => + type === 'cursorPosition' || type === 'mouseEvent' ? { altKey, metaKey, shiftKey } : null )(primaryUpdate); const altTest = key => key.slice(0, 3).toLowerCase() === 'alt' || key === 'KeyALT'; const metaTest = key => key.slice(0, 4).toLowerCase() === 'meta'; +const shiftTest = key => key === 'KeySHIFT' || key.slice(0, 5) === 'Shift'; const deadKey1 = 'KeyDEAD'; const deadKey2 = 'Key†'; @@ -65,6 +64,10 @@ const updateKeyLookupFromMouseEvent = (lookup, keyInfoFromMouseEvent) => { if (value) lookup.alt = true; else delete lookup.alt; } + if (shiftTest(key)) { + if (value) lookup.shift = true; + else delete lookup.shift; + } }); return lookup; }; @@ -83,6 +86,8 @@ const pressedKeys = selectReduce((prevLookup, next, keyInfoFromMouseEvent) => { if (metaTest(next.code)) code = 'meta'; + if (shiftTest(next.code)) code = 'shift'; + if (next.event === 'keyDown') { return { ...lookup, [code]: true }; } else { @@ -96,6 +101,7 @@ const keyUp = select(keys => Object.keys(keys).length === 0)(pressedKeys); const metaHeld = select(lookup => Boolean(lookup.meta))(pressedKeys); const optionHeld = select(lookup => Boolean(lookup.alt))(pressedKeys); +const shiftHeld = select(lookup => Boolean(lookup.shift))(pressedKeys); const cursorPosition = selectReduce((previous, position) => position || previous, { x: 0, y: 0 })( rawCursorPosition @@ -198,4 +204,5 @@ module.exports = { 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 c0e836d5de229..06cef83699154 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -const { select, selectReduce } = require('./state'); +const { select, makeUid } = require('./state'); const { dragging, @@ -17,6 +17,7 @@ const { mouseIsDown, optionHeld, pressedKeys, + shiftHeld, } = require('./gestures'); const { shapesAt, landmarkPoint } = require('./geometry'); @@ -26,7 +27,15 @@ const matrix2d = require('./matrix2d'); const config = require('./config'); -const { identity, disjunctiveUnion, mean, shallowEqual, unnest } = require('./functional'); +const { + disjunctiveUnion, + identity, + flatten, + mean, + not, + removeDuplicates, + shallowEqual, +} = require('./functional'); /** * Selectors directly from a state object @@ -55,29 +64,20 @@ const draggingShape = ({ draggedShape, shapes }, hoveredShape, down, mouseDowned const shapes = select(scene => scene.shapes)(scene); const hoveredShapes = select((shapes, cursorPosition) => - shapesAt(shapes.filter(s => s.type !== 'annotation' || s.interactive), 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') + ), + cursorPosition + ) )(shapes, cursorPosition); -const hoveredShape = selectReduce( - (prev, hoveredShapes) => { - if (hoveredShapes.length) { - const depthIndex = 0; // (prev.depthIndex + 1) % hoveredShapes.length; - return { - shape: hoveredShapes[depthIndex], - depthIndex, - }; - } else { - return { - shape: null, - depthIndex: 0, - }; - } - }, - { - shape: null, - depthIndex: 0, - }, - tuple => tuple.shape +const depthIndex = 0; +const hoveredShape = select( + hoveredShapes => (hoveredShapes.length ? hoveredShapes[depthIndex] : null) )(hoveredShapes); const draggedShape = select(draggingShape)(scene, hoveredShape, mouseIsDown, mouseDowned); @@ -148,6 +148,8 @@ const keyTransformGesture = select( const alterSnapGesture = select(metaHeld => (metaHeld ? ['relax'] : []))(metaHeld); +const multiselectModifier = shiftHeld; // todo abstract out keybindings + const initialTransformTuple = { deltaX: 0, deltaY: 0, @@ -155,29 +157,32 @@ const initialTransformTuple = { cumulativeTransform: null, }; -const mouseTransformGesture = selectReduce( - (prev, dragging, { x0, y0, x1, y1 }) => { - if (dragging) { - const deltaX = x1 - x0; - const deltaY = y1 - y0; - const transform = matrix.translate(deltaX - prev.deltaX, deltaY - prev.deltaY, 0); - const cumulativeTransform = matrix.translate(deltaX, deltaY, 0); - return { - deltaX, - deltaY, - transform, - cumulativeTransform, - }; - } else { - return initialTransformTuple; - } - }, - initialTransformTuple, - tuple => - [tuple] - .filter(tuple => tuple.transform) - .map(({ transform, cumulativeTransform }) => ({ transform, cumulativeTransform })) -)(dragging, dragVector); +const mouseTransformGesturePrev = select( + ({ mouseTransformState }) => mouseTransformState || initialTransformTuple +)(scene); + +const mouseTransformState = select((prev, dragging, { x0, y0, x1, y1 }) => { + if (dragging) { + const deltaX = x1 - x0; + const deltaY = y1 - y0; + const transform = matrix.translate(deltaX - prev.deltaX, deltaY - prev.deltaY, 0); + const cumulativeTransform = matrix.translate(deltaX, deltaY, 0); + return { + deltaX, + deltaY, + transform, + cumulativeTransform, + }; + } else { + return initialTransformTuple; + } +})(mouseTransformGesturePrev, dragging, dragVector); + +const mouseTransformGesture = select(tuple => + [tuple] + .filter(tuple => tuple.transform) + .map(({ transform, cumulativeTransform }) => ({ transform, cumulativeTransform })) +)(mouseTransformState); const transformGestures = select((keyTransformGesture, mouseTransformGesture) => keyTransformGesture.concat(mouseTransformGesture) @@ -193,66 +198,105 @@ const directSelect = select( action => (action && action.type === 'shapeSelect' ? action.payload : null) )(primaryUpdate); -const initialSelectedShapeState = { - shapes: [], - uid: null, - depthIndex: 0, - down: false, - metaHeld: false, -}; +const selectedShapeObjects = select(scene => scene.selectedShapeObjects || [])(scene); -const singleSelect = (prev, hoveredShapes, metaHeld, down, uid) => { +const singleSelect = (prev, hoveredShapes, metaHeld, uid) => { // cycle from top ie. from zero after the cursor position changed ie. !sameLocation - const metaChanged = metaHeld !== prev.metaHeld; + const down = true; // this function won't be called otherwise const depthIndex = config.depthSelect && metaHeld ? (prev.depthIndex + (down && !prev.down ? 1 : 0)) % hoveredShapes.length : 0; - return hoveredShapes.length - ? { - shapes: [hoveredShapes[depthIndex]], - uid, - depthIndex, - down, - metaHeld, - metaChanged: depthIndex === prev.depthIndex ? metaChanged : false, - } - : { ...initialSelectedShapeState, uid, down, metaHeld, metaChanged }; + return { + shapes: hoveredShapes.length ? [hoveredShapes[depthIndex]] : [], + uid, + depthIndex: hoveredShapes.length ? depthIndex : 0, + down, + }; }; -const multiSelect = (prev, hoveredShapes, metaHeld, down, uid) => { +const multiSelect = (prev, hoveredShapes, metaHeld, uid, selectedShapeObjects) => { + const shapes = + hoveredShapes.length > 0 + ? disjunctiveUnion(shape => shape.id, selectedShapeObjects, hoveredShapes.slice(0, 1)) // ie. depthIndex of 0, if any + : []; return { - shapes: hoveredShapes.length - ? disjunctiveUnion(shape => shape.id, prev.shapes, hoveredShapes) - : [], + shapes, uid, + depthIndex: 0, + down: false, }; }; -const selectedShapes = selectReduce( - (prev, hoveredShapes, { down, uid }, metaHeld, directSelect, allShapes) => { +const selectedShapesPrev = select( + scene => + scene.selectionState || { + shapes: [], + uid: null, + depthIndex: 0, + down: false, + } +)(scene); + +const reselectShapes = (allShapes, shapes) => + shapes.map(id => allShapes.find(shape => shape.id === id)); + +const contentShape = allShapes => shape => + shape.type === 'annotation' + ? contentShape(allShapes)(allShapes.find(s => s.id === shape.parent)) + : shape; + +const contentShapes = (allShapes, shapes) => shapes.map(contentShape(allShapes)); + +const selectionState = select( + ( + prev, + selectedShapeObjects, + hoveredShapes, + { down, uid }, + metaHeld, + multiselect, + directSelect, + allShapes + ) => { + const uidUnchanged = uid === prev.uid; const mouseButtonUp = !down; - if ( + const updateFromDirectSelect = directSelect && directSelect.shapes && - !shallowEqual(directSelect.shapes, prev.shapes.map(shape => shape.id)) - ) { - const { shapes, uid } = directSelect; - return { ...prev, shapes: shapes.map(id => allShapes.find(shape => shape.id === id)), uid }; + !shallowEqual(directSelect.shapes, selectedShapeObjects.map(shape => shape.id)); + if (updateFromDirectSelect) { + return { + shapes: reselectShapes(allShapes, directSelect.shapes), + uid: directSelect.uid, + depthIndex: prev.depthIndex, + down: prev.down, + }; } - if (uid === prev.uid && !directSelect) return prev; - if (mouseButtonUp) return { ...prev, down, uid, metaHeld }; // take action on mouse down only, ie. bail otherwise - const selectFunction = config.singleSelect ? singleSelect : multiSelect; - const result = selectFunction(prev, hoveredShapes, metaHeld, down, uid); - return result; - }, - initialSelectedShapeState, - d => d.shapes -)(hoveredShapes, mouseButton, metaHeld, directSelect, shapes); + 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); + } +)( + selectedShapesPrev, + selectedShapeObjects, + hoveredShapes, + mouseButton, + metaHeld, + multiselectModifier, + directSelect, + shapes +); + +const selectedShapes = select(selectionTuple => { + return selectionTuple.shapes; +})(selectionState); const selectedShapeIds = select(shapes => shapes.map(shape => shape.id))(selectedShapes); -const primaryShape = shape => shape.parent || shape.id; +const primaryShape = shape => shape.parent || shape.id; // fixme unify with contentShape const selectedPrimaryShapeIds = select(shapes => shapes.map(primaryShape))(selectedShapes); @@ -405,7 +449,7 @@ const rotationAnnotationManipulation = ( shape.type === 'annotation' && shape.subtype === config.rotationHandleName && shape.parent ); const shapes = shapeIds.map(id => id && allShapes.find(shape => shape.id === id)); - const tuples = unnest( + const tuples = flatten( shapes.map((shape, i) => directTransforms.map(transform => ({ transform, @@ -425,7 +469,7 @@ const resizeAnnotationManipulation = (transformGestures, directShapes, allShapes shape.type === 'annotation' && shape.subtype === config.resizeHandleName && shape.parent ); const shapes = shapeIds.map(id => id && allShapes.find(shape => shape.id === id)); - const tuples = unnest( + const tuples = flatten( shapes.map((shape, i) => transformGestures.map(gesture => ({ gesture, shape, directShape: directShapes[i] })) ) @@ -471,7 +515,7 @@ const fromScreen = currentTransform => transform => { // "cumulative" is the effect of the ongoing interaction; "baseline" is sans "cumulative", plain "localTransformMatrix" // is the composition of the baseline (previously absorbed transforms) and the cumulative (ie. ongoing interaction) const shapeApplyLocalTransforms = intents => shape => { - const transformIntents = unnest( + const transformIntents = flatten( intents .map( intent => @@ -482,7 +526,7 @@ const shapeApplyLocalTransforms = intents => shape => { ) .filter(identity) ); - const sizeIntents = unnest( + const sizeIntents = flatten( intents .map( intent => @@ -493,7 +537,7 @@ const shapeApplyLocalTransforms = intents => shape => { ) .filter(identity) ); - const cumulativeTransformIntents = unnest( + const cumulativeTransformIntents = flatten( intents .map( intent => @@ -504,7 +548,7 @@ const shapeApplyLocalTransforms = intents => shape => { ) .filter(identity) ); - const cumulativeSizeIntents = unnest( + const cumulativeSizeIntents = flatten( intents .map( intent => @@ -573,7 +617,7 @@ 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 shapeCascadeTransforms = shapes => shape => { +const cascadeTransforms = (shapes, shape) => { const upstreams = getUpstreams(shapes, shape); const upstreamTransforms = upstreams.map(shape => { return shape.snapDeltaMatrix @@ -581,16 +625,19 @@ const shapeCascadeTransforms = shapes => shape => { : shape.localTransformMatrix; }); const cascadedTransforms = matrix.reduceTransforms(upstreamTransforms); + return cascadedTransforms; +}; +const shapeCascadeProperties = shapes => shape => { return { ...shape, - transformMatrix: cascadedTransforms, + transformMatrix: cascadeTransforms(shapes, shape), width: 2 * snappedA(shape), height: 2 * snappedB(shape), }; }; -const cascadeTransforms = shapes => shapes.map(shapeCascadeTransforms(shapes)); +const cascadeProperties = shapes => shapes.map(shapeCascadeProperties(shapes)); const nextShapes = select((preexistingShapes, restated) => { if (restated && restated.newShapes) return restated.newShapes; @@ -614,8 +661,9 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { // key points of the dragged shape bounding box for (let j = 0; j < shapes.length; j++) { const s = shapes[j]; - if (d.id === s.id) continue; + if (d.id === s.id) continue; // don't self-constrain; todo in the future, self-constrain to the original location if (s.type === 'annotation') continue; // fixme avoid this by not letting annotations get in here + if (s.parent) continue; // for now, don't snap to grouped elements fixme could snap, but make sure transform is gloabl // key points of the stationery shape for (let k = -1; k < 2; k++) { for (let l = -1; l < 2; l++) { @@ -685,13 +733,6 @@ const alignmentGuides = (shapes, guidedShapes, draggedShape) => { return Object.values(result); }; -/* upcoming functionality -const draggedShapes = select( - (shapes, selectedShapeIds, mouseIsDown) => - mouseIsDown ? shapes.filter(shape => selectedShapeIds.indexOf(shape.id) !== -1) : [] -)(nextShapes, selectedShapeIds, mouseIsDown); -*/ - const isHorizontal = constraint => constraint.dimension === 'horizontal'; const isVertical = constraint => constraint.dimension === 'vertical'; @@ -825,6 +866,19 @@ const resizeEdgeAnnotations = (parent, a, b) => ([[x0, y0], [x1, y1]]) => { }; }; +const connectorVertices = [ + [[-1, -1], [0, -1]], + [[0, -1], [1, -1]], + [[1, -1], [1, 0]], + [[1, 0], [1, 1]], + [[1, 1], [0, 1]], + [[0, 1], [-1, 1]], + [[-1, 1], [-1, 0]], + [[-1, 0], [-1, -1]], +]; + +const cornerVertices = [[-1, -1], [1, -1], [-1, 1], [1, 1]]; + function resizeAnnotation(shapes, selectedShapes, shape) { const foundShape = shapes.find(s => shape.id === s.id); const properShape = @@ -837,7 +891,10 @@ function resizeAnnotation(shapes, selectedShapes, shape) { if (foundShape.subtype === config.resizeHandleName) { // preserve any interactive annotation when handling const result = foundShape.interactive - ? resizeAnnotationsFunction(shapes, [shapes.find(s => shape.parent === s.id)]) + ? resizeAnnotationsFunction({ + shapes, + selectedShapes: [shapes.find(s => shape.parent === s.id)], + }) : []; return result; } @@ -845,34 +902,29 @@ function resizeAnnotation(shapes, selectedShapes, shape) { return resizeAnnotation(shapes, selectedShapes, shapes.find(s => foundShape.parent === s.id)); // fixme left active: snap wobble. right active: opposite side wobble. - const a = snappedA(properShape); // properShape.width / 2;; - const b = snappedB(properShape); // properShape.height / 2; - const resizePoints = [ - [-1, -1, 315], - [1, -1, 45], - [1, 1, 135], - [-1, 1, 225], // corners - [0, -1, 0], - [1, 0, 90], - [0, 1, 180], - [-1, 0, 270], // edge midpoints - ].map(resizePointAnnotations(shape.id, a, b)); - const connectors = [ - [[-1, -1], [0, -1]], - [[0, -1], [1, -1]], - [[1, -1], [1, 0]], - [[1, 0], [1, 1]], - [[1, 1], [0, 1]], - [[0, 1], [-1, 1]], - [[-1, 1], [-1, 0]], - [[-1, 0], [-1, -1]], - ].map(resizeEdgeAnnotations(shape.id, a, b)); + const a = snappedA(properShape); + const b = snappedB(properShape); + const resizeVertices = + config.groupResize || properShape.type !== 'group' // todo remove the limitation of no group resize + ? [ + [-1, -1, 315], + [1, -1, 45], + [1, 1, 135], + [-1, 1, 225], // corners + [0, -1, 0], + [1, 0, 90], + [0, 1, 180], + [-1, 0, 270], // edge midpoints + ] + : []; + const resizePoints = resizeVertices.map(resizePointAnnotations(shape.id, a, b)); + const connectors = connectorVertices.map(resizeEdgeAnnotations(shape.id, a, b)); return [...resizePoints, ...connectors]; } -function resizeAnnotationsFunction(shapes, selectedShapes) { +function resizeAnnotationsFunction({ shapes, selectedShapes }) { const shapesToAnnotate = selectedShapes; - return unnest( + return flatten( shapesToAnnotate .map(shape => { return resizeAnnotation(shapes, selectedShapes, shape); @@ -886,25 +938,29 @@ function resizeAnnotationsFunction(shapes, selectedShapes) { // stark contrast with the concept of StickyLines - whose central idea is that constraints remain applied until explicitly // broken. const crystallizeConstraint = shape => { - return { - ...shape, - snapDeltaMatrix: null, - snapResizeVector: null, - localTransformMatrix: shape.snapDeltaMatrix - ? matrix.multiply(shape.localTransformMatrix, shape.snapDeltaMatrix) - : shape.localTransformMatrix, - a: snappedA(shape), - b: snappedB(shape), - }; + const result = { ...shape }; + if (shape.snapDeltaMatrix) { + result.localTransformMatrix = matrix.multiply( + shape.localTransformMatrix, + shape.snapDeltaMatrix + ); + result.snapDeltaMatrix = null; + } + if (shape.snapResizeVector) { + result.a = snappedA(shape); + result.b = snappedB(shape); + result.snapResizeVector = null; + } + return result; }; const translateShapeSnap = (horizontalConstraint, verticalConstraint, draggedElement) => shape => { - const constrainedShape = draggedElement && shape.id === draggedElement.id; const constrainedX = horizontalConstraint && horizontalConstraint.constrained === shape.id; const constrainedY = verticalConstraint && verticalConstraint.constrained === shape.id; const snapOffsetX = constrainedX ? -horizontalConstraint.signedDistance : 0; const snapOffsetY = constrainedY ? -verticalConstraint.signedDistance : 0; if (constrainedX || constrainedY) { + if (!snapOffsetX && !snapOffsetY) return shape; const snapOffset = matrix.translateComponent( matrix.multiply( matrix.rotateZ((matrix.matrixToAngle(draggedElement.localTransformMatrix) / 180) * Math.PI), @@ -915,13 +971,10 @@ const translateShapeSnap = (horizontalConstraint, verticalConstraint, draggedEle ...shape, snapDeltaMatrix: snapOffset, }; - } else if (constrainedShape) { - return { - ...shape, - snapDeltaMatrix: null, - }; - } else { + } else if (shape.snapDeltaMatrix || shape.snapResizeVector) { return crystallizeConstraint(shape); + } else { + return shape; } }; @@ -981,24 +1034,27 @@ const snappedShapes = select( symmetricManipulation ) => { const contentShapes = shapes.filter(shape => shape.type !== 'annotation'); + 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) + 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 horizontalConstraint = constrained && directionalConstraint(constraints, isHorizontal); const verticalConstraint = constrained && directionalConstraint(constraints, isVertical); - const snapper = draggedShape - ? { - [config.resizeHandleName]: resizeShapeSnap( + const snapper = + subtype === config.resizeHandleName + ? resizeShapeSnap( horizontalConstraint, verticalConstraint, draggedElement, symmetricManipulation, draggedShape.horizontalPosition, draggedShape.verticalPosition - ), - [undefined]: translateShapeSnap(horizontalConstraint, verticalConstraint, draggedElement), - }[draggedShape.subtype] || (shape => shape) - : crystallizeConstraint; + ) + : translateShapeSnap(horizontalConstraint, verticalConstraint, draggedElement); // leaf element or ad-hoc group return contentShapes.map(snapper); } )( @@ -1014,20 +1070,228 @@ const constrainedShapesWithPreexistingAnnotations = select((snapped, transformed snapped.concat(transformed.filter(s => s.type === 'annotation')) )(snappedShapes, transformedShapes); -const resizeAnnotations = select(resizeAnnotationsFunction)( - constrainedShapesWithPreexistingAnnotations, - selectedShapes +const extend = ([[xMin, yMin], [xMax, yMax]], [x0, y0], [x1, y1]) => [ + [Math.min(xMin, x0, x1), Math.min(yMin, y0, y1)], + [Math.max(xMax, x0, x1), Math.max(yMax, y0, y1)], +]; + +const isAdHocGroup = shape => + shape.type === config.groupName && shape.subtype === config.adHocGroupName; + +// fixme put it into geometry.js +const getAABB = shapes => + shapes.reduce( + (prev, shape) => { + const shapeBounds = cornerVertices.reduce((prev, xyVertex) => { + const cornerPoint = matrix.normalize( + matrix.mvMultiply(shape.transformMatrix, [ + shape.a * xyVertex[0], + shape.b * xyVertex[1], + 0, + 1, + ]) + ); + return extend(prev, cornerPoint, cornerPoint); + }, prev); + return extend(prev, ...shapeBounds); + }, + [[Infinity, Infinity], [-Infinity, -Infinity]] + ); + +const projectAABB = ([[xMin, yMin], [xMax, yMax]]) => { + const a = (xMax - xMin) / 2; + const b = (yMax - yMin) / 2; + const xTranslate = xMin + a; + const yTranslate = yMin + b; + const zTranslate = 0; // todo fix hack that ensures that grouped elements continue to be selectable + const localTransformMatrix = matrix.translate(xTranslate, yTranslate, zTranslate); + const rigTransform = matrix.translate(-xTranslate, -yTranslate, -zTranslate); + return { a, b, localTransformMatrix, rigTransform }; +}; + +const dissolveGroups = (preexistingAdHocGroups, shapes, selectedShapes) => { + return { + shapes: shapes.filter(shape => !isAdHocGroup(shape)).map(shape => { + const preexistingAdHocGroupParent = preexistingAdHocGroups.find( + groupShape => groupShape.id === shape.parent + ); + // if linked, dissociate from ad hoc group parent + return preexistingAdHocGroupParent + ? { + ...shape, + parent: null, + localTransformMatrix: matrix.multiply( + preexistingAdHocGroupParent.localTransformMatrix, // reinstate the group offset onto the child + shape.localTransformMatrix + ), + } + : shape; + }), + 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 axisAlignedBoundingBox = getAABB(shapesToBox); + const { a, b, localTransformMatrix, rigTransform } = projectAABB(axisAlignedBoundingBox); + const id = config.adHocGroupName + '_' + makeUid(); + const aabbShape = { + id, + type: config.groupName, + subtype: config.adHocGroupName, + a, + b, + localTransformMatrix, + rigTransform, + }; + return aabbShape; +}; + +const resizeGroup = (shapes, selectedShapes) => { + const extending = shape => { + const children = shapes.filter(s => s.parent === shape.id && s.type !== 'annotation'); + const axisAlignedBoundingBox = getAABB(children); + const { a, b, localTransformMatrix, rigTransform } = projectAABB(axisAlignedBoundingBox); + return { + ...shape, + localTransformMatrix, + a, + b, + rigTransform, + deltaLocalTransformMatrix: matrix.multiply( + shape.localTransformMatrix, + matrix.invert(localTransformMatrix) + ), + }; + }; + const extender = (shapes, shape) => { + if (!shape.parent) return shape; + const parent = shapes.find(s => s.id === shape.parent); + return { + ...shape, + localTransformMatrix: matrix.multiply( + shape.localTransformMatrix, + parent.deltaLocalTransformMatrix + ), + }; + }; + const extendingIfNeeded = shape => (isAdHocGroup(shape) ? extending(shape) : shape); + const extenderIfNeeded = (shape, i, shapes) => + isAdHocGroup(shape) || shape.type === 'annotation' ? shape : extender(shapes, shape); + const extendingShapes = shapes.map(extendingIfNeeded); + return { + shapes: extendingShapes.map(extenderIfNeeded), + selectedShapes: selectedShapes + .map(extendingIfNeeded) + .map(d => extenderIfNeeded(d, undefined, extendingShapes)), + }; +}; + +const getLeafs = (descendCondition, allShapes, shapes) => + removeDuplicates( + s => s.id, + flatten( + shapes.map( + shape => (descendCondition(shape) ? allShapes.filter(s => s.parent === shape.id) : shape) + ) + ) + ); + +const grouping = select((shapes, selectedShapes) => { + const preexistingAdHocGroups = shapes.filter(isAdHocGroup); + const freshSelectedShapes = shapes.filter(idsMatch(selectedShapes)); + const freshNonSelectedShapes = shapes.filter(not(idsMatch(selectedShapes))); + const someSelectedShapesAreGrouped = selectedShapes.some(isOrBelongsToAdHocGroup); + const selectionOutsideGroup = !someSelectedShapesAreGrouped; + + // 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 dissolveGroups( + preexistingAdHocGroups, + shapes, + asYetUngroupedShapes(preexistingAdHocGroups, freshSelectedShapes) + ); + } + + // preserve the current selection if the sole ad hoc group is being manipulated + if ( + selectedShapes.length === 1 && + contentShapes(shapes, selectedShapes)[0].subtype === 'adHocGroup' + ) + return { 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 config.groupResize ? resizeGroup(shapes, selectedShapes) : { shapes, selectedShapes }; + } else { + // group together the multiple items + const group = axisAlignedBoundingBoxShape(freshSelectedShapes); + const selectedLeafShapes = getLeafs( + shape => shape.subtype === config.adHocGroupName, + shapes, + freshSelectedShapes + ); + const parentedSelectedShapes = selectedLeafShapes.map(shape => ({ + ...shape, + parent: group.id, + localTransformMatrix: matrix.multiply(group.rigTransform, shape.transformMatrix), + })); + const nonGroupGraphConstituent = s => + s.subtype !== config.adHocGroupName && !parentedSelectedShapes.find(ss => s.id === ss.id); + const dissociateFromParentIfAny = s => + s.parent && s.parent.startsWith(config.adHocGroupName) ? { ...s, parent: null } : s; + const allTerminalShapes = parentedSelectedShapes.concat( + freshNonSelectedShapes.filter(nonGroupGraphConstituent).map(dissociateFromParentIfAny) + ); + return { + shapes: allTerminalShapes.concat([group]), + selectedShapes: [group], + }; + } +})(constrainedShapesWithPreexistingAnnotations, selectedShapes); + +const groupedSelectedShapes = select(({ selectedShapes }) => selectedShapes)(grouping); + +const groupedSelectedShapeIds = select(selectedShapes => selectedShapes.map(shape => shape.id))( + groupedSelectedShapes ); -const rotationAnnotations = select((shapes, selectedShapes) => { +const groupedSelectedPrimaryShapeIds = select(selectedShapes => selectedShapes.map(primaryShape))( + groupedSelectedShapes +); + +const resizeAnnotations = select(resizeAnnotationsFunction)(grouping); + +const rotationAnnotations = select(({ shapes, selectedShapes }) => { const shapesToAnnotate = selectedShapes; return shapesToAnnotate .map((shape, i) => rotationAnnotation(shapes, selectedShapes, shape, i)) .filter(identity); -})(constrainedShapesWithPreexistingAnnotations, selectedShapes); +})(grouping); const annotatedShapes = select( - (shapes, alignmentGuideAnnotations, hoverAnnotations, rotationAnnotations, resizeAnnotations) => { + ( + { shapes }, + alignmentGuideAnnotations, + hoverAnnotations, + rotationAnnotations, + resizeAnnotations + ) => { const annotations = [].concat( alignmentGuideAnnotations, hoverAnnotations, @@ -1038,15 +1302,9 @@ const annotatedShapes = select( const contentShapes = shapes.filter(shape => shape.type !== 'annotation'); return contentShapes.concat(annotations); // add current annotations } -)( - snappedShapes, - alignmentGuideAnnotations, - hoverAnnotations, - rotationAnnotations, - resizeAnnotations -); +)(grouping, alignmentGuideAnnotations, hoverAnnotations, rotationAnnotations, resizeAnnotations); -const globalTransformShapes = select(cascadeTransforms)(annotatedShapes); +const globalTransformShapes = select(cascadeProperties)(annotatedShapes); const bidirectionalCursors = { '0': 'ns-resize', @@ -1080,31 +1338,50 @@ const cursor = select((shape, draggedPrimaryShape) => { const nextScene = select( ( hoveredShape, - selectedShapes, + selectedShapeIds, selectedPrimaryShapes, shapes, gestureEnd, draggedShape, - cursor + cursor, + selectionState, + mouseTransformState, + selectedShapes ) => { + const selectedLeafShapes = getLeafs( + shape => shape.subtype === config.adHocGroupName, + shapes, + selectionState.shapes.map( + s => (s.type === 'annotation' ? shapes.find(ss => ss.id === s.parent) : s) + ) + ) + .filter(shape => shape.type !== 'annotation') + .map(s => s.id); return { hoveredShape, - selectedShapes, + selectedShapes: selectedShapeIds, + selectedLeafShapes, selectedPrimaryShapes, shapes, gestureEnd, draggedShape, cursor, + selectionState, + mouseTransformState, + selectedShapeObjects: selectedShapes, }; } )( hoveredShape, - selectedShapeIds, - selectedPrimaryShapeIds, + groupedSelectedShapeIds, + groupedSelectedPrimaryShapeIds, globalTransformShapes, gestureEnd, draggedShape, - cursor + cursor, + selectionState, + mouseTransformState, + groupedSelectedShapes ); module.exports = { @@ -1118,93 +1395,3 @@ module.exports = { focusedShapes, selectedShapes: selectedShapeIds, }; - -/** - * General inputs to behaviors: - * - * 1. Mode: the mode the user is in. For example, clicking on a shape in 'edit' mode does something different (eg. highlight - * activation hotspots or show the object in a configuration tab) than in 'presentation' mode (eg. jump to a link, or just - * nothing). This is just an example and it can be a lot more granular, eg. a 2D vs 3D mode; perspective vs isometric; - * shape being translated vs resized vs whatever. Multiple modes can apply simultaneously. Modes themselves may have - * structure: simple, binary or multistate modes at a flat level; ring-like; tree etc. or some mix. Modes are generally - * not a good thing, so we should use it sparingly (see Bret Victor's reference to NOMODES as one of his examples in - * Inventing on Principle) - * - * 2. Focus: there's some notion of what the behaviors act on, for example, a shape we hover over or select; multiple - * shapes we select or lasso; or members of a group (direct descendants, or all descendants, or only all leafs). The - * focus can be implied, eg. act on whatever's currently in view. It can also arise hierarchical: eg. move shapes within - * a specific 'project' (normal way of working things, like editing one specific text file), or highlighting multiple - * shapes with a lasso within a previously focused group. There can be effects (color highlighting, autozooming etc.) that - * show what is currently in focus, as the user's mental model and the computer's notion of focus must go hand in hand. - * - * 3. Gesture: a primitive action that's raw input. Eg. moving the mouse a bit, clicking, holding down a modifier key or - * hitting a key. This is how the user acts on the scene. Can be for direct manipulation (eg. drag or resize) or it can - * be very modal (eg. a key acting in a specific mode, or a key or other gesture that triggers a new mode or cancels a - * preexisting mode). Gestures may be compose simultaneously (eg. clicking while holding down a modifier key) and/or - * temporally (eg. grab, drag, release). Ie. composition and finite state machine. But these could (should?) be modeled - * via submerging into specific modes. For example, grabbing an object and starting to move the mouse may induce the - * 'drag' mode (within whatever mode we're already in). Combining modes, foci and gestures give us the typical design - * software toolbars, menus, palettes. For example, clicking (gesture) on the pencil icon (focus, as we're above it) will - * put us in the freehand drawing mode. - * - * 4. External variables: can be time, or a sequence of things triggered by time (eg. animation, alerting, data fetch...) - * or random data (for simulation) or a new piece of data from the server (in the case of collaborative editing) - * - * 5. Memory: undo/redo, repeat action, keyboard macros and time travel require that successive states or actions be recorded - * so they're recoverable later. Sometimes the challenge is in determining what the right level is. For example, should - * `undo` undo the last letter typed, or a larger transaction (eg. filling a field), or something in between, eg. regroup - * the actions and delete the lastly entered word sentence. Also, in macro recording, is actual mouse movement used, or - * something arising from it, eg. the selection on an object? - * - * Action: actions are granular, discrete pieces of progress along some user intent. Actions are not primary, except - * gestures. They arise from the above primary inputs. They can be hierarchical in that a series of actions (eg. - * selecting multiple shapes and hitting `Group`) leads to the higher level action of "group all these elements". - * - * All these are input to how we deduce _user intent_, therefore _action_. There can be a whirl of these things leading to - * higher levels, eg. click (gesture) over an icon (focus) puts us in a new mode, which then alters what specific gestures, - * modes and foci are possible; it can be an arbitrary graph. Let's try to characterize this graph... - * - */ - -/** - * Selections - * - * On first sight, selection is simple. The user clicks on an Element, and thus the Element becomes selected; any previous - * selection is cleared. If the user clicks anywhere else on the Canvas, the selection goes away. - * - * There are however wrinkles so large, they dwarf the original shape of the cloth: - * - * 1. Selecting occluded items - * a. by sequentially meta+clicking at a location - * b. via some other means, eg. some modal or non-modal popup box listing the elements underneath one another - * 2. Selecting multiple items - * a. by option-clicking - * b. by rectangle selection or lasso selection, with requirement for point / line / area / volume touching an element - * c. by rectangle selection or lasso selection, with requirement for point / line / area / volume fully including an element - * d. select all elements of a group - * 3. How to combine occluded item selection with multiple item selection? - * a. separate the notion of vertical cycling and selection (naive, otoh known by user, implementations conflate them) - * b. resort to the dialog or form selection (multiple ticks) - * c. volume aware selection - * 4. Group related select - * a. select a group by its leaf node and drag the whole group with it - * b. select an element of a group and only move that (within the group) - * c. hierarchy aware select: eg. select all leaf nodes of a group at any level - * 5. Composite selections (generalization of selecting multiple items) - * a. additive selections: eg. multiple rectangular brushes - * b. subtractive selection: eg. selecting all but a few elements of a group - * 6. Annotation selection. Modeling controls eg. resize and rotate hotspots as annotations is useful because the - * display and interaction often goes hand in hand. In other words, a passive legend is but a special case of - * an active affordance: it just isn't interactive (noop). Also, annotations are useful to model as shapes - * because: - * a. they're part of the scenegraph - * b. hierarchical relations can be exploited, eg. a leaf shape or a group may have annotation that's locally - * positionable (eg. resize or rotate hotspots) - * c. the transform/projection math, and often, other facilities (eg. drag) can be shared (DRY) - * The complications are: - * a. clicking on and dragging a rotate handle shouldn't do the full selection, ie. it shouldn't get - * a 'selected' border, and the rotate handle shouldn't get a rotate handle of its own, recursively :-) - * b. clicking on a rotation handle, which is outside the element, should preserve the selected state of - * the element - * c. tbc - */ diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/state.js b/x-pack/plugins/canvas/public/lib/aeroelastic/state.js index faefcc9e99c5c..448c8b161f7c2 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/state.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/state.js @@ -82,4 +82,5 @@ module.exports = { createStore, select, selectReduce, + makeUid, }; diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index a2fc39f30987c..fb82de32fc0ef 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -204,17 +204,20 @@ export const duplicateElement = createThunk( } ); -export const removeElement = createThunk( - 'removeElement', - ({ dispatch, getState }, elementId, pageId) => { - const element = getElementById(getState(), elementId, pageId); - const shouldRefresh = element.filter != null && element.filter.length > 0; +export const removeElements = createThunk( + 'removeElements', + ({ dispatch, getState }, elementIds, pageId) => { + const shouldRefresh = elementIds.some(elementId => { + const element = getElementById(getState(), elementId, pageId); + const filterIsApplied = element.filter != null && element.filter.length > 0; + return filterIsApplied; + }); - const _removeElement = createAction('removeElement', (elementId, pageId) => ({ + const _removeElements = createAction('removeElements', (elementIds, pageId) => ({ pageId, - elementId, + elementIds, })); - dispatch(_removeElement(elementId, pageId)); + dispatch(_removeElements(elementIds, pageId)); if (shouldRefresh) dispatch(fetchAllRenderables()); } diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js index 85647b4e9a64f..ea9f76550bc32 100644 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js @@ -9,7 +9,7 @@ import { aeroelastic as aero } from '../../lib/aeroelastic_kibana'; import { matrixToAngle } from '../../lib/aeroelastic/matrix'; import { addElement, - removeElement, + removeElements, duplicateElement, elementLayer, setPosition, @@ -212,7 +212,7 @@ export const aeroelastic = ({ dispatch, getState }) => { break; - case removeElement.toString(): + case removeElements.toString(): case addElement.toString(): case duplicateElement.toString(): case elementLayer.toString(): diff --git a/x-pack/plugins/canvas/public/state/reducers/elements.js b/x-pack/plugins/canvas/public/state/reducers/elements.js index 3dea1cb020485..de063965cfacb 100644 --- a/x-pack/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/plugins/canvas/public/state/reducers/elements.js @@ -57,7 +57,7 @@ function moveElementLayer(workpadState, pageId, elementId, movement) { export const elementsReducer = handleActions( { - // TODO: This takes the entire element, which is not neccesary, it could just take the id. + // 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 }); @@ -85,14 +85,18 @@ export const elementsReducer = handleActions( return push(workpadState, ['pages', pageIndex, 'elements'], element); }, - [actions.removeElement]: (workpadState, { payload: { pageId, elementId } }) => { + [actions.removeElements]: (workpadState, { payload: { pageId, elementIds } }) => { const pageIndex = getPageIndexById(workpadState, pageId); if (pageIndex < 0) return workpadState; - const elementIndex = getElementIndexById(workpadState.pages[pageIndex], elementId); - if (elementIndex < 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 del(workpadState, ['pages', pageIndex, 'elements', elementIndex]); + return elementIndices.reduce( + (state, nextElementIndex) => del(state, ['pages', pageIndex, 'elements', nextElementIndex]), + workpadState + ); }, }, {} diff --git a/x-pack/plugins/canvas/public/state/reducers/transient.js b/x-pack/plugins/canvas/public/state/reducers/transient.js index 471c6095e2972..6c2983fd850e3 100644 --- a/x-pack/plugins/canvas/public/state/reducers/transient.js +++ b/x-pack/plugins/canvas/public/state/reducers/transient.js @@ -8,7 +8,7 @@ import { handleActions } from 'redux-actions'; import { set, del } from 'object-path-immutable'; import { restoreHistory } from '../actions/history'; import * as actions from '../actions/transient'; -import { removeElement } from '../actions/elements'; +import { removeElements } from '../actions/elements'; import { setRefreshInterval } from '../actions/workpad'; export const transientReducer = handleActions( @@ -17,14 +17,14 @@ export const transientReducer = handleActions( // TODO: we shouldn't need to reset the resolved args for history [restoreHistory]: transientState => set(transientState, 'resolvedArgs', {}), - [removeElement]: (transientState, { payload: { elementId } }) => { + [removeElements]: (transientState, { payload: { elementIds } }) => { const { selectedElement } = transientState; return del( { ...transientState, - selectedElement: selectedElement === elementId ? null : selectedElement, + selectedElement: elementIds.indexOf(selectedElement) === -1 ? selectedElement : null, }, - ['resolvedArgs', elementId] + ['resolvedArgs', elementIds] ); },