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 2fbcecc94d8dd0..c95395d91734ca 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/workpad_page.js b/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.js index 2aa28492af522e..b94c5b8c2a45f0 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 fdb00987e80f95..a9fc4072683fa4 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 a3c5c06d0b23f8..4f8967503337aa 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 3ff3f5a0a7681a..825fabb5d03506 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 c0e836d5de229a..613823bd8bc4a4 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 => @@ -614,8 +658,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++) { @@ -825,6 +870,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 +895,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 +906,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); @@ -1014,20 +1070,224 @@ 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 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 = removeDuplicates( + s => s.id, + flatten( + freshSelectedShapes.map( + shape => + shape.subtype === config.adHocGroupName + ? shapes.filter(s => s.parent === shape.id) + : shape + ) + ) + ); + 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,13 +1298,7 @@ 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); @@ -1080,31 +1334,40 @@ const cursor = select((shape, draggedPrimaryShape) => { const nextScene = select( ( hoveredShape, - selectedShapes, + selectedShapeIds, selectedPrimaryShapes, shapes, gestureEnd, draggedShape, - cursor + cursor, + selectionState, + mouseTransformState, + selectedShapes ) => { return { hoveredShape, - selectedShapes, + selectedShapes: selectedShapeIds, 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 +1381,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 faefcc9e99c5cd..448c8b161f7c22 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, };