diff --git a/package.json b/package.json
index 2d5d9c3c33de5..9c673589796be 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
"test:ui:runner": "node scripts/functional_test_runner",
"test:server": "grunt test:server",
"test:coverage": "grunt test:coverage",
+ "typespec": "typings-tester --config x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json x-pack/plugins/canvas/public/lib/aeroelastic/__fixtures__/typescript/typespec_tests.ts",
"checkLicenses": "grunt licenses --dev",
"build": "node scripts/build --all-platforms",
"start": "node --trace-warnings --trace-deprecation scripts/kibana --dev ",
@@ -407,6 +408,7 @@
"tslint-microsoft-contrib": "^6.0.0",
"tslint-plugin-prettier": "^2.0.0",
"typescript": "^3.0.3",
+ "typings-tester": "^0.3.2",
"vinyl-fs": "^3.0.2",
"xml2js": "^0.4.19",
"xmlbuilder": "9.0.4",
diff --git a/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.js b/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.js
index 700e695912a40..d5a76efaf3d49 100644
--- a/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.js
+++ b/x-pack/plugins/canvas/public/components/alignment_guide/alignment_guide.js
@@ -6,7 +6,7 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { toCSS } from '../../lib/aeroelastic';
+import { matrixToCSS } from '../../lib/dom';
export const AlignmentGuide = ({ transformMatrix, width, height }) => {
const newStyle = {
@@ -16,7 +16,7 @@ export const AlignmentGuide = ({ transformMatrix, width, height }) => {
marginTop: -height / 2,
background: 'magenta',
position: 'absolute',
- transform: toCSS(transformMatrix),
+ transform: matrixToCSS(transformMatrix),
};
return (
{
const newStyle = {
@@ -15,7 +15,7 @@ export const BorderConnection = ({ transformMatrix, width, height }) => {
marginLeft: -width / 2,
marginTop: -height / 2,
position: 'absolute',
- transform: toCSS(transformMatrix),
+ transform: matrixToCSS(transformMatrix),
};
return
;
};
diff --git a/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.js b/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.js
index 4d01070b7b708..704f5934345e7 100644
--- a/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.js
+++ b/x-pack/plugins/canvas/public/components/border_resize_handle/border_resize_handle.js
@@ -6,12 +6,12 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { toCSS } from '../../lib/aeroelastic';
+import { matrixToCSS } from '../../lib/dom';
export const BorderResizeHandle = ({ transformMatrix }) => (
);
diff --git a/x-pack/plugins/canvas/public/components/dragbox_annotation/dragbox_annotation.js b/x-pack/plugins/canvas/public/components/dragbox_annotation/dragbox_annotation.js
new file mode 100644
index 0000000000000..8c67404cb9b7d
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/dragbox_annotation/dragbox_annotation.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import { matrixToCSS } from '../../lib/dom';
+
+export const DragBoxAnnotation = ({ transformMatrix, width, height }) => {
+ const newStyle = {
+ width,
+ height,
+ marginLeft: -width / 2,
+ marginTop: -height / 2,
+ transform: matrixToCSS(transformMatrix),
+ };
+ return
;
+};
+
+DragBoxAnnotation.propTypes = {
+ transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired,
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+};
diff --git a/x-pack/plugins/canvas/public/components/dragbox_annotation/dragbox_annotation.scss b/x-pack/plugins/canvas/public/components/dragbox_annotation/dragbox_annotation.scss
new file mode 100644
index 0000000000000..bd14ced586dbd
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/dragbox_annotation/dragbox_annotation.scss
@@ -0,0 +1,8 @@
+.canvasDragBoxAnnotation {
+ position: absolute;
+ background: none;
+ transform-origin: center center; /* the default, only for clarity */
+ transform-style: preserve-3d;
+ outline: dashed 1px $euiColorDarkShade;
+ pointer-events: none;
+}
diff --git a/x-pack/plugins/canvas/public/components/dragbox_annotation/index.js b/x-pack/plugins/canvas/public/components/dragbox_annotation/index.js
new file mode 100644
index 0000000000000..85c97e90776c6
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/dragbox_annotation/index.js
@@ -0,0 +1,10 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { pure } from 'recompose';
+import { DragBoxAnnotation as Component } from './dragbox_annotation';
+
+export const DragBoxAnnotation = pure(Component);
diff --git a/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.js b/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.js
index ab9edc1579224..7337c0446e31c 100644
--- a/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.js
+++ b/x-pack/plugins/canvas/public/components/hover_annotation/hover_annotation.js
@@ -6,7 +6,7 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { toCSS } from '../../lib/aeroelastic';
+import { matrixToCSS } from '../../lib/dom';
export const HoverAnnotation = ({ transformMatrix, width, height }) => {
const newStyle = {
@@ -14,7 +14,7 @@ export const HoverAnnotation = ({ transformMatrix, width, height }) => {
height,
marginLeft: -width / 2,
marginTop: -height / 2,
- transform: toCSS(transformMatrix),
+ transform: matrixToCSS(transformMatrix),
};
return
;
};
diff --git a/x-pack/plugins/canvas/public/components/positionable/positionable.js b/x-pack/plugins/canvas/public/components/positionable/positionable.js
index e68c4f749ea0d..fc8c3572664b0 100644
--- a/x-pack/plugins/canvas/public/components/positionable/positionable.js
+++ b/x-pack/plugins/canvas/public/components/positionable/positionable.js
@@ -6,7 +6,7 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { toCSS } from '../../lib/aeroelastic';
+import { matrixToCSS } from '../../lib/dom';
export const Positionable = ({ children, transformMatrix, width, height }) => {
// Throw if there is more than one child
@@ -19,7 +19,7 @@ export const Positionable = ({ children, transformMatrix, width, height }) => {
marginLeft: -width / 2,
marginTop: -height / 2,
position: 'absolute',
- transform: toCSS(transformMatrix.map((n, i) => (i < 12 ? n : Math.round(n)))),
+ transform: matrixToCSS(transformMatrix.map((n, i) => (i < 12 ? n : Math.round(n)))),
};
const stepChild = React.cloneElement(child, { size: { width, height } });
diff --git a/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.js b/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.js
index 16d95ce8285a6..335f2e719857f 100644
--- a/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.js
+++ b/x-pack/plugins/canvas/public/components/rotation_handle/rotation_handle.js
@@ -6,12 +6,12 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { toCSS } from '../../lib/aeroelastic';
+import { matrixToCSS } from '../../lib/dom';
export const RotationHandle = ({ transformMatrix }) => (
diff --git a/x-pack/plugins/canvas/public/components/tooltip_annotation/tooltip_annotation.js b/x-pack/plugins/canvas/public/components/tooltip_annotation/tooltip_annotation.js
index 535ca531365f9..1836bfd0162f3 100644
--- a/x-pack/plugins/canvas/public/components/tooltip_annotation/tooltip_annotation.js
+++ b/x-pack/plugins/canvas/public/components/tooltip_annotation/tooltip_annotation.js
@@ -6,11 +6,11 @@
import React from 'react';
import PropTypes from 'prop-types';
-import { toCSS } from '../../lib/aeroelastic';
+import { matrixToCSS } from '../../lib/dom';
export const HoverAnnotation = ({ transformMatrix, text }) => {
const newStyle = {
- transform: `${toCSS(transformMatrix)} translate(1em, -1em)`,
+ transform: `${matrixToCSS(transformMatrix)} translate(1em, -1em)`,
};
return (
diff --git a/x-pack/plugins/canvas/public/components/workpad_page/aeroelastic_redux_helpers.js b/x-pack/plugins/canvas/public/components/workpad_page/aeroelastic_redux_helpers.js
new file mode 100644
index 0000000000000..383288072ab76
--- /dev/null
+++ b/x-pack/plugins/canvas/public/components/workpad_page/aeroelastic_redux_helpers.js
@@ -0,0 +1,199 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { shallowEqual } from 'recompose';
+import { invert, matrixToAngle, multiply, rotateZ, translate } from '../../lib/aeroelastic/matrix';
+import { arrayToMap, identity } from '../../lib/aeroelastic/functional';
+
+export const aeroelasticConfiguration = {
+ getAdHocChildAnnotationName: 'adHocChildAnnotation',
+ adHocGroupName: 'adHocGroup',
+ alignmentGuideName: 'alignmentGuide',
+ atopZ: 1000,
+ depthSelect: true,
+ devColor: 'magenta',
+ dragBoxAnnotationName: 'dragBoxAnnotation',
+ dragBoxZ: 1050, // above alignment guides but below the upcoming hover tooltip
+ groupName: 'group',
+ groupResize: true,
+ guideDistance: 3,
+ hoverAnnotationName: 'hoverAnnotation',
+ hoverLift: 100,
+ intraGroupManipulation: false,
+ intraGroupSnapOnly: false,
+ minimumElementSize: 0,
+ persistentGroupName: 'persistentGroup',
+ resizeAnnotationConnectorOffset: 0,
+ resizeAnnotationOffset: 0,
+ resizeAnnotationOffsetZ: 0.1, // causes resize markers to be slightly above the shape plane
+ resizeAnnotationSize: 10,
+ resizeConnectorName: 'resizeConnector',
+ resizeHandleName: 'resizeHandle',
+ rotateAnnotationOffset: 12,
+ rotateSnapInPixels: 10,
+ rotationEpsilon: 0.001,
+ rotationHandleName: 'rotationHandle',
+ rotationHandleSize: 14,
+ rotationTooltipName: 'rotationTooltip',
+ shortcuts: false,
+ singleSelect: false,
+ snapConstraint: true,
+ tooltipZ: 1100,
+};
+
+export const makeUid = () => 1e11 + Math.floor((1e12 - 1e11) * Math.random());
+
+// check for duplication
+const deduped = a => a.filter((d, i) => a.indexOf(d) === i);
+export const idDuplicateCheck = groups => {
+ if (deduped(groups.map(g => g.id)).length !== groups.length) {
+ throw new Error('Duplicate element encountered');
+ }
+};
+
+export const missingParentCheck = groups => {
+ const idMap = arrayToMap(groups.map(g => g.id));
+ groups.forEach(g => {
+ if (g.parent && !idMap[g.parent]) {
+ g.parent = null;
+ }
+ });
+};
+
+/**
+ * elementToShape
+ *
+ * converts a `kibana-canvas` element to an `aeroelastic` shape.
+ *
+ * Shape: the layout algorithms need to deal with objects through their geometric properties, excluding other aspects,
+ * such as what's inside the element, eg. image or scatter plot. This representation is, at its core, a transform matrix
+ * that establishes a new local coordinate system https://drafts.csswg.org/css-transforms/#local-coordinate-system plus a
+ * size descriptor. There are two versions of the transform matrix:
+ * - `transformMatrix` is analogous to the SVG https://drafts.csswg.org/css-transforms/#current-transformation-matrix
+ * - `localTransformMatrix` is analogous to the SVG https://drafts.csswg.org/css-transforms/#transformation-matrix
+ *
+ * Element: it also needs to represent the geometry, primarily because of the need to persist it in `redux` and on the
+ * server, and to accept such data from the server. The redux and server representations will need to change as more general
+ * projections such as 3D are added. The element also needs to maintain its content, such as an image or a plot.
+ *
+ * While all elements on the current page also exist as shapes, there are shapes that are not elements: annotations.
+ * For example, `rotation_handle`, `border_resize_handle` and `border_connection` are modeled as shapes by the layout
+ * library, simply for generality.
+ */
+export const elementToShape = (element, i) => {
+ const position = element.position;
+ const a = position.width / 2;
+ const b = position.height / 2;
+ const cx = position.left + a;
+ const cy = position.top + b;
+ const z = i; // painter's algo: latest item goes to top
+ // multiplying the angle with -1 as `transform: matrix3d` uses a left-handed coordinate system
+ const angleRadians = (-position.angle / 180) * Math.PI;
+ const transformMatrix = multiply(translate(cx, cy, z), rotateZ(angleRadians));
+ const isGroup = element.id.startsWith('group');
+ const parent = (element.position && element.position.parent) || null; // reserved for hierarchical (tree shaped) grouping
+ return {
+ id: element.id,
+ type: isGroup ? 'group' : 'rectangleElement',
+ subtype: isGroup ? 'persistentGroup' : '',
+ parent,
+ transformMatrix,
+ a, // we currently specify half-width, half-height as it leads to
+ b, // more regular math (like ellipsis radii rather than diameters)
+ };
+};
+
+export const shapeToElement = shape => {
+ let angle = Math.round((matrixToAngle(shape.transformMatrix) * 180) / Math.PI);
+ if (1 / angle === -Infinity) {
+ angle = 0;
+ }
+ return {
+ left: shape.transformMatrix[12] - shape.a,
+ top: shape.transformMatrix[13] - shape.b,
+ width: shape.a * 2,
+ height: shape.b * 2,
+ angle,
+ parent: shape.parent || null,
+ type: shape.type === 'group' ? 'group' : 'element',
+ };
+};
+
+export const updateGlobalPositions = (setPositions, { shapes, gestureEnd }, unsortedElements) => {
+ const ascending = (a, b) => (a.id < b.id ? -1 : 1);
+ const relevant = s => s.type !== 'annotation' && s.subtype !== 'adHocGroup';
+ const elements = unsortedElements.filter(relevant).sort(ascending);
+ const repositionings = shapes
+ .filter(relevant)
+ .sort(ascending)
+ .map((shape, i) => {
+ const element = elements[i];
+ const elemPos = element && element.position;
+ if (elemPos && gestureEnd) {
+ // get existing position information from element
+ const oldProps = {
+ left: elemPos.left,
+ top: elemPos.top,
+ width: elemPos.width,
+ height: elemPos.height,
+ angle: Math.round(elemPos.angle),
+ type: elemPos.type,
+ parent: elemPos.parent || null,
+ };
+
+ // cast shape into element-like object to compare
+ const newProps = shapeToElement(shape);
+
+ if (1 / newProps.angle === -Infinity) {
+ newProps.angle = 0;
+ } // recompose.shallowEqual discerns between 0 and -0
+
+ const result = shallowEqual(oldProps, newProps)
+ ? null
+ : { position: newProps, elementId: shape.id };
+ return result;
+ }
+ })
+ .filter(identity);
+ if (repositionings.length) {
+ setPositions(repositionings);
+ }
+};
+
+// todo check past usage and eliminate
+const getRootElementId = (lookup, id) => {
+ if (!lookup.has(id)) {
+ return null;
+ }
+
+ const element = lookup.get(id);
+ return element.parent && element.parent.subtype !== 'adHocGroup'
+ ? getRootElementId(lookup, element.parent)
+ : element.id;
+};
+
+export const componentLayoutLocalState = props => {
+ // console.log('setting aero state on component!!!');
+ const shapes = props.elements
+ .map(elementToShape)
+ .filter((d, i, a) => !d.id.startsWith('group') || a.find(s => s.parent === d.id));
+ // todo delete these
+ idDuplicateCheck(shapes);
+ missingParentCheck(shapes);
+ shapes.forEach(shape => {
+ shape.localTransformMatrix = shape.parent
+ ? multiply(
+ invert(shapes.find(s => s.id === shape.parent).transformMatrix),
+ shape.transformMatrix
+ )
+ : shape.transformMatrix;
+ });
+ // todo move this initial state in a file under `aeroelastic/`
+ return {
+ primaryUpdate: null,
+ currentScene: { shapes, configuration: aeroelasticConfiguration },
+ };
+};
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 326860e687d80..b186d07283813 100644
--- a/x-pack/plugins/canvas/public/components/workpad_page/index.js
+++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js
@@ -6,15 +6,27 @@
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
-import { compose, withState, withProps, withHandlers } from 'recompose';
+import { compose, withHandlers, withProps, withState } from 'recompose';
import { notify } from '../../lib/notify';
-import { aeroelastic } from '../../lib/aeroelastic_kibana';
-import { setClipboardData, getClipboardData } from '../../lib/clipboard';
+import { getClipboardData, setClipboardData } from '../../lib/clipboard';
import { cloneSubgraphs } from '../../lib/clone_subgraphs';
-import { removeElements, insertNodes, elementLayer } from '../../state/actions/elements';
-import { getFullscreen, canUserWrite } from '../../state/selectors/app';
+import { canUserWrite, getFullscreen } from '../../state/selectors/app';
import { getNodes, isWriteable } from '../../state/selectors/workpad';
import { flatten } from '../../lib/aeroelastic/functional';
+import { nextScene } from '../../lib/aeroelastic/layout';
+import {
+ addElement,
+ elementLayer,
+ insertNodes,
+ removeElements,
+ setMultiplePositions,
+} from '../../state/actions/elements';
+import {
+ componentLayoutLocalState,
+ makeUid,
+ shapeToElement,
+ updateGlobalPositions,
+} from './aeroelastic_redux_helpers';
import { eventHandlers } from './event_handlers';
import { WorkpadPage as Component } from './workpad_page';
import { selectElement } from './../../state/actions/transient';
@@ -26,33 +38,154 @@ const mapStateToProps = (state, ownProps) => {
};
};
-const mapDispatchToProps = dispatch => {
+const mapDispatchToProps = dispatch => ({
+ insertNodes: pageId => selectedElements => dispatch(insertNodes(selectedElements, pageId)),
+ removeElements: pageId => elementIds => dispatch(removeElements(elementIds, pageId)),
+ addElement: pageId => element => dispatch(addElement(pageId, element)),
+ setMultiplePositions: positions => dispatch(setMultiplePositions(positions)),
+ selectElement: selectedElement => dispatch(selectElement(selectedElement)),
+ // TODO: Abstract this out. This is the same code as in sidebar/index.js
+ elementLayer: (pageId, selectedElement, movement) => {
+ dispatch(
+ elementLayer({
+ pageId,
+ elementId: selectedElement.id,
+ movement,
+ })
+ );
+ },
+});
+
+const isSelectedAnimation = ({ isSelected, animation }) => {
+ function getClassName() {
+ if (animation) {
+ return animation.name;
+ }
+ return isSelected ? 'canvasPage--isActive' : 'canvasPage--isInactive';
+ }
+
+ function getAnimationStyle() {
+ if (!animation) {
+ return {};
+ }
+ return {
+ animationDirection: animation.direction,
+ // TODO: Make this configurable
+ animationDuration: '1s',
+ };
+ }
+
return {
- insertNodes: pageId => selectedElements => dispatch(insertNodes(selectedElements, pageId)),
- removeElements: pageId => elementIds => dispatch(removeElements(elementIds, pageId)),
- selectElement: selectedElement => dispatch(selectElement(selectedElement)),
- // TODO: Abstract this out. This is the same code as in sidebar/index.js
- elementLayer: (pageId, selectedElement, movement) => {
- dispatch(
- elementLayer({
- pageId,
- elementId: selectedElement.id,
- movement,
- })
- );
- },
+ className: getClassName(),
+ animationStyle: getAnimationStyle(),
};
};
-const getRootElementId = (lookup, id) => {
- if (!lookup.has(id)) {
- return null;
- }
+const calculateHandlers = ({
+ aeroelastic,
+ page,
+ insertNodes,
+ removeElements,
+ selectElement,
+ elementLayer,
+}) => {
+ const { shapes, selectedPrimaryShapes = [] } = aeroelastic.currentScene;
+ const recurseGroupTree = shapeId => {
+ return [
+ shapeId,
+ ...flatten(
+ shapes
+ .filter(s => s.parent === shapeId && s.type !== 'annotation')
+ .map(s => s.id)
+ .map(recurseGroupTree)
+ ),
+ ];
+ };
- const element = lookup.get(id);
- return element.parent && element.parent.subtype !== 'adHocGroup'
- ? getRootElementId(lookup, element.parent)
- : element.id;
+ const selectedPrimaryShapeObjects = selectedPrimaryShapes.map(id =>
+ shapes.find(s => s.id === id)
+ );
+ const selectedPersistentPrimaryShapes = flatten(
+ selectedPrimaryShapeObjects.map(shape =>
+ shape.subtype === 'adHocGroup'
+ ? shapes.filter(s => s.parent === shape.id && s.type !== 'annotation').map(s => s.id)
+ : [shape.id]
+ )
+ );
+ const selectedElementIds = flatten(selectedPersistentPrimaryShapes.map(recurseGroupTree));
+ const selectedElements = [];
+ return {
+ removeElements: () => {
+ // currently, handle the removal of one element, exploiting multiselect subsequently
+ if (selectedElementIds.length) {
+ removeElements(page.id)(selectedElementIds);
+ }
+ },
+ copyElements: () => {
+ if (selectedElements.length) {
+ setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes });
+ notify.success('Copied element to clipboard');
+ }
+ },
+ cutElements: () => {
+ if (selectedElements.length) {
+ setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes });
+ removeElements(page.id)(selectedElementIds);
+ notify.success('Copied element to clipboard');
+ }
+ },
+ // TODO: This is slightly different from the duplicateElements function in sidebar/index.js. Should they be doing the same thing?
+ // This should also be abstracted.
+ duplicateElements: () => {
+ const clonedElements = selectedElements && cloneSubgraphs(selectedElements);
+ if (clonedElements) {
+ insertNodes(page.id)(clonedElements);
+ if (selectedPrimaryShapes.length) {
+ if (selectedElements.length > 1) {
+ // adHocGroup branch (currently, pasting will leave only the 1st element selected, rather than forming a
+ // new adHocGroup - todo)
+ selectElement(clonedElements[0].id);
+ } else {
+ // single element or single persistentGroup branch
+ selectElement(
+ clonedElements[selectedElements.findIndex(s => s.id === selectedPrimaryShapes[0])].id
+ );
+ }
+ }
+ }
+ },
+ pasteElements: () => {
+ const { selectedElements, rootShapes } = JSON.parse(getClipboardData()) || {};
+ const clonedElements = selectedElements && cloneSubgraphs(selectedElements);
+ if (clonedElements) {
+ // first clone and persist the new node(s)
+ insertNodes(page.id)(clonedElements);
+ // then select the cloned node
+ if (rootShapes.length) {
+ if (selectedElements.length > 1) {
+ // adHocGroup branch (currently, pasting will leave only the 1st element selected, rather than forming a
+ // new adHocGroup - todo)
+ selectElement(clonedElements[0].id);
+ } else {
+ // single element or single persistentGroup branch
+ selectElement(
+ clonedElements[selectedElements.findIndex(s => s.id === rootShapes[0])].id
+ );
+ }
+ }
+ }
+ },
+ // TODO: Same as above. Abstract these out. This is the same code as in sidebar/index.js
+ // Note: these layer actions only work when a single element is selected
+ bringForward: () =>
+ selectedElements.length === 1 && elementLayer(page.id, selectedElements[0], 1),
+ bringToFront: () =>
+ selectedElements.length === 1 && elementLayer(page.id, selectedElements[0], Infinity),
+ sendBackward: () =>
+ selectedElements.length === 1 && elementLayer(page.id, selectedElements[0], -1),
+ sendToBack: () =>
+ selectedElements.length === 1 && elementLayer(page.id, selectedElements[0], -Infinity),
+ };
};
export const WorkpadPage = compose(
@@ -60,163 +193,102 @@ export const WorkpadPage = compose(
mapStateToProps,
mapDispatchToProps
),
- withProps(({ isSelected, animation }) => {
- function getClassName() {
- if (animation) {
- return animation.name;
- }
- return isSelected ? 'canvasPage--isActive' : 'canvasPage--isInactive';
- }
-
- function getAnimationStyle() {
- if (!animation) {
- return {};
- }
- return {
- animationDirection: animation.direction,
- // TODO: Make this configurable
- animationDuration: '1s',
- };
- }
-
- return {
- className: getClassName(),
- animationStyle: getAnimationStyle(),
- };
- }),
- withState('updateCount', 'setUpdateCount', 0), // TODO: remove this, see setUpdateCount below
- withProps(
- ({
- updateCount,
- setUpdateCount,
+ withProps(isSelectedAnimation),
+ withState('aeroelastic', 'setAeroelastic', componentLayoutLocalState),
+ withState('handlers', 'setHandlers', calculateHandlers),
+ withProps(props => {
+ const {
+ aeroelastic,
+ setAeroelastic,
+ handlers,
+ setHandlers,
page,
elements: pageElements,
- insertNodes,
+ addElement,
+ setMultiplePositions,
removeElements,
- selectElement,
- elementLayer,
- }) => {
- const { shapes, selectedPrimaryShapes = [], cursor } = aeroelastic.getStore(
- page.id
- ).currentScene;
- const elementLookup = new Map(pageElements.map(element => [element.id, element]));
- const recurseGroupTree = shapeId => {
- return [
- shapeId,
- ...flatten(
- shapes
- .filter(s => s.parent === shapeId && s.type !== 'annotation')
- .map(s => s.id)
- .map(recurseGroupTree)
- ),
- ];
+ } = props;
+ const { shapes, cursor } = aeroelastic.currentScene;
+ const elementLookup = new Map(pageElements.map(element => [element.id, element]));
+ const elements = shapes.map(shape => {
+ const element = elementLookup.has(shape.id) && elementLookup.get(shape.id);
+ // instead of just combining `element` with `shape`, we make property transfer explicit
+ const result = {
+ ...(element ? { ...shape, filter: element.filter } : shape),
+ width: shape.a * 2,
+ height: shape.b * 2,
};
+ return result;
+ });
+ return {
+ elements,
+ cursor,
+ commit: (type, payload) => {
+ setAeroelastic(state => {
+ const currentScene = nextScene({
+ ...state,
+ primaryUpdate: { type, payload: { ...payload, uid: makeUid() } },
+ });
+ if (currentScene.gestureEnd) {
+ // annotations don't need Redux persisting
+ const primaryShapes = currentScene.shapes.filter(shape => shape.type !== 'annotation');
- const selectedPrimaryShapeObjects = selectedPrimaryShapes.map(id =>
- shapes.find(s => s.id === id)
- );
- const selectedPersistentPrimaryShapes = flatten(
- selectedPrimaryShapeObjects.map(shape =>
- shape.subtype === 'adHocGroup'
- ? shapes.filter(s => s.parent === shape.id && s.type !== 'annotation').map(s => s.id)
- : [shape.id]
- )
- );
- const selectedElementIds = flatten(selectedPersistentPrimaryShapes.map(recurseGroupTree));
- const selectedElements = [];
- const elements = shapes.map(shape => {
- let element = null;
- if (elementLookup.has(shape.id)) {
- element = elementLookup.get(shape.id);
- if (selectedElementIds.indexOf(shape.id) > -1) {
- selectedElements.push({ ...element, id: shape.id });
- }
- }
- // instead of just combining `element` with `shape`, we make property transfer explicit
- return element ? { ...shape, filter: element.filter } : shape;
- });
- return {
- elements,
- cursor,
- commit: (...args) => {
- aeroelastic.commit(page.id, ...args);
- // TODO: remove this, it's a hack to force react to rerender
- setUpdateCount(updateCount + 1);
- },
- removeElements: () => {
- // currently, handle the removal of one element, exploiting multiselect subsequently
- if (selectedElementIds.length) {
- removeElements(page.id)(selectedElementIds);
- }
- },
- copyElements: () => {
- if (selectedElements.length) {
- setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes });
- notify.success('Copied element to clipboard');
- }
- },
- cutElements: () => {
- if (selectedElements.length) {
- setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes });
- removeElements(page.id)(selectedElementIds);
- notify.success('Copied element to clipboard');
- }
- },
- // TODO: This is slightly different from the duplicateElements function in sidebar/index.js. Should they be doing the same thing?
- // This should also be abstracted.
- duplicateElements: () => {
- const clonedElements = selectedElements && cloneSubgraphs(selectedElements);
- if (clonedElements) {
- insertNodes(page.id)(clonedElements);
- if (selectedPrimaryShapes.length) {
- if (selectedElements.length > 1) {
- // adHocGroup branch (currently, pasting will leave only the 1st element selected, rather than forming a
- // new adHocGroup - todo)
- selectElement(clonedElements[0].id);
- } else {
- // single element or single persistentGroup branch
- selectElement(
- clonedElements[selectedElements.findIndex(s => s.id === selectedPrimaryShapes[0])]
- .id
- );
- }
- }
- }
- },
- pasteElements: () => {
- const { selectedElements, rootShapes } = JSON.parse(getClipboardData()) || {};
- const clonedElements = selectedElements && cloneSubgraphs(selectedElements);
- if (clonedElements) {
- // first clone and persist the new node(s)
- insertNodes(page.id)(clonedElements);
- // then select the cloned node
- if (rootShapes.length) {
- if (selectedElements.length > 1) {
- // adHocGroup branch (currently, pasting will leave only the 1st element selected, rather than forming a
- // new adHocGroup - todo)
- selectElement(clonedElements[0].id);
- } else {
- // single element or single persistentGroup branch
- selectElement(
- clonedElements[selectedElements.findIndex(s => s.id === rootShapes[0])].id
- );
- }
+ // persistent groups
+ const persistableGroups = primaryShapes.filter(s => s.subtype === 'persistentGroup');
+
+ // remove all group elements
+ const elementsToRemove = pageElements.filter(
+ e => e.position.type === 'group' && !persistableGroups.find(p => p.id === e.id)
+ );
+ if (elementsToRemove.length) {
+ // console.log('removing groups', elementsToRemove.map(e => e.id).join(', '));
+ removeElements(page.id)(elementsToRemove.map(e => e.id));
}
+
+ // create all needed groups
+ persistableGroups
+ .filter(p => !pageElements.find(e => p.id === e.id))
+ .forEach(g => {
+ const partialElement = {
+ id: g.id,
+ filter: undefined,
+ expression: 'shape fill="rgba(255,255,255,0)" | render', // https://github.com/elastic/kibana/pull/28796
+ position: shapeToElement(g),
+ };
+ addElement(page.id)(partialElement);
+ });
+
+ // update the position of possibly changed elements
+ updateGlobalPositions(
+ positions => setMultiplePositions(positions.map(p => ({ ...p, pageId: page.id }))),
+ currentScene,
+ pageElements
+ );
+
+ // handlers can only change if there's change to Redux (checked by proxy of putting it in
+ // the if(currentScene.gestureEnd) {...}
+ // todo consider somehow putting it in or around `connect`, to more directly tie it to Redux change
+ setHandlers(() =>
+ calculateHandlers({
+ aeroelastic,
+ page,
+ insertNodes,
+ removeElements,
+ selectElement,
+ elementLayer,
+ })
+ );
}
- },
- // TODO: Same as above. Abstract these out. This is the same code as in sidebar/index.js
- // Note: these layer actions only work when a single element is selected
- bringForward: () =>
- selectedElements.length === 1 && elementLayer(page.id, selectedElements[0], 1),
- bringToFront: () =>
- selectedElements.length === 1 && elementLayer(page.id, selectedElements[0], Infinity),
- sendBackward: () =>
- selectedElements.length === 1 && elementLayer(page.id, selectedElements[0], -1),
- sendToBack: () =>
- selectedElements.length === 1 && elementLayer(page.id, selectedElements[0], -Infinity),
- };
- }
- ), // Updates states; needs to have both local and global
+
+ return {
+ ...state,
+ currentScene,
+ };
+ });
+ },
+ ...handlers,
+ };
+ }), // Updates states; needs to have both local and global
withHandlers(eventHandlers) // Captures user intent, needs to have reconciled state
)(Component);
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 44c5ef922f2b4..2cf42f6045f6b 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
@@ -10,6 +10,7 @@ import { Shortcuts } from 'react-shortcuts';
import { ElementWrapper } from '../element_wrapper';
import { AlignmentGuide } from '../alignment_guide';
import { HoverAnnotation } from '../hover_annotation';
+import { DragBoxAnnotation } from '../dragbox_annotation';
import { TooltipAnnotation } from '../tooltip_annotation';
import { RotationHandle } from '../rotation_handle';
import { BorderConnection } from '../border_connection';
@@ -177,6 +178,8 @@ export class WorkpadPage extends PureComponent {
case 'adHocChildAnnotation': // now sharing aesthetics but may diverge in the future
case 'hoverAnnotation': // fixme: with the upcoming TS work, use enumerative types here
return ;
+ case 'dragBoxAnnotation':
+ return ;
case 'rotationHandle':
return ;
case 'resizeHandle':
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/__fixtures__/typescript/typespec_tests.ts b/x-pack/plugins/canvas/public/lib/aeroelastic/__fixtures__/typescript/typespec_tests.ts
new file mode 100644
index 0000000000000..274e0e64ba361
--- /dev/null
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/__fixtures__/typescript/typespec_tests.ts
@@ -0,0 +1,82 @@
+import { select } from '../../select';
+import { Json, Selector } from '../..';
+
+/*
+
+ Type checking isn't too useful if future commits can accidentally weaken the type constraints, because a
+ TypeScript linter will not complain - everything that passed before will continue to pass. The coder
+ will not have feedback that the original intent with the typing got compromised. To declare the intent
+ via passing and failing type checks, test cases are needed, some of which designed to expect a TS pass,
+ some of them to expect a TS complaint. It documents intent for peers too, as type specs are a tough read.
+
+ Run compile-time type specification tests in the `kibana` root with:
+
+ yarn typespec
+
+ Test "cases" expecting to pass TS checks are not annotated, while ones we want TS to complain about
+ are prepended with the comment
+
+ // typings:expect-error
+
+ The test "suite" and "cases" are wrapped in IIFEs to prevent linters from complaining about the unused
+ binding. It can be structured internally as desired.
+
+*/
+
+((): void => {
+ /**
+ * TYPE TEST SUITE
+ */
+
+ (function jsonTests(plain: Json): void {
+ // numbers are OK
+ plain = 1;
+ plain = NaN;
+ plain = Infinity;
+ plain = -Infinity;
+ plain = Math.pow(2, 6);
+ // other JSON primitive types are OK
+ plain = false;
+ plain = 'hello';
+ plain = null;
+ // structures made of above and of structures are OK
+ plain = {};
+ plain = [];
+ plain = { a: 1 };
+ plain = [0, null, false, NaN, 3.14, 'one more'];
+ plain = { a: { b: 5, c: { d: [1, 'a', -Infinity, null], e: -1 }, f: 'b' }, g: false };
+
+ // typings:expect-error
+ plain = undefined; // it's undefined
+ // typings:expect-error
+ plain = a => a; // it's a function
+ // typings:expect-error
+ plain = [new Date()]; // it's a time
+ // typings:expect-error
+ plain = { a: Symbol('haha') }; // symbol isn't permitted either
+ // typings:expect-error
+ plain = window || void 0;
+ // typings:expect-error
+ plain = { a: { b: 5, c: { d: [1, 'a', undefined, null] } } }; // going deep into the structure
+
+ return; // jsonTests
+ })(null);
+
+ (function selectTests(selector: Selector): void {
+ selector = select((a: Json) => a); // one arg
+ selector = select((a: Json, b: Json): Json => `${a} and ${b}`); // more args
+ selector = select(() => 1); // zero arg
+ selector = select((...args: Json[]) => args); // variadic
+
+ // typings:expect-error
+ selector = (a: Json) => a; // not a selector
+ // typings:expect-error
+ selector = select(() => {}); // should yield a JSON value, but it returns void
+ // typings:expect-error
+ selector = select((x: Json) => ({ a: x, b: undefined })); // should return a Json
+
+ return; // selectTests
+ })(select((a: Json) => a));
+
+ return; // test suite
+})();
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/dag_start.js b/x-pack/plugins/canvas/public/lib/aeroelastic/dag_start.js
new file mode 100644
index 0000000000000..2d1534416c00a
--- /dev/null
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/dag_start.js
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import { select } from './select';
+import { getPrimaryUpdate, getScene } from './layout_functions';
+
+export const state = d => d;
+/**
+ * Scenegraph update based on events, gestures...
+ */
+
+export const scene = select(getScene)(state);
+export const primaryUpdate = select(getPrimaryUpdate)(state);
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js b/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js
index b060713a21b52..0645099cc4318 100644
--- a/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/geometry.js
@@ -4,12 +4,44 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { invert, mvMultiply, normalize, ORIGIN } from './matrix';
+import {
+ componentProduct,
+ invert,
+ mvMultiply,
+ normalize,
+ ORIGIN,
+ RIGHT,
+ UP,
+ TOP_LEFT,
+ BOTTOM_LEFT,
+ TOP_RIGHT,
+ BOTTOM_RIGHT,
+} from './matrix';
+import { dotProduct } from './matrix2d';
/**
* Pure calculations with geometry awareness - a set of rectangles with known size (a, b) and projection (transform matrix)
*/
+const cornerScreenPositions = (transformMatrix, a, b) =>
+ // for unknown perf gain, this could be cached per shape
+ [TOP_LEFT, TOP_RIGHT, BOTTOM_RIGHT, BOTTOM_LEFT].map(corner =>
+ mvMultiply(transformMatrix, componentProduct(corner, [a, b, 0, 1]))
+ );
+
+export const insideAABB = ({ x, y, a, b }) => (transformMatrix, aa, bb) => {
+ const corners = cornerScreenPositions(transformMatrix, aa, bb);
+ // console.log('insideAABB x, y, a, b:', x, y, a, b, corners[0]);
+ const result = corners.every(([xx, yy]) => {
+ const res = x - a <= xx && xx <= x + a && y - b <= yy && yy <= y + b;
+ // console.log('Corner:', res, 'xx/yy:', xx, yy);
+ return res;
+ });
+ // console.log('insideAABB result:', result);
+ // console.log('\n');
+ return result;
+};
+
/**
*
* a * x0 + b * x1 = x
@@ -33,39 +65,60 @@ import { invert, mvMultiply, normalize, ORIGIN } from './matrix';
* b = (y - a * y0) / y1
*
*/
+
+const planeTuple = transformMatrix => {
+ // for unknown perf gain, this could be cached per shape
+ const centerPoint = normalize(mvMultiply(transformMatrix, ORIGIN));
+ const rightPoint = normalize(mvMultiply(transformMatrix, RIGHT));
+ const upPoint = normalize(mvMultiply(transformMatrix, UP));
+ const x0 = rightPoint[0] - centerPoint[0];
+ const y0 = rightPoint[1] - centerPoint[1];
+ const x1 = upPoint[0] - centerPoint[0];
+ const y1 = upPoint[1] - centerPoint[1];
+ const rightSlope = y1 ? rightPoint[2] - centerPoint[2] : 0; // handle degenerate case: y1 === 0 (infinite slope)
+ const upSlope = y1 ? upPoint[2] - centerPoint[2] : 0; // handle degenerate case: y1 === 0 (infinite slope)
+ const inverseProjection = invert(transformMatrix);
+ const A1 = 1 / (x0 - (y0 / y1) * x1);
+ const A2 = -((A1 * x1) / y1);
+ const A0 = -A1 * centerPoint[0] - A2 * centerPoint[1];
+ const invY1 = -1 / y1;
+ const z0 = centerPoint[2] + rightSlope * A0 + upSlope * invY1 * (centerPoint[1] + A0 * y0);
+ const zx = A1 * (rightSlope + upSlope * y0 * invY1);
+ const zy = -upSlope * invY1 + A2 * (rightSlope + upSlope * y0 * invY1);
+ const planeVector = [zx, zy, z0];
+ return { inverseProjection, planeVector };
+};
+
+const rectangleAtPoint = ({ transformMatrix, a, b }, x, y) => {
+ const { inverseProjection, planeVector } = planeTuple(transformMatrix);
+
+ // Determine z (depth) by composing the x, y vector out of local unit x and unit y vectors; by knowing the
+ // scalar multipliers for the unit x and unit y vectors, we can determine z from their respective 'slope' (gradient)
+ const screenNormalVector = [x, y, 1];
+ const z = dotProduct(planeVector, screenNormalVector);
+
+ // We go full tilt with the inverse transform approach because that's general enough to handle any non-pathological
+ // composition of transforms. Eg. this is a description of the idea: https://math.stackexchange.com/a/1685315
+ // Hmm maybe we should reuse the above right and up unit vectors to establish whether we're within the (a, b) 'radius'
+ // rather than using matrix inversion. Bound to be cheaper.
+
+ const intersection = normalize(mvMultiply(inverseProjection, [x, y, z, 1]));
+ const [sx, sy] = intersection;
+ const inside = Math.abs(sx) <= a && Math.abs(sy) <= b;
+
+ // z is needed downstream, to tell which one is the closest shape hit by an x, y ray (shapes can be tilted in z)
+ // it looks weird to even return items where inside === false, but it could be useful for hotspots outside the rectangle
+
+ return {
+ z,
+ intersection,
+ inside,
+ };
+};
+
// set of shapes under a specific point
const shapesAtPoint = (shapes, x, y) =>
- shapes.map((shape, index) => {
- const { transformMatrix, a, b } = shape;
-
- // Determine z (depth) by composing the x, y vector out of local unit x and unit y vectors; by knowing the
- // scalar multipliers for the unit x and unit y vectors, we can determine z from their respective 'slope' (gradient)
- const centerPoint = normalize(mvMultiply(transformMatrix, ORIGIN));
- const rightPoint = normalize(mvMultiply(transformMatrix, [1, 0, 0, 1]));
- const upPoint = normalize(mvMultiply(transformMatrix, [0, 1, 0, 1]));
- const x0 = rightPoint[0] - centerPoint[0];
- const y0 = rightPoint[1] - centerPoint[1];
- const x1 = upPoint[0] - centerPoint[0];
- const y1 = upPoint[1] - centerPoint[1];
- const A = (x - centerPoint[0] - ((y - centerPoint[1]) / y1) * x1) / (x0 - (y0 / y1) * x1);
- const B = (y - centerPoint[1] - A * y0) / y1;
- const rightSlope = rightPoint[2] - centerPoint[2];
- const upSlope = upPoint[2] - centerPoint[2];
- const z = centerPoint[2] + (y1 ? rightSlope * A + upSlope * B : 0); // handle degenerate case: y1 === 0 (infinite slope)
-
- // We go full tilt with the inverse transform approach because that's general enough to handle any non-pathological
- // composition of transforms. Eg. this is a description of the idea: https://math.stackexchange.com/a/1685315
- // Hmm maybe we should reuse the above right and up unit vectors to establish whether we're within the (a, b) 'radius'
- // rather than using matrix inversion. Bound to be cheaper.
-
- const inverseProjection = invert(transformMatrix);
- const intersection = normalize(mvMultiply(inverseProjection, [x, y, z, 1]));
- const [sx, sy] = intersection;
-
- // z is needed downstream, to tell which one is the closest shape hit by an x, y ray (shapes can be tilted in z)
- // it looks weird to even return items where inside === false, but it could be useful for hotspots outside the rectangle
- return { z, intersection, inside: Math.abs(sx) <= a && Math.abs(sy) <= b, shape, index };
- });
+ shapes.map((shape, index) => ({ ...rectangleAtPoint(shape, x, y), shape, index }));
// Z-order the possibly several shapes under the same point.
// Since CSS X points to the right, Y to the bottom (not the top!) and Z toward the viewer, it's a left-handed coordinate
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js
index 5a4b0285885cb..fcfb68e74fa61 100644
--- a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js
@@ -4,7 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { select, selectReduce } from './state';
+import { select } from './select';
+import { primaryUpdate, scene } from './dag_start';
// Only needed to shuffle some modifier keys for Apple keyboards as per vector editing software conventions,
// so it's OK that user agent strings are not reliable; in case it's spoofed, it'll just work with a slightly
@@ -18,13 +19,24 @@ const appleKeyboard = Boolean(
window.navigator.userAgent.match('Macintosh|iPhone|iPad')
);
-/**
- * Selectors directly from a state object
- *
- * (we could turn gesture.js into a factory, with this state root - primaryUpdate - being passed...)
- */
+const gestureStatePrev = select(
+ scene =>
+ scene.gestureState || {
+ cursor: {
+ x: 0,
+ y: 0,
+ },
+ mouseIsDown: false,
+ mouseButtonState: { buttonState: 'up', downX: null, downY: null },
+ }
+)(scene);
-const primaryUpdate = state => state.primaryUpdate;
+export const gestureEnd = select(
+ action =>
+ action &&
+ (action.type === 'actionEvent' ||
+ (action.type === 'mouseEvent' && action.payload.event === 'mouseUp'))
+)(primaryUpdate);
/**
* Gestures - derived selectors for transient state
@@ -47,37 +59,10 @@ export const metaHeld = select(appleKeyboard ? e => e.metaKey : e => e.altKey)(k
export const optionHeld = select(appleKeyboard ? e => e.altKey : e => e.ctrlKey)(keyFromMouse);
export const shiftHeld = select(e => e.shiftKey)(keyFromMouse);
-export const cursorPosition = selectReduce((previous, position) => position || previous, {
- x: 0,
- y: 0,
-})(rawCursorPosition);
-
-export const mouseButton = selectReduce(
- (prev, next) => {
- if (!next) {
- return prev;
- }
- const { event, uid } = next;
- if (event === 'mouseDown') {
- return { down: true, uid };
- } else {
- return event === 'mouseUp' ? { down: false, uid } : prev;
- }
- },
- { down: false, uid: null }
-)(mouseButtonEvent);
-
-export const mouseIsDown = selectReduce(
- (previous, next) => (next ? next.event === 'mouseDown' : previous),
- false
-)(mouseButtonEvent);
-
-export const gestureEnd = select(
- action =>
- action &&
- (action.type === 'actionEvent' ||
- (action.type === 'mouseEvent' && action.payload.event === 'mouseUp'))
-)(primaryUpdate);
+export const cursorPosition = select(({ cursor }, position) => position || cursor)(
+ gestureStatePrev,
+ rawCursorPosition
+);
/**
* mouseButtonStateTransitions
@@ -115,8 +100,24 @@ const mouseButtonStateTransitions = (state, mouseNowDown, movedAlready) => {
}
};
-const mouseButtonState = selectReduce(
- ({ buttonState, downX, downY }, mouseNowDown, { x, y }) => {
+export const mouseButton = select(next => {
+ if (!next) {
+ return { down: false, uid: null };
+ }
+ const { event, uid } = next;
+ if (event === 'mouseDown') {
+ return { down: true, uid };
+ } else {
+ return event === 'mouseUp' ? { down: false, uid } : { down: false, uid: null };
+ }
+})(mouseButtonEvent);
+
+export const mouseIsDown = select(({ mouseIsDown }, next) =>
+ next ? next.event === 'mouseDown' : mouseIsDown
+)(gestureStatePrev, mouseButtonEvent);
+
+const mouseButtonState = select(
+ ({ mouseButtonState: { buttonState, downX, downY } }, mouseNowDown, { x, y }) => {
const movedAlready = x !== downX || y !== downY;
const newButtonState = mouseButtonStateTransitions(buttonState, mouseNowDown, movedAlready);
return {
@@ -124,9 +125,8 @@ const mouseButtonState = selectReduce(
downX: newButtonState === 'downed' ? x : downX,
downY: newButtonState === 'downed' ? y : downY,
};
- },
- { buttonState: 'up', downX: null, downY: null }
-)(mouseIsDown, cursorPosition);
+ }
+)(gestureStatePrev, mouseIsDown, cursorPosition);
export const mouseDowned = select(state => state.buttonState === 'downed')(mouseButtonState);
@@ -143,3 +143,9 @@ export const dragVector = select(({ buttonState, downX, downY }, { x, y }) => ({
export const actionEvent = select(action =>
action.type === 'actionEvent' ? action.payload : null
)(primaryUpdate);
+
+export const gestureState = select((cursor, mouseIsDown, mouseButtonState) => ({
+ cursor,
+ mouseIsDown,
+ mouseButtonState,
+}))(cursorPosition, mouseIsDown, mouseButtonState);
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/types.ts b/x-pack/plugins/canvas/public/lib/aeroelastic/index.d.ts
similarity index 54%
rename from x-pack/plugins/canvas/public/lib/aeroelastic/types.ts
rename to x-pack/plugins/canvas/public/lib/aeroelastic/index.d.ts
index 2f58c9f12eeda..5ab3ef1c6a769 100644
--- a/x-pack/plugins/canvas/public/lib/aeroelastic/types.ts
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/index.d.ts
@@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
+// linear algebra
type f64 = number; // eventual AssemblyScript compatibility; doesn't hurt with vanilla TS either
type f = f64; // shorthand
@@ -15,16 +16,24 @@ export type transformMatrix2d = [f, f, f, f, f, f, f, f, f] &
export type transformMatrix3d = [f, f, f, f, f, f, f, f, f, f, f, f, f, f, f, f] &
ReadonlyArray & { __nominal: 'transformMatrix3d' };
-export interface Meta {
- silent: boolean;
+// plain, JSON-bijective value
+export type Json = JsonPrimitive | JsonArray | JsonMap;
+type JsonPrimitive = null | boolean | number | string;
+interface JsonArray extends Array {}
+interface JsonMap extends IMap {}
+interface IMap {
+ [key: string]: T;
}
+
+// state object
+export type State = Json & WithActionId;
export type ActionId = number;
-export type TypeName = string;
-export type NodeResult = any;
-export type Payload = any;
-export type NodeFunction = (...args: any[]) => any;
-export type UpdaterFunction = (arg: NodeResult) => NodeResult;
-export type ChangeCallbackFunction = (
- { type, state }: { type: TypeName; state: NodeResult },
- meta: Meta
-) => void;
+interface WithActionId {
+ primaryUpdate: { type: string; payload: { uid: ActionId; [propName: string]: Json } };
+ [propName: string]: Json; // allow other arbitrary props
+}
+
+// reselect-based data flow
+export type PlainFun = (...args: Json[]) => Json;
+export type Selector = (...fns: Resolve[]) => Resolve;
+type Resolve = ((obj: State) => Json);
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/index.js b/x-pack/plugins/canvas/public/lib/aeroelastic/index.js
deleted file mode 100644
index 132f4dcd23d75..0000000000000
--- a/x-pack/plugins/canvas/public/lib/aeroelastic/index.js
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { matrixToCSS } from './dom';
-import { nextScene } from './layout';
-import { primaryUpdate } from './layout_functions';
-import { multiply, rotateZ, translate } from './matrix';
-import { createStore, select } from './state';
-
-export const layout = { nextScene, primaryUpdate };
-export const matrix = { multiply, rotateZ, translate };
-export const state = { createStore, select };
-export const toCSS = matrixToCSS;
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js
index 011d066def20d..add77fd5f653f 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.
*/
-import { select } from './state';
+import { select } from './select';
import {
actionEvent,
@@ -12,6 +12,7 @@ import {
dragging,
dragVector,
gestureEnd,
+ gestureState,
metaHeld,
mouseButton,
mouseDowned,
@@ -23,15 +24,18 @@ import {
import {
applyLocalTransforms,
cascadeProperties,
- configuration,
draggingShape,
getAdHocChildrenAnnotations,
getAlignmentGuideAnnotations,
getAlterSnapGesture,
getAnnotatedShapes,
+ getConfiguration,
getConstrainedShapesWithPreexistingAnnotations,
getCursor,
getDirectSelect,
+ getDragBox,
+ getDragBoxAnnotation,
+ getDragBoxHovered,
getDraggedPrimaryShape,
getFocusedShape,
getGroupAction,
@@ -39,6 +43,7 @@ import {
getGroupedSelectedShapeIds,
getGroupedSelectedShapes,
getGrouping,
+ getGroupingTuple,
getHoverAnnotations,
getHoveredShape,
getHoveredShapes,
@@ -51,7 +56,6 @@ import {
getRestateShapesEvent,
getRotationAnnotations,
getRotationTooltipAnnotation,
- getScene,
getSelectedPrimaryShapeIds,
getSelectedShapeObjects,
getSelectedShapes,
@@ -60,21 +64,20 @@ import {
getShapes,
getSnappedShapes,
getTransformIntents,
- primaryUpdate,
resizeAnnotationsFunction,
} from './layout_functions';
-/**
- * Scenegraph update based on events, gestures...
- */
+import { primaryUpdate, scene } from './dag_start';
+
+export const shapes = select(getShapes)(scene);
-export const shapes = select(getShapes)(getScene);
+const configuration = select(getConfiguration)(scene);
const hoveredShapes = select(getHoveredShapes)(configuration, shapes, cursorPosition);
const hoveredShape = select(getHoveredShape)(hoveredShapes);
-const draggedShape = select(draggingShape)(getScene, hoveredShape, mouseIsDown, mouseDowned);
+const draggedShape = select(draggingShape)(scene, hoveredShape, mouseIsDown, mouseDowned);
export const focusedShape = select(getFocusedShape)(draggedShape, hoveredShape);
@@ -82,7 +85,7 @@ const alterSnapGesture = select(getAlterSnapGesture)(metaHeld);
const multiselectModifier = shiftHeld; // todo abstract out keybindings
-const mouseTransformGesturePrev = select(getMouseTransformGesturePrev)(getScene);
+const mouseTransformGesturePrev = select(getMouseTransformGesturePrev)(scene);
const mouseTransformState = select(getMouseTransformState)(
mouseTransformGesturePrev,
@@ -96,12 +99,16 @@ const transformGestures = mouseTransformGesture;
const restateShapesEvent = select(getRestateShapesEvent)(primaryUpdate);
+const dragBox = select(getDragBox)(dragging, draggedShape, dragVector);
+
// directSelect is an API entry point (via the `shapeSelect` action) that lets the client directly specify what thing
const directSelect = select(getDirectSelect)(primaryUpdate);
-const selectedShapeObjects = select(getSelectedShapeObjects)(getScene);
+const selectedShapeObjects = select(getSelectedShapeObjects)(scene);
+
+const selectedShapesPrev = select(getSelectedShapesPrev)(scene);
-const selectedShapesPrev = select(getSelectedShapesPrev)(getScene);
+const boxHovered = select(getDragBoxHovered)(dragBox, shapes);
const selectionState = select(getSelectionState)(
selectedShapesPrev,
@@ -112,6 +119,7 @@ const selectionState = select(getSelectionState)(
metaHeld,
multiselectModifier,
directSelect,
+ boxHovered,
shapes
);
@@ -150,7 +158,12 @@ const alignmentGuideAnnotations = select(getAlignmentGuideAnnotations)(
const hoverAnnotations = select(getHoverAnnotations)(
configuration,
- hoveredShape,
+ select((h, b) =>
+ h
+ .slice(0, 1)
+ .concat(b)
+ .filter((d, i, a) => a.indexOf(d) === i)
+ )(hoveredShapes, boxHovered),
selectedPrimaryShapeIds,
draggedShape
);
@@ -183,11 +196,18 @@ const rotationTooltipAnnotation = select(getRotationTooltipAnnotation)(
const groupAction = select(getGroupAction)(actionEvent);
+const groupingTuple = select(getGroupingTuple)(
+ configuration,
+ constrainedShapesWithPreexistingAnnotations,
+ selectedShapes
+);
+
const grouping = select(getGrouping)(
configuration,
constrainedShapesWithPreexistingAnnotations,
selectedShapes,
- groupAction
+ groupAction,
+ groupingTuple
);
const groupedSelectedShapes = select(getGroupedSelectedShapes)(grouping);
@@ -204,6 +224,8 @@ const resizeAnnotations = select(resizeAnnotationsFunction)(configuration, group
const rotationAnnotations = select(getRotationAnnotations)(configuration, grouping);
+const dragBoxAnnotation = select(getDragBoxAnnotation)(configuration, dragBox);
+
const annotatedShapes = select(getAnnotatedShapes)(
grouping,
alignmentGuideAnnotations,
@@ -211,7 +233,8 @@ const annotatedShapes = select(getAnnotatedShapes)(
rotationAnnotations,
resizeAnnotations,
rotationTooltipAnnotation,
- adHocChildrenAnnotations
+ adHocChildrenAnnotations,
+ dragBoxAnnotation
);
const globalTransformShapes = select(cascadeProperties)(annotatedShapes);
@@ -231,5 +254,6 @@ export const nextScene = select(getNextScene)(
cursor,
selectionState,
mouseTransformState,
- groupedSelectedShapes
+ groupedSelectedShapes,
+ gestureState
);
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js
index 3e2a030ff38da..a6268d99f1d60 100644
--- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js
@@ -4,8 +4,18 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { getId } from './../../lib/get_id';
-import { landmarkPoint, shapesAt } from './geometry';
+import { getId as rawGetId } from './../../lib/get_id';
+import { insideAABB, landmarkPoint, shapesAt } from './geometry';
+
+const idMap = {};
+const getId = (name, extension) => {
+ // the original getId function is impure; this wrapper acts like one
+ // (while it's possible for the same group to have the same members - ungroup then make the same group again -
+ // it's okay if the newly arising group gets the same id)
+ // Todo move this mapping out of the layout engine into the Canvas state layout integration
+ const key = name + '|' + extension;
+ return idMap[key] || (idMap[key] = rawGetId(name));
+};
import {
compositeComponent,
@@ -393,7 +403,6 @@ const shapeApplyLocalTransforms = intents => shape => {
const localTransformMatrix = cumulativeTransformIntents.length
? multiply(baselineLocalTransformMatrix, cumulativeTransformIntentMatrix)
: baselineLocalTransformMatrix;
-
const cumulativeSizeIntentMatrix = multiply2d(...cumulativeSizeIntents);
const sizeVector = mvMultiply2d(
cumulativeSizeIntents.length
@@ -926,7 +935,7 @@ const idsMatch = selectedShapes => shape => selectedShapes.find(idMatch(shape));
const axisAlignedBoundingBoxShape = (config, shapesToBox) => {
const axisAlignedBoundingBox = getAABB(shapesToBox);
const { a, b, localTransformMatrix, rigTransform } = projectAABB(axisAlignedBoundingBox);
- const id = getId(config.groupName);
+ const id = getId(config.groupName, shapesToBox.map(s => s.id).join('|'));
const aabbShape = {
id,
type: config.groupName,
@@ -1014,11 +1023,10 @@ const getLeafs = (descendCondition, allShapes, shapes) =>
const preserveCurrentGroups = (shapes, selectedShapes) => ({ shapes, selectedShapes });
+// todo move it outside layout_functions as gestures is using it too
export const getScene = state => state.currentScene;
-export const configuration = state => {
- return state.configuration;
-};
+export const getConfiguration = scene => scene.configuration;
export const getShapes = scene => scene.shapes;
@@ -1063,7 +1071,7 @@ const multiSelect = (prev, config, hoveredShapes, metaHeld, uid, selectedShapeOb
};
};
-export const getGrouping = (config, shapes, selectedShapes, groupAction) => {
+export const getGroupingTuple = (config, shapes, selectedShapes) => {
const childOfGroup = shape => shape.parent && shape.parent.startsWith(config.groupName);
const isAdHocGroup = shape =>
shape.type === config.groupName && shape.subtype === config.adHocGroupName;
@@ -1076,7 +1084,21 @@ export const getGrouping = (config, shapes, selectedShapes, groupAction) => {
const isOrBelongsToGroup = shape => isGroup(shape) || childOfGroup(shape);
const someSelectedShapesAreGrouped = selectedShapes.some(isOrBelongsToGroup);
const selectionOutsideGroup = !someSelectedShapesAreGrouped;
+ return {
+ selectionOutsideGroup,
+ freshSelectedShapes,
+ freshNonSelectedShapes,
+ preexistingAdHocGroups,
+ };
+};
+export const getGrouping = (config, shapes, selectedShapes, groupAction, tuple) => {
+ const {
+ selectionOutsideGroup,
+ freshSelectedShapes,
+ freshNonSelectedShapes,
+ preexistingAdHocGroups,
+ } = tuple;
if (groupAction === 'group') {
const selectedAdHocGroupsToPersist = selectedShapes.filter(
s => s.subtype === config.adHocGroupName
@@ -1183,7 +1205,7 @@ export const getCursor = (config, shape, draggedPrimaryShape) => {
/**
* Selectors directly from a state object
*/
-export const primaryUpdate = state => state.primaryUpdate;
+export const getPrimaryUpdate = state => state.primaryUpdate;
export const getSelectedShapesPrev = scene =>
scene.selectionState || {
@@ -1202,6 +1224,7 @@ export const getSelectionState = (
metaHeld,
multiselect,
directSelect,
+ boxSelected,
allShapes
) => {
const uidUnchanged = uid === prev.uid;
@@ -1306,14 +1329,16 @@ export const getAdHocChildrenAnnotations = (config, { shapes }) => {
.map(borderAnnotation(config.getAdHocChildAnnotationName, config.hoverLift));
};
-export const getHoverAnnotations = (config, shape, selectedPrimaryShapeIds, draggedShape) => {
- return shape &&
- shape.type !== 'annotation' &&
- selectedPrimaryShapeIds.indexOf(shape.id) === -1 &&
- !draggedShape
- ? [borderAnnotation(config.hoverAnnotationName, config.hoverLift)(shape)]
- : [];
-};
+export const getHoverAnnotations = (config, shapes, selectedPrimaryShapeIds, draggedShape) =>
+ shapes
+ .filter(
+ shape =>
+ shape &&
+ shape.type !== 'annotation' &&
+ selectedPrimaryShapeIds.indexOf(shape.id) === -1 &&
+ !draggedShape
+ )
+ .map(borderAnnotation(config.hoverAnnotationName, config.hoverLift));
export const getSnappedShapes = (
config,
@@ -1376,6 +1401,40 @@ export const getRotationAnnotations = (config, { shapes, selectedShapes }) => {
.filter(identity);
};
+export const getDragBox = (dragging, draggedShape, { x0, y0, x1, y1 }) =>
+ dragging &&
+ !draggedShape && {
+ x: (x0 + x1) / 2,
+ y: (y0 + y1) / 2,
+ a: Math.abs(x1 - x0) / 2,
+ b: Math.abs(y1 - y0) / 2,
+ };
+
+export const getDragBoxHovered = (box, shapes) => {
+ if (box) {
+ const inside = insideAABB(box);
+ return shapes.filter(s => s.type !== 'annotation' && inside(s.transformMatrix, s.a, s.b));
+ } else {
+ return [];
+ }
+};
+
+export const getDragBoxAnnotation = (config, box) =>
+ box
+ ? [
+ {
+ id: config.dragBoxAnnotationName,
+ type: 'annotation',
+ subtype: config.dragBoxAnnotationName,
+ interactive: false,
+ parent: null,
+ localTransformMatrix: translate(box.x, box.y, config.dragBoxZ),
+ a: box.a,
+ b: box.b,
+ },
+ ]
+ : [];
+
export const getAnnotatedShapes = (
{ shapes },
alignmentGuideAnnotations,
@@ -1383,7 +1442,8 @@ export const getAnnotatedShapes = (
rotationAnnotations,
resizeAnnotations,
rotationTooltipAnnotation,
- adHocChildrenAnnotations
+ adHocChildrenAnnotations,
+ dragBoxAnnotation
) => {
// fixme update it to a simple concatenator, no need for enlisting the now pretty long subtype list
const annotations = [].concat(
@@ -1392,7 +1452,8 @@ export const getAnnotatedShapes = (
rotationAnnotations,
resizeAnnotations,
rotationTooltipAnnotation,
- adHocChildrenAnnotations
+ adHocChildrenAnnotations,
+ dragBoxAnnotation
);
// remove preexisting annotations
const contentShapes = shapes.filter(shape => shape.type !== 'annotation');
@@ -1410,7 +1471,8 @@ export const getNextScene = (
cursor,
selectionState,
mouseTransformState,
- selectedShapes
+ selectedShapes,
+ gestureState
) => {
const selectedLeafShapes = getLeafs(
shape => shape.type === config.groupName,
@@ -1432,6 +1494,7 @@ export const getNextScene = (
draggedShape,
cursor,
selectionState,
+ gestureState,
mouseTransformState,
selectedShapeObjects: selectedShapes,
};
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.ts b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.ts
index 175bd27eef504..3dfac4f67e144 100644
--- a/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.ts
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix.ts
@@ -24,7 +24,7 @@
*
*/
-import { transformMatrix3d, vector3d } from './types';
+import { transformMatrix3d, vector3d } from '.';
const NANMATRIX = [
NaN,
@@ -46,6 +46,12 @@ const NANMATRIX = [
] as transformMatrix3d;
export const ORIGIN = [0, 0, 0, 1] as vector3d;
+export const RIGHT = [1, 0, 0, 1] as vector3d;
+export const UP = [0, 1, 0, 1] as vector3d;
+export const TOP_LEFT = [-1, 1, 0, 1] as vector3d;
+export const TOP_RIGHT = [1, 1, 0, 1] as vector3d;
+export const BOTTOM_LEFT = [-1, -1, 0, 1] as vector3d;
+export const BOTTOM_RIGHT = [1, -1, 0, 1] as vector3d;
export const translate = (x: number, y: number, z: number): transformMatrix3d =>
[1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1] as transformMatrix3d;
@@ -269,6 +275,9 @@ export const subtract = (
p - P,
] as transformMatrix3d;
+export const componentProduct = ([a, b, c, d]: vector3d, [A, B, C, D]: vector3d): vector3d =>
+ [a * A, b * B, c * C, d * D] as vector3d;
+
export const reduceTransforms = (transforms: transformMatrix3d[]): transformMatrix3d =>
transforms.length === 1
? transforms[0]
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/matrix2d.ts b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix2d.ts
index bd23c5c3c6d68..804dc95d4d897 100644
--- a/x-pack/plugins/canvas/public/lib/aeroelastic/matrix2d.ts
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/matrix2d.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { transformMatrix2d, vector2d } from './types';
+import { transformMatrix2d, vector2d } from '.';
export const ORIGIN = [0, 0, 1] as vector2d;
@@ -91,3 +91,6 @@ export const subtract = (
export const componentProduct = ([a, b, c]: vector2d, [A, B, C]: vector2d): vector2d =>
[a * A, b * B, c * C] as vector2d;
+
+export const dotProduct = ([a, b, c]: vector2d, [A, B, C]: vector2d): number =>
+ a * A + b * B + c * C;
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/select.ts b/x-pack/plugins/canvas/public/lib/aeroelastic/select.ts
new file mode 100644
index 0000000000000..a2a236a4310b5
--- /dev/null
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/select.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License;
+ * you may not use this file except in compliance with the Elastic License.
+ */
+
+import {ActionId, Json, PlainFun, Selector, State} from '.';
+
+// any arbitrary pure logic function, eg. in layout_functions.js
+
+export const select = (fun: PlainFun): Selector => (...fns) => {
+ let { prevId, cache } = { prevId: NaN as ActionId, cache: null as Json };
+ const old = (object: State): boolean => prevId === (prevId = object.primaryUpdate.payload.uid);
+ return obj => (old(obj) ? cache : (cache = fun(...fns.map(f => f(obj) as Json))));
+};
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/state.ts b/x-pack/plugins/canvas/public/lib/aeroelastic/state.ts
deleted file mode 100644
index d1d6bc87847e9..0000000000000
--- a/x-pack/plugins/canvas/public/lib/aeroelastic/state.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import {
- ActionId,
- ChangeCallbackFunction,
- Meta,
- NodeFunction,
- NodeResult,
- Payload,
- TypeName,
- UpdaterFunction,
-} from './types';
-
-export const shallowEqual = (a: any, b: any): boolean => {
- 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 makeUid = (): ActionId => 1e11 + Math.floor((1e12 - 1e11) * Math.random());
-
-export const selectReduce = (fun: NodeFunction, previousValue: NodeResult): NodeFunction => (
- ...inputs: NodeFunction[]
-): NodeResult => {
- // last-value memoizing version of this single line function:
- // (fun, previousValue) => (...inputs) => state => previousValue = fun(previousValue, ...inputs.map(input => input(state)))
- let argumentValues = [] as NodeResult[];
- let value = previousValue;
- let prevValue = previousValue;
- return (state: NodeResult) => {
- if (
- shallowEqual(argumentValues, (argumentValues = inputs.map(input => input(state)))) &&
- value === prevValue
- ) {
- return value;
- }
-
- prevValue = value;
- value = fun(prevValue, ...argumentValues);
- return value;
- };
-};
-
-export const select = (fun: NodeFunction): NodeFunction => (
- ...inputs: NodeFunction[]
-): NodeResult => {
- // last-value memoizing version of this single line function:
- // fun => (...inputs) => state => fun(...inputs.map(input => input(state)))
- let argumentValues = [] as NodeResult[];
- let value: NodeResult;
- let actionId: ActionId;
- return (state: NodeResult) => {
- const lastActionId: ActionId = state.primaryUpdate.payload.uid;
- if (
- actionId === lastActionId ||
- shallowEqual(argumentValues, (argumentValues = inputs.map(input => input(state))))
- ) {
- return value;
- }
-
- value = fun(...argumentValues);
- actionId = lastActionId;
- return value;
- };
-};
-
-export const createStore = (initialState: NodeResult, onChangeCallback: ChangeCallbackFunction) => {
- let currentState = initialState;
- let updater: UpdaterFunction = (state: NodeResult): NodeResult => state; // default: no side effect
- const getCurrentState = () => currentState;
- // const setCurrentState = newState => (currentState = newState);
- const setUpdater = (updaterFunction: UpdaterFunction) => {
- updater = updaterFunction;
- };
-
- const commit = (type: TypeName, payload: Payload, meta: Meta = { silent: false }) => {
- currentState = updater({
- ...currentState,
- primaryUpdate: {
- type,
- payload: { ...payload, uid: makeUid() },
- },
- });
- if (!meta.silent) {
- onChangeCallback({ type, state: currentState }, meta);
- }
- };
-
- const dispatch = (type: TypeName, payload: Payload) => commit(type, payload);
-
- return { getCurrentState, setUpdater, commit, dispatch };
-};
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json b/x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json
new file mode 100644
index 0000000000000..31d338e9ada2b
--- /dev/null
+++ b/x-pack/plugins/canvas/public/lib/aeroelastic/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "extends": "../../../../../tsconfig",
+ "compilerOptions": {
+ "module": "commonjs",
+ "lib": ["esnext", "dom"],
+ "noImplicitAny": true,
+ "strictNullChecks": true,
+ "strictFunctionTypes": false,
+ "strictPropertyInitialization": true,
+ "noImplicitThis": true,
+ "noImplicitReturns": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "layout/*": ["aeroelastic/*"]
+ }
+ }
+}
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js b/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js
deleted file mode 100644
index 54ffd65db0d04..0000000000000
--- a/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
- * or more contributor license agreements. Licensed under the Elastic License;
- * you may not use this file except in compliance with the Elastic License.
- */
-
-import { layout, matrix, state } from './aeroelastic';
-
-const stores = new Map();
-
-export const aeroelastic = {
- matrix,
-
- clearStores() {
- stores.clear();
- },
-
- createStore(initialState, onChangeCallback = () => {}, page) {
- if (stores.has(page)) {
- throw new Error('Only a single aeroelastic store per page should exist');
- }
-
- stores.set(page, state.createStore(initialState, onChangeCallback));
-
- const updateScene = state.select((nextScene, primaryUpdate) => ({
- shapeAdditions: nextScene.shapes,
- primaryUpdate,
- currentScene: nextScene,
- configuration: nextScene.configuration,
- }))(layout.nextScene, layout.primaryUpdate);
-
- stores.get(page).setUpdater(updateScene);
- },
-
- removeStore(page) {
- if (stores.has(page)) {
- stores.delete(page);
- }
- },
-
- getStore(page) {
- const store = stores.get(page);
- if (!store) {
- throw new Error('An aeroelastic store should exist for page ' + page);
- }
-
- return store.getCurrentState();
- },
-
- commit(page, ...args) {
- const store = stores.get(page);
- return store && store.commit(...args);
- },
-};
diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/dom.ts b/x-pack/plugins/canvas/public/lib/dom.ts
similarity index 92%
rename from x-pack/plugins/canvas/public/lib/aeroelastic/dom.ts
rename to x-pack/plugins/canvas/public/lib/dom.ts
index 5bd7325fa6808..d6f6fa9a8fbb9 100644
--- a/x-pack/plugins/canvas/public/lib/aeroelastic/dom.ts
+++ b/x-pack/plugins/canvas/public/lib/dom.ts
@@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
-import { transformMatrix3d } from './types';
+import { transformMatrix3d } from './aeroelastic';
// converts a transform matrix to a CSS string
export const matrixToCSS = (transformMatrix: transformMatrix3d): string =>
diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js
index fe88a21341301..547af29c62cd6 100644
--- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js
+++ b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js
@@ -5,9 +5,7 @@
*/
import { shallowEqual } from 'recompose';
-import { aeroelastic as aero } from '../../lib/aeroelastic_kibana';
import { matrixToAngle } from '../../lib/aeroelastic/matrix';
-import { arrayToMap, identity } from '../../lib/aeroelastic/functional';
import {
addElement,
removeElements,
@@ -23,87 +21,8 @@ import { appReady } from '../actions/app';
import { setWorkpad } from '../actions/workpad';
import { getNodes, getPages, getSelectedPage, getSelectedElement } from '../selectors/workpad';
-const aeroelasticConfiguration = {
- getAdHocChildAnnotationName: 'adHocChildAnnotation',
- adHocGroupName: 'adHocGroup',
- alignmentGuideName: 'alignmentGuide',
- atopZ: 1000,
- depthSelect: true,
- devColor: 'magenta',
- groupName: 'group',
- groupResize: true,
- guideDistance: 3,
- hoverAnnotationName: 'hoverAnnotation',
- hoverLift: 100,
- intraGroupManipulation: false,
- intraGroupSnapOnly: false,
- minimumElementSize: 0,
- persistentGroupName: 'persistentGroup',
- resizeAnnotationConnectorOffset: 0,
- resizeAnnotationOffset: 0,
- resizeAnnotationOffsetZ: 0.1, // causes resize markers to be slightly above the shape plane
- resizeAnnotationSize: 10,
- resizeConnectorName: 'resizeConnector',
- resizeHandleName: 'resizeHandle',
- rotateAnnotationOffset: 12,
- rotateSnapInPixels: 10,
- rotationEpsilon: 0.001,
- rotationHandleName: 'rotationHandle',
- rotationHandleSize: 14,
- rotationTooltipName: 'rotationTooltip',
- shortcuts: false,
- singleSelect: false,
- snapConstraint: true,
- tooltipZ: 1100,
-};
+const isGroupId = id => id.startsWith('group');
-const isGroupId = id => id.startsWith(aeroelasticConfiguration.groupName);
-
-/**
- * elementToShape
- *
- * converts a `kibana-canvas` element to an `aeroelastic` shape.
- *
- * Shape: the layout algorithms need to deal with objects through their geometric properties, excluding other aspects,
- * such as what's inside the element, eg. image or scatter plot. This representation is, at its core, a transform matrix
- * that establishes a new local coordinate system https://drafts.csswg.org/css-transforms/#local-coordinate-system plus a
- * size descriptor. There are two versions of the transform matrix:
- * - `transformMatrix` is analogous to the SVG https://drafts.csswg.org/css-transforms/#current-transformation-matrix
- * - `localTransformMatrix` is analogous to the SVG https://drafts.csswg.org/css-transforms/#transformation-matrix
- *
- * Element: it also needs to represent the geometry, primarily because of the need to persist it in `redux` and on the
- * server, and to accept such data from the server. The redux and server representations will need to change as more general
- * projections such as 3D are added. The element also needs to maintain its content, such as an image or a plot.
- *
- * While all elements on the current page also exist as shapes, there are shapes that are not elements: annotations.
- * For example, `rotation_handle`, `border_resize_handle` and `border_connection` are modeled as shapes by the layout
- * library, simply for generality.
- */
-const elementToShape = (element, i) => {
- const position = element.position;
- const a = position.width / 2;
- const b = position.height / 2;
- const cx = position.left + a;
- const cy = position.top + b;
- const z = i; // painter's algo: latest item goes to top
- // multiplying the angle with -1 as `transform: matrix3d` uses a left-handed coordinate system
- const angleRadians = (-position.angle / 180) * Math.PI;
- const transformMatrix = aero.matrix.multiply(
- aero.matrix.translate(cx, cy, z),
- aero.matrix.rotateZ(angleRadians)
- );
- const isGroup = isGroupId(element.id);
- const parent = (element.position && element.position.parent) || null; // reserved for hierarchical (tree shaped) grouping
- return {
- id: element.id,
- type: isGroup ? 'group' : 'rectangleElement',
- subtype: isGroup ? 'persistentGroup' : '',
- parent,
- transformMatrix,
- a, // we currently specify half-width, half-height as it leads to
- b, // more regular math (like ellipsis radii rather than diameters)
- };
-};
const shapeToElement = shape => {
return {
@@ -158,23 +77,6 @@ const updateGlobalPositions = (setMultiplePositions, { shapes, gestureEnd }, uns
};
const id = element => element.id;
-// check for duplication
-const deduped = a => a.filter((d, i) => a.indexOf(d) === i);
-const idDuplicateCheck = groups => {
- if (deduped(groups.map(g => g.id)).length !== groups.length) {
- throw new Error('Duplicate element encountered');
- }
-};
-
-const missingParentCheck = groups => {
- const idMap = arrayToMap(groups.map(g => g.id));
- groups.forEach(g => {
- if (g.parent && !idMap[g.parent]) {
- g.parent = null;
- }
- });
-};
-
export const aeroelastic = ({ dispatch, getState }) => {
// When aeroelastic updates an element, we need to dispatch actions to notify redux of the changes
@@ -259,10 +161,8 @@ export const aeroelastic = ({ dispatch, getState }) => {
const createStore = page =>
aero.createStore(
{
- shapeAdditions: [],
primaryUpdate: null,
- currentScene: { shapes: [] },
- configuration: aeroelasticConfiguration,
+ currentScene: { shapes: [], configuration: aeroelasticConfiguration },
},
onChangeCallback,
page
diff --git a/x-pack/plugins/canvas/public/state/middleware/index.js b/x-pack/plugins/canvas/public/state/middleware/index.js
index fe842df379948..96389dbad57eb 100644
--- a/x-pack/plugins/canvas/public/state/middleware/index.js
+++ b/x-pack/plugins/canvas/public/state/middleware/index.js
@@ -7,7 +7,6 @@
import { applyMiddleware, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { getWindow } from '../../lib/get_window';
-import { aeroelastic } from './aeroelastic';
import { breadcrumbs } from './breadcrumbs';
import { esPersistMiddleware } from './es_persist';
import { fullscreen } from './fullscreen';
@@ -22,7 +21,6 @@ const middlewares = [
thunkMiddleware,
esPersistMiddleware,
historyMiddleware,
- aeroelastic,
breadcrumbs,
fullscreen,
inFlight,
diff --git a/x-pack/plugins/canvas/public/style/index.scss b/x-pack/plugins/canvas/public/style/index.scss
index fa1638b82c00a..db33e08db994a 100644
--- a/x-pack/plugins/canvas/public/style/index.scss
+++ b/x-pack/plugins/canvas/public/style/index.scss
@@ -28,6 +28,7 @@
@import '../components/datatable/datatable';
@import '../components/debug/debug';
@import '../components/dom_preview/dom_preview';
+@import '../components/dragbox_annotation/dragbox_annotation';
@import '../components/element_content/element_content';
@import '../components/element_types/element_types';
@import '../components/fullscreen/fullscreen';
diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json
index a724e008f3c29..1ec9d7f76af88 100644
--- a/x-pack/tsconfig.json
+++ b/x-pack/tsconfig.json
@@ -9,7 +9,8 @@
"typings/**/*"
],
"exclude": [
- "test/**/*"
+ "test/**/*",
+ "**/typespec_tests.ts"
],
"compilerOptions": {
"paths": {
diff --git a/yarn.lock b/yarn.lock
index 4f48e45986642..21c1e9295351e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5740,7 +5740,7 @@ commander@0.6.1:
resolved "https://registry.yarnpkg.com/commander/-/commander-0.6.1.tgz#fa68a14f6a945d54dbbe50d8cdb3320e9e3b1a06"
integrity sha1-+mihT2qUXVTbvlDYzbMyDp47GgY=
-commander@2, commander@2.19.0, commander@^2.11.0, commander@^2.12.1, commander@^2.8.1, commander@^2.9.0:
+commander@2, commander@2.19.0, commander@^2.11.0, commander@^2.12.1, commander@^2.12.2, commander@^2.8.1, commander@^2.9.0:
version "2.19.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
@@ -21723,6 +21723,13 @@ typescript@^3.0.3:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.3.tgz#4853b3e275ecdaa27f78fda46dc273a7eb7fc1c8"
integrity sha512-kk80vLW9iGtjMnIv11qyxLqZm20UklzuR2tL0QAnDIygIUIemcZMxlMWudl9OOt76H3ntVzcTiddQ1/pAAJMYg==
+typings-tester@^0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/typings-tester/-/typings-tester-0.3.2.tgz#04cc499d15ab1d8b2d14dd48415a13d01333bc5b"
+ integrity sha512-HjGoAM2UoGhmSKKy23TYEKkxlphdJFdix5VvqWFLzH1BJVnnwG38tpC6SXPgqhfFGfHY77RlN1K8ts0dbWBQ7A==
+ dependencies:
+ commander "^2.12.2"
+
ua-parser-js@^0.7.18, ua-parser-js@^0.7.9:
version "0.7.18"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed"