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"