Feat: ad-hoc grouping (#23249) (#23585)

* Feat: ad-hoc grouping

* Feat: deleting ad-hoc group constituents

* Chore: deleted the former removeElement action

* Feat: make group snap to guides

* Feat: make group snap to guides 2

* Feat: make group snap to guides 3
This commit is contained in:
Robert Monfera 2018-09-27 23:23:06 +02:00 committed by GitHub
parent 87f67eaa1a
commit d8f5fc9889
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 534 additions and 323 deletions

View file

@ -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());

View file

@ -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

View file

@ -79,7 +79,7 @@ export const WorkpadPage = ({
default:
return [];
}
} else {
} else if (element.subtype !== 'adHocGroup') {
return <ElementWrapper key={element.id} element={element} />;
}
})

View file

@ -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,

View file

@ -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,
};

View file

@ -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,
};

View file

@ -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 };
};
const multiSelect = (prev, hoveredShapes, metaHeld, down, uid) => {
return {
shapes: hoveredShapes.length
? disjunctiveUnion(shape => shape.id, prev.shapes, hoveredShapes)
: [],
shapes: hoveredShapes.length ? [hoveredShapes[depthIndex]] : [],
uid,
depthIndex: hoveredShapes.length ? depthIndex : 0,
down,
};
};
const selectedShapes = selectReduce(
(prev, hoveredShapes, { down, uid }, metaHeld, directSelect, allShapes) => {
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,
uid,
depthIndex: 0,
down: false,
};
};
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
*/

View file

@ -82,4 +82,5 @@ module.exports = {
createStore,
select,
selectReduce,
makeUid,
};

View file

@ -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());
}

View file

@ -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():

View file

@ -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
);
},
},
{}

View file

@ -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]
);
},