diff --git a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/index.js b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/index.js index cbfa41f8d167..3bd545bead8f 100644 --- a/x-pack/plugins/canvas/public/apps/workpad/workpad_app/index.js +++ b/x-pack/plugins/canvas/public/apps/workpad/workpad_app/index.js @@ -6,7 +6,7 @@ import { connect } from 'react-redux'; import { compose, branch, renderComponent } from 'recompose'; -import { selectElement } from '../../../state/actions/transient'; +import { selectToplevelNodes } from '../../../state/actions/transient'; import { canUserWrite, getAppReady } from '../../../state/selectors/app'; import { getWorkpad, isWriteable } from '../../../state/selectors/workpad'; import { LoadWorkpad } from './load_workpad'; @@ -25,7 +25,7 @@ const mapStateToProps = state => { const mapDispatchToProps = dispatch => ({ deselectElement(ev) { ev && ev.stopPropagation(); - dispatch(selectElement(null)); + dispatch(selectToplevelNodes([])); }, }); diff --git a/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js index 3b6c76b9935f..51e24c98036f 100644 --- a/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js +++ b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js @@ -37,9 +37,12 @@ export class DomPreview extends React.Component { return; } - if (!this.observer) { - this.original = this.original || document.querySelector(`#${this.props.elementId}`); - if (this.original) { + const currentOriginal = document.querySelector(`#${this.props.elementId}`); + const originalChanged = currentOriginal !== this.original; + if (originalChanged) { + this.observer && this.observer.disconnect(); + this.original = currentOriginal; + if (currentOriginal) { const slowUpdate = debounce(this.update, 100); this.observer = new MutationObserver(slowUpdate); // configuration of the observer diff --git a/x-pack/plugins/canvas/public/components/fullscreen_control/index.js b/x-pack/plugins/canvas/public/components/fullscreen_control/index.js index 2daaaf042534..425412b939f5 100644 --- a/x-pack/plugins/canvas/public/components/fullscreen_control/index.js +++ b/x-pack/plugins/canvas/public/components/fullscreen_control/index.js @@ -5,7 +5,7 @@ */ import { connect } from 'react-redux'; -import { setFullscreen, selectElement } from '../../state/actions/transient'; +import { setFullscreen, selectToplevelNodes } from '../../state/actions/transient'; import { getFullscreen } from '../../state/selectors/app'; import { FullscreenControl as Component } from './fullscreen_control'; @@ -16,7 +16,7 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ setFullscreen: value => { dispatch(setFullscreen(value)); - value && dispatch(selectElement(null)); + value && dispatch(selectToplevelNodes([])); }, }); diff --git a/x-pack/plugins/canvas/public/components/sidebar/index.js b/x-pack/plugins/canvas/public/components/sidebar/index.js index 8fe520f97121..65204b62c379 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/index.js +++ b/x-pack/plugins/canvas/public/components/sidebar/index.js @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { cloneSubgraphs } from '../../lib/clone_subgraphs'; import { insertNodes, elementLayer } from '../../state/actions/elements'; import { getSelectedPage, getSelectedElement } from '../../state/selectors/workpad'; -import { selectElement } from './../../state/actions/transient'; +import { selectToplevelNodes } from '../../state/actions/transient'; import { Sidebar as Component } from './sidebar'; @@ -23,7 +23,7 @@ const mapDispatchToProps = dispatch => ({ // todo: more unification w/ copy/paste; group cloning const newElements = cloneSubgraphs([selectedElement]); dispatch(insertNodes(newElements, pageId)); - dispatch(selectElement(newElements[0].id)); + dispatch(selectToplevelNodes(newElements.map(e => e.id))); }, elementLayer: (pageId, selectedElement) => movement => dispatch( diff --git a/x-pack/plugins/canvas/public/components/workpad/workpad.js b/x-pack/plugins/canvas/public/components/workpad/workpad.js index 956d8a0dbdb6..6608538cdeed 100644 --- a/x-pack/plugins/canvas/public/components/workpad/workpad.js +++ b/x-pack/plugins/canvas/public/components/workpad/workpad.js @@ -131,7 +131,7 @@ export class Workpad extends React.PureComponent { {pages.map((page, i) => ( { const handleMouseMove = ( commit, { clientX, clientY, altKey, metaKey, shiftKey, ctrlKey }, - isEditable, canvasOrigin ) => { - if (isEditable) { - const { x, y } = localMousePosition(canvasOrigin, clientX, clientY); + const { x, y } = localMousePosition(canvasOrigin, clientX, clientY); + if (commit) { commit('cursorPosition', { x, y, altKey, metaKey, shiftKey, ctrlKey }); } }; const handleMouseLeave = (commit, { buttons }) => { - if (buttons !== 1) { + if (buttons !== 1 && commit) { commit('cursorPosition', {}); // reset hover only if we're not holding down left key (ie. drag in progress) } }; -const handleMouseDown = (commit, e, isEditable, canvasOrigin) => { +const handleMouseDown = (commit, e, canvasOrigin) => { e.stopPropagation(); const { clientX, clientY, buttons, altKey, metaKey, shiftKey, ctrlKey } = e; - if (buttons !== 1 || !isEditable) { + if (buttons !== 1 || !commit) { resetHandler(); - return; // left-click and edit mode only + return; // left-click only } setupHandler(commit, canvasOrigin); const { x, y } = localMousePosition(canvasOrigin, clientX, clientY); @@ -72,9 +71,9 @@ const handleMouseDown = (commit, e, isEditable, canvasOrigin) => { }; export const eventHandlers = { - onMouseDown: props => e => handleMouseDown(props.commit, e, props.isEditable, props.canvasOrigin), - onMouseMove: props => e => handleMouseMove(props.commit, e, props.isEditable, props.canvasOrigin), + onMouseDown: props => e => handleMouseDown(props.commit, e, props.canvasOrigin), + onMouseMove: props => e => handleMouseMove(props.commit, e, props.canvasOrigin), onMouseLeave: props => e => handleMouseLeave(props.commit, e), - onWheel: props => e => handleMouseMove(props.commit, e, props.isEditable, props.canvasOrigin), + onWheel: props => e => handleMouseMove(props.commit, e, props.canvasOrigin), resetHandler: () => () => resetHandler(), }; 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 687c3de4b079..5a22aef7a713 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_page/index.js @@ -4,53 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ +import isEqual from 'react-fast-compare'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { compose, withState, withProps, withHandlers } from 'recompose'; -import { aeroelastic } from '../../lib/aeroelastic_kibana'; -import { removeElements, insertNodes, elementLayer } from '../../state/actions/elements'; -import { getFullscreen, canUserWrite } from '../../state/selectors/app'; -import { getNodes, isWriteable } from '../../state/selectors/workpad'; -import { flatten } from '../../lib/aeroelastic/functional'; +import { branch, compose, withHandlers, withProps, withState, shouldUpdate } from 'recompose'; +import { elementLayer, insertNodes, removeElements } from '../../state/actions/elements'; +import { canUserWrite, getFullscreen } from '../../state/selectors/app'; +import { getNodes, getPageById, isWriteable } from '../../state/selectors/workpad'; +import { updater } from '../../lib/aeroelastic/layout'; +import { createStore } from '../../lib/aeroelastic/store'; +import { not, flatten } from '../../lib/aeroelastic/functional'; +import { elementToShape, globalStateUpdater, crawlTree, shapesForNodes } from './integration_utils'; import { eventHandlers } from './event_handlers'; -import { WorkpadPage as Component } from './workpad_page'; -import { selectElement } from './../../state/actions/transient'; +import { InteractiveWorkpadPage as InteractiveComponent } from './interactive_workpad_page'; +import { StaticWorkpadPage as StaticComponent } from './static_workpad_page'; +import { selectToplevelNodes } from './../../state/actions/transient'; -const mapStateToProps = (state, ownProps) => { - return { - isEditable: !getFullscreen(state) && isWriteable(state) && canUserWrite(state), - elements: getNodes(state, ownProps.page.id), - }; -}; - -const mapDispatchToProps = dispatch => { - 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, - }) - ); - }, - }; -}; - -// eslint-disable-next-line -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; +const configuration = { + 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: 2, + 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 animationProps = ({ isSelected, animation }) => { @@ -78,84 +79,156 @@ const animationProps = ({ isSelected, animation }) => { }; }; -const layoutProps = ({ forceUpdate, page, elements: pageElements }) => { - 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) - ), - ]; - }; - - const selectedPrimaryShapeObjects = selectedPrimaryShapes - .map(id => shapes.find(s => s.id === id)) - .filter(shape => shape); - - 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, expression: element.expression } : shape; - }); - return { - elements, - cursor, - selectedElementIds, - selectedElements, - selectedPrimaryShapes, - commit: (...args) => { - aeroelastic.commit(page.id, ...args); - forceUpdate(); - }, - }; -}; +const simplePositioning = ({ elements }) => ({ + elements: elements.map((element, i) => { + const { type, subtype, transformMatrix } = elementToShape(element, i); + return { + id: element.id, + filter: element.filter, + width: element.position.width, + height: element.position.height, + type, + subtype, + transformMatrix, + }; + }), +}); const groupHandlerCreators = { - groupElements: ({ commit }) => () => - commit('actionEvent', { - event: 'group', - }), - ungroupElements: ({ commit }) => () => - commit('actionEvent', { - event: 'ungroup', - }), + groupNodes: ({ commit }) => () => commit('actionEvent', { event: 'group' }), + ungroupNodes: ({ commit }) => () => commit('actionEvent', { event: 'ungroup' }), }; +const StaticPage = compose( + withProps(simplePositioning), + () => StaticComponent +); + +const mapStateToProps = (state, ownProps) => { + const selectedToplevelNodes = state.transient.selectedToplevelNodes; + const nodes = getNodes(state, ownProps.pageId); + const selectedPrimaryShapeObjects = selectedToplevelNodes + .map(id => nodes.find(s => s.id === id)) + .filter(shape => shape); + const selectedPersistentPrimaryNodes = flatten( + selectedPrimaryShapeObjects.map(shape => + nodes.find(n => n.id === shape.id) // is it a leaf or a persisted group? + ? [shape.id] + : nodes.filter(s => s.parent === shape.id).map(s => s.id) + ) + ); + const selectedNodeIds = flatten(selectedPersistentPrimaryNodes.map(crawlTree(nodes))); + return { + state, + isEditable: !getFullscreen(state) && isWriteable(state) && canUserWrite(state), + elements: nodes, + selectedToplevelNodes, + selectedNodes: selectedNodeIds.map(id => nodes.find(s => s.id === id)), + pageStyle: getPageById(state, ownProps.pageId).style, + }; +}; + +const mapDispatchToProps = dispatch => ({ + dispatch, + insertNodes: pageId => selectedNodes => dispatch(insertNodes(selectedNodes, pageId)), + removeNodes: pageId => nodeIds => dispatch(removeElements(nodeIds, pageId)), + selectToplevelNodes: nodes => + dispatch(selectToplevelNodes(nodes.filter(e => !e.position.parent).map(e => e.id))), + // TODO: Abstract this out, this is similar to layering code in sidebar/index.js: + elementLayer: (pageId, selectedElement, movement) => { + dispatch(elementLayer({ pageId, elementId: selectedElement.id, movement })); + }, +}); + +const mergeProps = ( + { state, isEditable, elements, ...restStateProps }, + { dispatch, ...restDispatchProps }, + { isSelected, ...remainingOwnProps } +) => + isEditable && isSelected + ? { + elements, + isInteractive: true, + isSelected, + ...remainingOwnProps, + ...restDispatchProps, + ...restStateProps, + updateGlobalState: globalStateUpdater(dispatch, () => state), + } + : { elements, isSelected, isInteractive: false, ...remainingOwnProps }; + +const componentLayoutState = ({ aeroStore, setAeroStore, elements, selectedToplevelNodes }) => { + const shapes = shapesForNodes(elements); + const selectedShapes = selectedToplevelNodes.filter(e => shapes.find(s => s.id === e)); + const newState = { + primaryUpdate: null, + currentScene: { + shapes, + configuration, + selectedShapes, + selectionState: aeroStore + ? aeroStore.getCurrentState().currentScene.selectionState + : { uid: 0, depthIndex: 0, down: false }, + gestureState: aeroStore + ? aeroStore.getCurrentState().currentScene.gestureState + : { + cursor: { x: 0, y: 0 }, + mouseIsDown: false, + mouseButtonState: { buttonState: 'up', downX: null, downY: null }, + }, + }, + }; + if (aeroStore) { + aeroStore.setCurrentState(newState); + } else { + setAeroStore((aeroStore = createStore(newState, updater))); + } + return { aeroStore }; +}; + +const InteractivePage = compose( + withState('aeroStore', 'setAeroStore'), + withProps(componentLayoutState), + withProps(({ aeroStore, updateGlobalState }) => ({ + commit: (type, payload) => { + const newLayoutState = aeroStore.commit(type, payload); + if (newLayoutState.currentScene.gestureEnd) { + updateGlobalState(newLayoutState); + } + }, + })), + withState('canvasOrigin', 'saveCanvasOrigin'), + withState('_forceRerender', 'forceRerender'), + withProps(({ aeroStore }) => ({ cursor: aeroStore.getCurrentState().currentScene.cursor })), + withProps(({ aeroStore, elements }) => { + const elementLookup = new Map(elements.map(element => [element.id, element])); + const elementsToRender = aeroStore.getCurrentState().currentScene.shapes.map(shape => { + const element = elementLookup.get(shape.id); + return element + ? { ...shape, width: shape.a * 2, height: shape.b * 2, filter: element.filter } + : shape; + }); + return { elements: elementsToRender }; + }), + withProps(({ commit, forceRerender }) => ({ + commit: (...args) => forceRerender(commit(...args)), + })), + withHandlers(groupHandlerCreators), + withHandlers(eventHandlers), // Captures user intent, needs to have reconciled state + () => InteractiveComponent +); + export const WorkpadPage = compose( + shouldUpdate(not(isEqual)), // this is critical, else random unrelated rerenders in the parent cause glitches here + withProps(animationProps), connect( mapStateToProps, - mapDispatchToProps + mapDispatchToProps, + mergeProps ), - withProps(animationProps), - withState('_forceUpdate', 'forceUpdate'), // TODO: phase out this solution - withState('canvasOrigin', 'saveCanvasOrigin'), - withProps(layoutProps), // Updates states; needs to have both local and global - withHandlers(groupHandlerCreators), - withHandlers(eventHandlers) // Captures user intent, needs to have reconciled state -)(Component); + branch(({ isInteractive }) => isInteractive, InteractivePage, StaticPage) +)(); WorkpadPage.propTypes = { - page: PropTypes.shape({ - id: PropTypes.string.isRequired, - }).isRequired, + pageId: PropTypes.string.isRequired, }; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/integration_utils.js b/x-pack/plugins/canvas/public/components/workpad_page/integration_utils.js new file mode 100644 index 000000000000..8237d1778ebb --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_page/integration_utils.js @@ -0,0 +1,208 @@ +/* + * 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 { getNodes, getSelectedPage } from '../../state/selectors/workpad'; +import { addElement, removeElements, setMultiplePositions } from '../../state/actions/elements'; +import { selectToplevelNodes } from '../../state/actions/transient'; +import { matrixToAngle, multiply, rotateZ, translate } from '../../lib/aeroelastic/matrix'; +import { arrayToMap, flatten, identity } from '../../lib/aeroelastic/functional'; +import { getLocalTransformMatrix } from '../../lib/aeroelastic/layout_functions'; + +const isGroupId = id => id.startsWith('group'); + +/** + * 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 = 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 => ({ + left: shape.transformMatrix[12] - shape.a, + top: shape.transformMatrix[13] - shape.b, + width: shape.a * 2, + height: shape.b * 2, + angle: Math.round((matrixToAngle(shape.transformMatrix) * 180) / Math.PI), + parent: shape.parent || null, + type: shape.type === 'group' ? 'group' : 'element', +}); + +const globalPositionUpdates = (setMultiplePositions, { 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 + + return shallowEqual(oldProps, newProps) + ? null + : { position: newProps, elementId: shape.id }; + } + }) + .filter(identity); + return repositionings; +}; + +const dedupe = (d, i, a) => a.findIndex(s => s.id === d.id) === i; + +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 shapesForNodes = nodes => { + const rawShapes = nodes + .map(elementToShape) + // filtering to eliminate residual element of a possible group that had been deleted in Redux + .filter((d, i, a) => !isGroupId(d.id) || a.find(s => s.parent === d.id)) + .filter(dedupe); + missingParentCheck(rawShapes); + const getLocalMatrix = getLocalTransformMatrix(rawShapes); + return rawShapes.map(s => ({ ...s, localTransformMatrix: getLocalMatrix(s) })); +}; + +const updateGlobalPositionsInRedux = (setMultiplePositions, scene, unsortedElements) => { + const repositionings = globalPositionUpdates(setMultiplePositions, scene, unsortedElements); + if (repositionings.length) { + setMultiplePositions(repositionings); + } +}; + +export const globalStateUpdater = (dispatch, getState) => state => { + const nextScene = state.currentScene; + const globalState = getState(); + const page = getSelectedPage(globalState); + const elements = getNodes(globalState, page); + const shapes = nextScene.shapes; + const persistableGroups = shapes.filter(s => s.subtype === 'persistentGroup').filter(dedupe); + const persistedGroups = elements.filter(e => isGroupId(e.id)).filter(dedupe); + + persistableGroups.forEach(g => { + if ( + !persistedGroups.find(p => { + if (!p.id) { + throw new Error('Element has no id'); + } + return p.id === g.id; + }) + ) { + const partialElement = { + id: g.id, + filter: undefined, + expression: 'shape fill="rgba(255,255,255,0)" | render', + position: { + ...shapeToElement(g), + }, + }; + dispatch(addElement(page, partialElement)); + } + }); + + const elementsToRemove = persistedGroups.filter( + // list elements for removal if they're not in the persistable set, or if there's no longer an associated element + // the latter of which shouldn't happen, so it's belts and braces + p => + !persistableGroups.find(g => p.id === g.id) || !elements.find(e => e.position.parent === p.id) + ); + + updateGlobalPositionsInRedux( + positions => dispatch(setMultiplePositions(positions.map(p => ({ ...p, pageId: page })))), + nextScene, + elements + ); + + if (elementsToRemove.length) { + // remove elements for groups that were ungrouped + dispatch(removeElements(elementsToRemove.map(e => e.id), page)); + } + + // set the selected element on the global store, if one element is selected + const selectedPrimaryShapes = nextScene.selectedPrimaryShapes; + if (!shallowEqual(selectedPrimaryShapes, getState().transient.selectedToplevelNodes)) { + dispatch( + selectToplevelNodes( + flatten( + selectedPrimaryShapes.map(n => + n.startsWith('group') && shapes.find(s => s.id === n).subtype === 'adHocGroup' + ? shapes.filter(s => s.type !== 'annotation' && s.parent === n).map(s => s.id) + : [n] + ) + ) + ) + ); + } +}; + +export const crawlTree = shapes => shapeId => { + const rec = shapeId => [ + shapeId, + ...flatten(shapes.filter(s => s.position.parent === shapeId).map(s => rec(s.id))), + ]; + return rec(shapeId); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/interactive_workpad_page.js b/x-pack/plugins/canvas/public/components/workpad_page/interactive_workpad_page.js new file mode 100644 index 000000000000..a6b2e81fdb80 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_page/interactive_workpad_page.js @@ -0,0 +1,128 @@ +/* + * 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, { PureComponent } from 'react'; +import { ElementWrapper } from '../element_wrapper'; +import { AlignmentGuide } from '../alignment_guide'; +import { HoverAnnotation } from '../hover_annotation'; +import { TooltipAnnotation } from '../tooltip_annotation'; +import { RotationHandle } from '../rotation_handle'; +import { BorderConnection } from '../border_connection'; +import { BorderResizeHandle } from '../border_resize_handle'; +import { WorkpadShortcuts } from './workpad_shortcuts'; +import { interactiveWorkpadPagePropTypes } from './prop_types'; + +export class InteractiveWorkpadPage extends PureComponent { + static propTypes = interactiveWorkpadPagePropTypes; + + componentWillUnmount() { + this.props.resetHandler(); + } + + render() { + const { + pageId, + pageStyle, + className, + animationStyle, + elements, + cursor = 'auto', + height, + width, + onDoubleClick, + onKeyDown, + onMouseDown, + onMouseLeave, + onMouseMove, + onMouseUp, + onAnimationEnd, + onWheel, + selectedNodes, + selectToplevelNodes, + insertNodes, + removeNodes, + elementLayer, + groupNodes, + ungroupNodes, + canvasOrigin, + saveCanvasOrigin, + } = this.props; + + let shortcuts = null; + + const shortcutProps = { + elementLayer, + groupNodes, + insertNodes, + pageId, + removeNodes, + selectedNodes, + selectToplevelNodes, + ungroupNodes, + }; + shortcuts = ; + + return ( +
{ + if (!canvasOrigin && node && node.getBoundingClientRect) { + saveCanvasOrigin(() => () => node.getBoundingClientRect()); + } + }} + data-test-subj="canvasWorkpadPage" + className={`canvasPage ${className} canvasPage--isEditable`} + data-shared-items-container + style={{ ...pageStyle, ...animationStyle, height, width, cursor }} + onKeyDown={onKeyDown} + onMouseMove={onMouseMove} + onMouseUp={onMouseUp} + onMouseDown={onMouseDown} + onMouseLeave={onMouseLeave} + onDoubleClick={onDoubleClick} + onAnimationEnd={onAnimationEnd} + onWheel={onWheel} + > + {shortcuts} + {elements + .map(node => { + if (node.type === 'annotation') { + const props = { + key: node.id, + type: node.type, + transformMatrix: node.transformMatrix, + width: node.width, + height: node.height, + text: node.text, + }; + + switch (node.subtype) { + case 'alignmentGuide': + return ; + 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 'rotationHandle': + return ; + case 'resizeHandle': + return ; + case 'resizeConnector': + return ; + case 'rotationTooltip': + return ; + default: + return []; + } + } else if (node.type !== 'group') { + return ; + } + }) + .filter(element => !!element)} +
+ ); + } +} diff --git a/x-pack/plugins/canvas/public/components/workpad_page/prop_types.js b/x-pack/plugins/canvas/public/components/workpad_page/prop_types.js new file mode 100644 index 000000000000..7cf7eac48ca5 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_page/prop_types.js @@ -0,0 +1,51 @@ +/* + * 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. + */ + +// NOTE: the data-shared-* attributes here are used for reporting +import PropTypes from 'prop-types'; + +export const staticWorkpadPagePropTypes = { + pageId: PropTypes.string.isRequired, + pageStyle: PropTypes.object, + className: PropTypes.string.isRequired, + animationStyle: PropTypes.object.isRequired, + elements: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + type: PropTypes.string, + }) + ).isRequired, + height: PropTypes.number.isRequired, + width: PropTypes.number.isRequired, + onAnimationEnd: PropTypes.func, +}; + +export const interactiveWorkpadPagePropTypes = { + ...staticWorkpadPagePropTypes, + cursor: PropTypes.string, + onDoubleClick: PropTypes.func, + onKeyDown: PropTypes.func, + onMouseDown: PropTypes.func, + onMouseLeave: PropTypes.func, + onMouseMove: PropTypes.func, + onMouseUp: PropTypes.func, + onAnimationEnd: PropTypes.func, + resetHandler: PropTypes.func, + copyElements: PropTypes.func, + cutElements: PropTypes.func, + duplicateElements: PropTypes.func, + pasteElements: PropTypes.func, + removeElements: PropTypes.func, + bringForward: PropTypes.func, + bringToFront: PropTypes.func, + sendBackward: PropTypes.func, + sendToBack: PropTypes.func, + canvasOrigin: PropTypes.func, + saveCanvasOrigin: PropTypes.func.isRequired, +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_page/static_workpad_page.js b/x-pack/plugins/canvas/public/components/workpad_page/static_workpad_page.js new file mode 100644 index 000000000000..f617b9e21927 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/workpad_page/static_workpad_page.js @@ -0,0 +1,32 @@ +/* + * 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, { PureComponent } from 'react'; +import { ElementWrapper } from '../element_wrapper'; +import { staticWorkpadPagePropTypes } from './prop_types'; + +export class StaticWorkpadPage extends PureComponent { + static propTypes = staticWorkpadPagePropTypes; + + render() { + const { pageId, pageStyle, className, animationStyle, elements, height, width } = this.props; + + return ( +
+ {elements.map(element => ( + + ))} +
+ ); + } +} 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 deleted file mode 100644 index 4a5249565320..000000000000 --- a/x-pack/plugins/canvas/public/components/workpad_page/workpad_page.js +++ /dev/null @@ -1,186 +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 React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { ElementWrapper } from '../element_wrapper'; -import { AlignmentGuide } from '../alignment_guide'; -import { HoverAnnotation } from '../hover_annotation'; -import { TooltipAnnotation } from '../tooltip_annotation'; -import { RotationHandle } from '../rotation_handle'; -import { BorderConnection } from '../border_connection'; -import { BorderResizeHandle } from '../border_resize_handle'; -import { WorkpadShortcuts } from './workpad_shortcuts'; - -// NOTE: the data-shared-* attributes here are used for reporting -export class WorkpadPage extends PureComponent { - static propTypes = { - page: PropTypes.shape({ - id: PropTypes.string.isRequired, - style: PropTypes.object, - }).isRequired, - className: PropTypes.string.isRequired, - animationStyle: PropTypes.object.isRequired, - elements: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - transformMatrix: PropTypes.arrayOf(PropTypes.number).isRequired, - width: PropTypes.number.isRequired, - height: PropTypes.number.isRequired, - type: PropTypes.string, - }) - ).isRequired, - cursor: PropTypes.string, - height: PropTypes.number.isRequired, - width: PropTypes.number.isRequired, - isEditable: PropTypes.bool.isRequired, - onDoubleClick: PropTypes.func, - onKeyDown: PropTypes.func, - onMouseDown: PropTypes.func, - onMouseLeave: PropTypes.func, - onMouseMove: PropTypes.func, - onMouseUp: PropTypes.func, - onAnimationEnd: PropTypes.func, - resetHandler: PropTypes.func, - copyElements: PropTypes.func, - cutElements: PropTypes.func, - duplicateElements: PropTypes.func, - pasteElements: PropTypes.func, - removeElements: PropTypes.func, - bringForward: PropTypes.func, - bringToFront: PropTypes.func, - sendBackward: PropTypes.func, - sendToBack: PropTypes.func, - canvasOrigin: PropTypes.func, - saveCanvasOrigin: PropTypes.func.isRequired, - }; - - componentWillUnmount() { - this.props.resetHandler(); - } - - render() { - const { - page, - className, - animationStyle, - elements, - cursor = 'auto', - height, - width, - isEditable, - isSelected, - onDoubleClick, - onKeyDown, - onMouseDown, - onMouseLeave, - onMouseMove, - onMouseUp, - onAnimationEnd, - onWheel, - selectedElementIds, - selectedElements, - selectedPrimaryShapes, - selectElement, - insertNodes, - removeElements, - elementLayer, - groupElements, - ungroupElements, - forceUpdate, - canvasOrigin, - saveCanvasOrigin, - } = this.props; - - let shortcuts = null; - - if (isEditable && isSelected) { - const shortcutProps = { - elementLayer, - forceUpdate, - groupElements, - insertNodes, - pageId: page.id, - removeElements, - selectedElementIds, - selectedElements, - selectedPrimaryShapes, - selectElement, - ungroupElements, - }; - shortcuts = ; - } - - return ( -
{ - if (!canvasOrigin && element && element.getBoundingClientRect) { - saveCanvasOrigin(() => () => element.getBoundingClientRect()); - } - }} - data-test-subj="canvasWorkpadPage" - className={`canvasPage ${className} ${isEditable ? 'canvasPage--isEditable' : ''}`} - data-shared-items-container - style={{ - ...page.style, - ...animationStyle, - height, - width, - cursor, - }} - onKeyDown={onKeyDown} - onMouseMove={onMouseMove} - onMouseUp={onMouseUp} - onMouseDown={onMouseDown} - onMouseLeave={onMouseLeave} - onDoubleClick={onDoubleClick} - onAnimationEnd={onAnimationEnd} - onWheel={onWheel} - > - {shortcuts} - {elements - .map(element => { - if (element.type === 'annotation') { - if (!isEditable) { - return; - } - const props = { - key: element.id, - type: element.type, - transformMatrix: element.transformMatrix, - width: element.width, - height: element.height, - text: element.text, - }; - - switch (element.subtype) { - case 'alignmentGuide': - return ; - 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 'rotationHandle': - return ; - case 'resizeHandle': - return ; - case 'resizeConnector': - return ; - case 'rotationTooltip': - return ; - default: - return []; - } - } else if (element.type !== 'group') { - return ; - } - }) - .filter(element => !!element)} -
- ); - } -} diff --git a/x-pack/plugins/canvas/public/components/workpad_page/workpad_shortcuts.tsx b/x-pack/plugins/canvas/public/components/workpad_page/workpad_shortcuts.tsx index 889076144e05..b5d604c32a50 100644 --- a/x-pack/plugins/canvas/public/components/workpad_page/workpad_shortcuts.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_page/workpad_shortcuts.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; import React, { Component } from 'react'; import isEqual from 'react-fast-compare'; @@ -19,29 +18,95 @@ import { notify } from '../../lib/notify'; export interface Props { pageId: string; - selectedElementIds: string[]; - selectedElements: any[]; - selectedPrimaryShapes: any[]; - selectElement: (elementId: string) => void; - insertNodes: (pageId: string) => (selectedElements: any[]) => void; - removeElements: (pageId: string) => (selectedElementIds: string[]) => void; - elementLayer: (pageId: string, selectedElement: any, movement: any) => void; - groupElements: () => void; - ungroupElements: () => void; - forceUpdate: () => void; + selectedNodes: any[]; + selectToplevelNodes: (...nodeIds: string[]) => void; + insertNodes: (pageId: string) => (selectedNodes: any[]) => void; + removeNodes: (pageId: string) => (selectedNodeIds: string[]) => void; + elementLayer: (pageId: string, selectedNode: any, movement: any) => void; + groupNodes: () => void; + ungroupNodes: () => void; } +const id = (node: any): string => node.id; + +const keyMap = { + DELETE: function deleteNodes({ pageId, removeNodes, selectedNodes }: any): any { + // currently, handle the removal of one node, exploiting multiselect subsequently + if (selectedNodes.length) { + removeNodes(pageId)(selectedNodes.map(id)); + } + }, + COPY: function copyNodes({ selectedNodes }: any): any { + if (selectedNodes.length) { + setClipboardData({ selectedNodes }); + notify.success('Copied element to clipboard'); + } + }, + CUT: function cutNodes({ pageId, removeNodes, selectedNodes }: any): any { + if (selectedNodes.length) { + setClipboardData({ selectedNodes }); + removeNodes(pageId)(selectedNodes.map(id)); + notify.success('Cut element to clipboard'); + } + }, + CLONE: function duplicateNodes({ + insertNodes, + pageId, + selectToplevelNodes, + selectedNodes, + }: any): any { + // TODO: This is slightly different from the duplicateNodes function in sidebar/index.js. Should they be doing the same thing? + // This should also be abstracted. + const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); + if (clonedNodes) { + insertNodes(pageId)(clonedNodes); + selectToplevelNodes(clonedNodes); + } + }, + PASTE: function pasteNodes({ insertNodes, pageId, selectToplevelNodes }: any): any { + const { selectedNodes } = JSON.parse(getClipboardData()) || { selectedNodes: [] }; + const clonedNodes = selectedNodes && cloneSubgraphs(selectedNodes); + if (clonedNodes) { + insertNodes(pageId)(clonedNodes); // first clone and persist the new node(s) + selectToplevelNodes(clonedNodes); // then select the cloned node(s) + } + }, + BRING_FORWARD: function bringForward({ elementLayer, pageId, selectedNodes }: any): any { + // 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 node is selected + if (selectedNodes.length === 1) { + elementLayer(pageId, selectedNodes[0], 1); + } + }, + BRING_TO_FRONT: function bringToFront({ elementLayer, pageId, selectedNodes }: any): any { + if (selectedNodes.length === 1) { + elementLayer(pageId, selectedNodes[0], Infinity); + } + }, + SEND_BACKWARD: function sendBackward({ elementLayer, pageId, selectedNodes }: any): any { + if (selectedNodes.length === 1) { + elementLayer(pageId, selectedNodes[0], -1); + } + }, + SEND_TO_BACK: function sendToBack({ elementLayer, pageId, selectedNodes }: any): any { + if (selectedNodes.length === 1) { + elementLayer(pageId, selectedNodes[0], -Infinity); + } + }, + GROUP: ({ groupNodes }: any): any => groupNodes(), + UNGROUP: ({ ungroupNodes }: any): any => ungroupNodes(), +} as any; + export class WorkpadShortcuts extends Component { public render() { - const { pageId, forceUpdate } = this.props; return ( { - this._keyHandler(action, event); - forceUpdate(); + event.preventDefault(); + keyMap[action](this.props); }} - targetNodeSelector={`#${pageId}`} + targetNodeSelector={`#${this.props.pageId}`} global /> ); @@ -50,164 +115,4 @@ export class WorkpadShortcuts extends Component { public shouldComponentUpdate(nextProps: Props) { return !isEqual(nextProps, this.props); } - - private _keyHandler(action: string, event: Event) { - event.preventDefault(); - switch (action) { - case 'COPY': - this._copyElements(); - break; - case 'CLONE': - this._duplicateElements(); - break; - case 'CUT': - this._cutElements(); - break; - case 'DELETE': - this._removeElements(); - break; - case 'PASTE': - this._pasteElements(); - break; - case 'BRING_FORWARD': - this._bringForward(); - break; - case 'BRING_TO_FRONT': - this._bringToFront(); - break; - case 'SEND_BACKWARD': - this._sendBackward(); - break; - case 'SEND_TO_BACK': - this._sendToBack(); - break; - case 'GROUP': - this.props.groupElements(); - break; - case 'UNGROUP': - this.props.ungroupElements(); - break; - } - } - - private _removeElements() { - const { pageId, removeElements, selectedElementIds } = this.props; - // currently, handle the removal of one element, exploiting multiselect subsequently - if (selectedElementIds.length) { - removeElements(pageId)(selectedElementIds); - } - } - - private _copyElements() { - const { selectedElements, selectedPrimaryShapes } = this.props; - if (selectedElements.length) { - setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes }); - notify.success('Copied element to clipboard'); - } - } - - private _cutElements() { - const { - pageId, - removeElements, - selectedElements, - selectedElementIds, - selectedPrimaryShapes, - } = this.props; - - if (selectedElements.length) { - setClipboardData({ selectedElements, rootShapes: selectedPrimaryShapes }); - removeElements(pageId)(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. - private _duplicateElements() { - const { - insertNodes, - pageId, - selectElement, - selectedElements, - selectedPrimaryShapes, - } = this.props; - const clonedElements = selectedElements && cloneSubgraphs(selectedElements); - - if (clonedElements) { - insertNodes(pageId)(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 - ); - } - } - } - } - - private _pasteElements() { - const { insertNodes, pageId, selectElement } = this.props; - const { selectedElements, rootShapes } = JSON.parse(getClipboardData()) || { - selectedElements: [], - rootShapes: [], - }; - - const clonedElements = selectedElements && cloneSubgraphs(selectedElements); - - if (clonedElements) { - // first clone and persist the new node(s) - insertNodes(pageId)(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: { id: string }) => 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 - private _bringForward() { - const { elementLayer, pageId, selectedElements } = this.props; - if (selectedElements.length === 1) { - elementLayer(pageId, selectedElements[0], 1); - } - } - - private _bringToFront() { - const { elementLayer, pageId, selectedElements } = this.props; - if (selectedElements.length === 1) { - elementLayer(pageId, selectedElements[0], Infinity); - } - } - - private _sendBackward() { - const { elementLayer, pageId, selectedElements } = this.props; - if (selectedElements.length === 1) { - elementLayer(pageId, selectedElements[0], -1); - } - } - - private _sendToBack() { - const { elementLayer, pageId, selectedElements } = this.props; - if (selectedElements.length === 1) { - elementLayer(pageId, selectedElements[0], -Infinity); - } - } } diff --git a/x-pack/plugins/canvas/public/lib/__tests__/history_provider.js b/x-pack/plugins/canvas/public/lib/__tests__/history_provider.js index 756dd4e36e75..82cb468be3c5 100644 --- a/x-pack/plugins/canvas/public/lib/__tests__/history_provider.js +++ b/x-pack/plugins/canvas/public/lib/__tests__/history_provider.js @@ -12,7 +12,7 @@ function createState() { return { transient: { selectedPage: 'page-f3ce-4bb7-86c8-0417606d6592', - selectedElement: 'element-d88c-4bbd-9453-db22e949b92e', + selectedToplevelNodes: ['element-d88c-4bbd-9453-db22e949b92e'], resolvedArgs: {}, }, persistent: { diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js index 0b6ced5401ac..7e1a29f26f11 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/gestures.js @@ -27,17 +27,7 @@ const appleKeyboard = Boolean( const primaryUpdate = state => state.primaryUpdate; -const gestureStatePrev = select( - scene => - scene.gestureState || { - cursor: { - x: 0, - y: 0, - }, - mouseIsDown: false, - mouseButtonState: { buttonState: 'up', downX: null, downY: null }, - } -)(scene); +const gestureStatePrev = select(scene => scene.gestureState)(scene); /** * Gestures - derived selectors for transient state diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/index.d.ts b/x-pack/plugins/canvas/public/lib/aeroelastic/index.d.ts index 65ff3d1eec23..685df19b7db5 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/index.d.ts +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/index.d.ts @@ -40,14 +40,6 @@ export type PlainFun = (...args: Json[]) => Json; export type Selector = (...fns: Resolve[]) => Resolve; type Resolve = ((obj: State) => Json); -// -export interface Meta { - silent: boolean; -} export type TypeName = string; export type Payload = JsonMap; export type UpdaterFunction = (arg: State) => State; -export type ChangeCallbackFunction = ( - { type, state }: { type: TypeName; state: State }, - meta: Meta -) => void; 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 729db94038f5..000000000000 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/index.js +++ /dev/null @@ -1,14 +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 { updater } from './layout'; -import { multiply, rotateZ, translate } from './matrix'; -import { createStore } from './store'; - -export const matrix = { multiply, rotateZ, translate }; - -export const createLayoutStore = (initialState, onChangeCallback) => - createStore(initialState, updater, onChangeCallback); diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js index a8f894c837de..26e4ab3cd4b7 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout.js @@ -32,7 +32,6 @@ import { getConfiguration, getConstrainedShapesWithPreexistingAnnotations, getCursor, - getDirectSelect, getDraggedPrimaryShape, getFocusedShape, getGroupAction, @@ -48,9 +47,7 @@ import { getMouseTransformGesturePrev, getMouseTransformState, getNextScene, - getNextShapes, getResizeManipulator, - getRestateShapesEvent, getRotationAnnotations, getRotationTooltipAnnotation, getSelectedPrimaryShapeIds, @@ -58,6 +55,7 @@ import { getSelectedShapes, getSelectedShapesPrev, getSelectionState, + getSelectionStateFull, getShapes, getSnappedShapes, getTransformIntents, @@ -95,28 +93,23 @@ const mouseTransformGesture = select(getMouseTransformGesture)(mouseTransformSta const transformGestures = mouseTransformGesture; -const restateShapesEvent = select(getRestateShapesEvent)(primaryUpdate); - -// 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)(scene); +const selectedShapeObjects = select(getSelectedShapeObjects)(scene, shapes); const selectedShapesPrev = select(getSelectedShapesPrev)(scene); -const selectionState = select(getSelectionState)( +const selectionStateFull = select(getSelectionStateFull)( selectedShapesPrev, configuration, selectedShapeObjects, hoveredShapes, mouseButton, metaHeld, - multiselectModifier, - directSelect, - shapes + multiselectModifier ); -const selectedShapes = select(getSelectedShapes)(selectionState); +const selectionState = select(getSelectionState)(selectionStateFull); + +const selectedShapes = select(getSelectedShapes)(selectionStateFull); const selectedPrimaryShapeIds = select(getSelectedPrimaryShapeIds)(selectedShapes); // fixme unify with contentShape @@ -134,13 +127,9 @@ const transformIntents = select(getTransformIntents)( resizeManipulator ); -// "cumulative" is the effect of the ongoing interaction; "baseline" is sans "cumulative", plain "localTransformMatrix" +const transformedShapes = select(applyLocalTransforms)(shapes, transformIntents); -const nextShapes = select(getNextShapes)(shapes, restateShapesEvent); - -const transformedShapes = select(applyLocalTransforms)(nextShapes, transformIntents); - -const draggedPrimaryShape = select(getDraggedPrimaryShape)(nextShapes, draggedShape); +const draggedPrimaryShape = select(getDraggedPrimaryShape)(shapes, draggedShape); const alignmentGuideAnnotations = select(getAlignmentGuideAnnotations)( configuration, @@ -239,7 +228,6 @@ export const nextScene = select(getNextScene)( cursor, selectionState, mouseTransformState, - 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 a65488fc1577..aa474d69a46d 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/layout_functions.js @@ -37,7 +37,6 @@ import { mean, not, removeDuplicates, - shallowEqual, } from './functional'; import { getId as rawGetId } from './../../lib/get_id'; @@ -136,34 +135,18 @@ export const getMouseTransformGesture = tuple => .filter(tpl => tpl.transform) .map(({ transform, cumulativeTransform }) => ({ transform, cumulativeTransform })); -export const getRestateShapesEvent = action => { - if (!action || action.type !== 'restateShapesEvent') { - return null; +export const getLocalTransformMatrix = shapes => shape => { + if (!shape.parent) { + return shape.transformMatrix; } - const shapes = action.payload.newShapes; - const local = shape => { - if (!shape.parent) { - return shape.transformMatrix; - } - return multiply( - invert(shapes.find(s => s.id === shape.parent).transformMatrix), - shape.transformMatrix - ); - }; - const newShapes = shapes.map(s => ({ ...s, localTransformMatrix: local(s) })); - return { newShapes, uid: action.payload.uid }; -}; // is selected, as otherwise selection is driven by gestures and knowledge of element positions + return multiply( + invert(shapes.find(s => s.id === shape.parent).transformMatrix), + shape.transformMatrix + ); +}; -export const getDirectSelect = action => - action && action.type === 'shapeSelect' ? action.payload : null; - -export const getSelectedShapeObjects = scene => scene.selectedShapeObjects || []; // returns true if the shape is not a child of one of the shapes - -// fixme put it into geometry.js -// broken. -// is the composition of the baseline (previously absorbed transforms) and the cumulative (ie. ongoing interaction) -const reselectShapes = (allShapes, shapes) => - shapes.map(id => allShapes.find(shape => shape.id === id)); +export const getSelectedShapeObjects = (scene, shapes) => + (scene.selectedShapes || []).map(s => shapes.find(ss => ss.id === s)); const contentShape = allShapes => shape => shape.type === 'annotation' @@ -1201,44 +1184,24 @@ export const getCursor = (config, shape, draggedPrimaryShape) => { } }; -export const getSelectedShapesPrev = scene => - scene.selectionState || { - shapes: [], - uid: null, - depthIndex: 0, - down: false, - }; +export const getSelectedShapesPrev = scene => scene.selectionState; -export const getSelectionState = ( +export const getSelectionStateFull = ( prev, config, selectedShapeObjects, hoveredShapes, { down, uid }, metaHeld, - multiselect, - directSelect, - allShapes + multiselect ) => { const uidUnchanged = uid === prev.uid; const mouseButtonUp = !down; - const updateFromDirectSelect = - directSelect && - directSelect.shapes && - !shallowEqual(directSelect.shapes, selectedShapeObjects.map(shape => shape.id)); - if (updateFromDirectSelect) { - return { - shapes: reselectShapes(allShapes, directSelect.shapes), - uid: directSelect.uid, - depthIndex: prev.depthIndex, - down: prev.down, - }; - } if (selectedShapeObjects) { prev.shapes = selectedShapeObjects.slice(); } // take action on mouse down only, and if the uid changed (except with directSelect), ie. bail otherwise - if (mouseButtonUp || (uidUnchanged && !directSelect)) { + if (mouseButtonUp || uidUnchanged) { return { ...prev, down, uid, metaHeld }; } const selectFunction = config.singleSelect || !multiselect ? singleSelect : multiSelect; @@ -1247,6 +1210,8 @@ export const getSelectionState = ( export const getSelectedShapes = selectionTuple => selectionTuple.shapes; +export const getSelectionState = ({ uid, depthIndex, down }) => ({ uid, depthIndex, down }); + export const getSelectedPrimaryShapeIds = shapes => shapes.map(primaryShape); export const getResizeManipulator = (config, toggle) => @@ -1276,15 +1241,6 @@ export const getTransformIntents = ( ...resizeAnnotationManipulation(config, transformGestures, directShapes, shapes, manipulator), ]; -export const getNextShapes = (preexistingShapes, restated) => { - if (restated && restated.newShapes) { - return restated.newShapes; - } - - // this is the per-shape model update at the current PoC level - return preexistingShapes; -}; - export const getDraggedPrimaryShape = (shapes, draggedShape) => draggedShape && shapes.find(shape => shape.id === primaryShape(draggedShape)); @@ -1418,9 +1374,9 @@ export const getAnnotatedShapes = ( }; // collection of shapes themselves export const getNextScene = ( - config, + configuration, hoveredShape, - selectedShapeIds, + selectedShapes, selectedPrimaryShapes, shapes, gestureEnd, @@ -1428,34 +1384,20 @@ export const getNextScene = ( cursor, selectionState, mouseTransformState, - selectedShapes, gestureState -) => { - const selectedLeafShapes = getLeafs( - shape => shape.type === config.groupName, - shapes, - selectionState.shapes - .map(s => (s.type === 'annotation' ? shapes.find(ss => ss.id === s.parent) : s)) - .filter(identity) - ) - .filter(shape => shape.type !== 'annotation') - .map(s => s.id); - return { - configuration: config, - hoveredShape, - selectedShapes: selectedShapeIds, - selectedLeafShapes, - selectedPrimaryShapes, - shapes, - gestureEnd, - draggedShape, - cursor, - selectionState, - gestureState, - mouseTransformState, - selectedShapeObjects: selectedShapes, - }; -}; +) => ({ + configuration, + hoveredShape, + selectedShapes, + selectedPrimaryShapes, + shapes, + gestureEnd, + draggedShape, + cursor, + selectionState, + mouseTransformState, + gestureState, +}); export const updaterFun = (nextScene, primaryUpdate) => ({ primaryUpdate, diff --git a/x-pack/plugins/canvas/public/lib/aeroelastic/store.ts b/x-pack/plugins/canvas/public/lib/aeroelastic/store.ts index 43c3b72dcf28..2ac380abc673 100644 --- a/x-pack/plugins/canvas/public/lib/aeroelastic/store.ts +++ b/x-pack/plugins/canvas/public/lib/aeroelastic/store.ts @@ -4,39 +4,29 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - ActionId, - ChangeCallbackFunction, - Meta, - Payload, - State, - TypeName, - UpdaterFunction, -} from '.'; +import { ActionId, Payload, State, TypeName, UpdaterFunction } from '.'; let counter = 0 as ActionId; -export const createStore = ( - initialState: State, - updater: UpdaterFunction, - onChangeCallback: ChangeCallbackFunction -) => { +export const createStore = (initialState: State, updater: UpdaterFunction) => { let currentState = initialState; - const getCurrentState = () => currentState; - - const commit = (type: TypeName, payload: Payload, meta: Meta = { silent: false }) => { - currentState = updater({ + const commit = (type: TypeName, payload: Payload) => { + return (currentState = updater({ ...currentState, primaryUpdate: { type, payload: { ...payload, uid: counter++ }, }, - }); - if (!meta.silent) { - onChangeCallback({ type, state: currentState }, meta); - } + })); }; - return { getCurrentState, commit }; + const getCurrentState = () => currentState; + + const setCurrentState = (state: State) => { + currentState = state; + commit('flush', {}); + }; + + return { getCurrentState, setCurrentState, commit }; }; 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 42c484482b40..000000000000 --- a/x-pack/plugins/canvas/public/lib/aeroelastic_kibana.js +++ /dev/null @@ -1,41 +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 { createLayoutStore, matrix } from './aeroelastic'; - -const stores = new Map(); - -export const aeroelastic = { - matrix, - - clearStores() { - stores.clear(); - }, - - createStore(initialState, onChangeCallback = () => {}, page) { - stores.set(page, createLayoutStore(initialState, onChangeCallback)); - }, - - 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/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index 7e3e03b2931a..46b1e8a25ade 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -16,7 +16,7 @@ import { getDefaultElement } from '../defaults'; import { notify } from '../../lib/notify'; import { runInterpreter } from '../../lib/run_interpreter'; import { subMultitree } from '../../lib/aeroelastic/functional'; -import { selectElement } from './transient'; +import { selectToplevelNodes } from './transient'; import * as args from './resolved_args'; export function getSiblingContext(state, elementId, checkIndex) { @@ -221,10 +221,7 @@ export const removeElements = createThunk( // todo consider doing the group membership collation in aeroelastic, or the Redux reducer, when adding templates const allElements = getNodes(state, pageId); - const allRoots = rootElementIds.map(id => allElements.find(e => id === e.id)); - if (allRoots.indexOf(undefined) !== -1) { - throw new Error('Some of the elements to be deleted do not exist'); - } + const allRoots = rootElementIds.map(id => allElements.find(e => id === e.id)).filter(d => d); const elementIds = subMultitree(e => e.id, e => e.position.parent, allElements, allRoots).map( e => e.id ); @@ -236,8 +233,8 @@ export const removeElements = createThunk( }); const _removeElements = createAction('removeElements', (elementIds, pageId) => ({ - pageId, elementIds, + pageId, })); dispatch(_removeElements(elementIds, pageId)); @@ -411,5 +408,5 @@ export const addElement = createThunk('addElement', ({ dispatch }, pageId, eleme } // select the new element - dispatch(selectElement(newElement.id)); + dispatch(selectToplevelNodes([newElement.id])); }); diff --git a/x-pack/plugins/canvas/public/state/actions/transient.js b/x-pack/plugins/canvas/public/state/actions/transient.js index c7ad3baf989b..20e467ff06fe 100644 --- a/x-pack/plugins/canvas/public/state/actions/transient.js +++ b/x-pack/plugins/canvas/public/state/actions/transient.js @@ -8,6 +8,6 @@ import { createAction } from 'redux-actions'; export const setCanUserWrite = createAction('setCanUserWrite'); export const setFullscreen = createAction('setFullscreen'); -export const selectElement = createAction('selectElement'); +export const selectToplevelNodes = createAction('selectToplevelNodes'); export const setFirstLoad = createAction('setFirstLoad'); export const setElementStats = createAction('setElementStats'); diff --git a/x-pack/plugins/canvas/public/state/initial_state.js b/x-pack/plugins/canvas/public/state/initial_state.js index 4321e51b0a89..ade6f4698508 100644 --- a/x-pack/plugins/canvas/public/state/initial_state.js +++ b/x-pack/plugins/canvas/public/state/initial_state.js @@ -21,7 +21,7 @@ export const getInitialState = path => { error: 0, }, fullscreen: false, - selectedElement: null, + selectedToplevelNodes: [], resolvedArgs: {}, refresh: { interval: 0, diff --git a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js b/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js deleted file mode 100644 index 2701444283ac..000000000000 --- a/x-pack/plugins/canvas/public/state/middleware/aeroelastic.js +++ /dev/null @@ -1,403 +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 { 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, - insertNodes, - elementLayer, - setMultiplePositions, - fetchAllRenderables, -} from '../actions/elements'; -import { restoreHistory } from '../actions/history'; -import { selectElement } from '../actions/transient'; -import { addPage, removePage, duplicatePage, setPage } from '../actions/pages'; -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: 2, - 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(aeroelasticConfiguration.groupName); - -const pageChangerActions = [duplicatePage.toString(), addPage.toString(), setPage.toString()]; - -/** - * 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 { - left: shape.transformMatrix[12] - shape.a, - top: shape.transformMatrix[13] - shape.b, - width: shape.a * 2, - height: shape.b * 2, - angle: Math.round((matrixToAngle(shape.transformMatrix) * 180) / Math.PI), - parent: shape.parent || null, - type: shape.type === 'group' ? 'group' : 'element', - }; -}; - -const updateGlobalPositions = (setMultiplePositions, { 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 - - return shallowEqual(oldProps, newProps) - ? null - : { position: newProps, elementId: shape.id }; - } - }) - .filter(identity); - if (repositionings.length) { - setMultiplePositions(repositionings); - } -}; - -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 - - const onChangeCallback = ({ state }) => { - const nextScene = state.currentScene; - if (!nextScene.gestureEnd) { - return; - } // only update redux on gesture end - // TODO: check for gestureEnd on element selection - - // read current data out of redux - const page = getSelectedPage(getState()); - const elements = getNodes(getState(), page); - const selectedElement = getSelectedElement(getState()); - - const shapes = nextScene.shapes; - const persistableGroups = shapes.filter(s => s.subtype === 'persistentGroup'); - const persistedGroups = elements.filter(e => isGroupId(e.id)); - - idDuplicateCheck(persistableGroups); - idDuplicateCheck(persistedGroups); - - persistableGroups.forEach(g => { - if ( - !persistedGroups.find(p => { - if (!p.id) { - throw new Error('Element has no id'); - } - return p.id === g.id; - }) - ) { - const partialElement = { - id: g.id, - filter: undefined, - expression: 'shape fill="rgba(255,255,255,0)" | render', - position: { - ...shapeToElement(g), - }, - }; - dispatch(addElement(page, partialElement)); - } - }); - - const elementsToRemove = persistedGroups.filter( - // list elements for removal if they're not in the persistable set, or if there's no longer an associated element - // the latter of which shouldn't happen, so it's belts and braces - p => - !persistableGroups.find(g => p.id === g.id) || - !elements.find(e => e.position.parent === p.id) - ); - - updateGlobalPositions( - positions => dispatch(setMultiplePositions(positions.map(p => ({ ...p, pageId: page })))), - nextScene, - elements - ); - - if (elementsToRemove.length) { - // remove elements for groups that were ungrouped - dispatch(removeElements(elementsToRemove.map(e => e.id), page)); - } - - // set the selected element on the global store, if one element is selected - const selectedShape = nextScene.selectedPrimaryShapes[0]; - if (nextScene.selectedShapes.length === 1 && !isGroupId(selectedShape)) { - if (selectedShape !== (selectedElement && selectedElement.id)) { - dispatch(selectElement(selectedShape)); - } - } else { - // otherwise, clear the selected element state - // even for groups - TODO add handling for groups, esp. persistent groups - common styling etc. - if (selectedElement) { - const shape = shapes.find(s => s.id === selectedShape); - // don't reset if eg. we're in the middle of converting an ad hoc group into a persistent one - if (!shape || shape.subtype !== 'adHocGroup') { - dispatch(selectElement(null)); - } - } - } - }; - - const createStore = page => - aero.createStore( - { - primaryUpdate: null, - currentScene: { shapes: [], configuration: aeroelasticConfiguration }, - }, - onChangeCallback, - page - ); - - const populateWithElements = page => { - const newShapes = getNodes(getState(), page) - .map(elementToShape) - // filtering to eliminate residual element of a possible group that had been deleted in Redux - .filter((d, i, a) => !isGroupId(d.id) || a.find(s => s.parent === d.id)); - idDuplicateCheck(newShapes); - missingParentCheck(newShapes); - return aero.commit(page, 'restateShapesEvent', { newShapes }, { silent: true }); - }; - - const selectShape = (page, id) => { - aero.commit(page, 'shapeSelect', { shapes: [id] }); - }; - - const unselectShape = page => { - aero.commit(page, 'shapeSelect', { shapes: [] }); - }; - - const unhoverShape = page => { - aero.commit(page, 'cursorPosition', {}); - }; - - return next => action => { - // get information before the state is changed - const prevPage = getSelectedPage(getState()); - const prevElements = getNodes(getState(), prevPage); - - if (action.type === setWorkpad.toString()) { - const pages = action.payload.pages; - aero.clearStores(); - // Create the aeroelastic store, which happens once per page creation; disposed on workbook change. - // TODO: consider implementing a store removal upon page removal to reclaim a small amount of storage - pages.map(p => p.id).forEach(createStore); - } - - if (action.type === restoreHistory.toString()) { - aero.clearStores(); - action.payload.workpad.pages.map(p => p.id).forEach(createStore); - } - - if (action.type === appReady.toString()) { - const pages = getPages(getState()); - aero.clearStores(); - pages.map(p => p.id).forEach(createStore); - } - - let lastPageRemoved = false; - if (action.type === removePage.toString()) { - const preRemoveState = getState(); - if (getPages(preRemoveState).length <= 1) { - lastPageRemoved = true; - } - - aero.removeStore(action.payload); - } - - if (pageChangerActions.indexOf(action.type) >= 0) { - if (getSelectedElement(getState())) { - dispatch(selectElement(null)); // ensure sidebar etc. get updated; will update the layout engine too - } else { - unselectShape(prevPage); // deselect persistent groups as they're not currently selections in Redux - } - unhoverShape(prevPage); // ensure hover box isn't stuck on page change, no matter how action originated - } - - next(action); - - switch (action.type) { - case appReady.toString(): - case restoreHistory.toString(): - case setWorkpad.toString(): - // Populate the aeroelastic store, which only happens once per page creation; disposed on workbook change. - getPages(getState()) - .map(p => p.id) - .forEach(populateWithElements); - break; - - case addPage.toString(): - case duplicatePage.toString(): - const newPage = getSelectedPage(getState()); - createStore(newPage); - if (action.type === duplicatePage.toString()) { - dispatch(fetchAllRenderables()); - } - - populateWithElements(newPage); - break; - - case removePage.toString(): - const postRemoveState = getState(); - if (lastPageRemoved) { - const freshPage = getSelectedPage(postRemoveState); - createStore(freshPage); - } - break; - - case selectElement.toString(): - // without this condition, a mouse release anywhere will trigger it, leading to selection of whatever is - // underneath the pointer (maybe nothing) when the mouse is released - if (action.payload) { - selectShape(prevPage, action.payload); - } else { - unselectShape(prevPage); - } - - break; - - case removeElements.toString(): - case addElement.toString(): - case insertNodes.toString(): - case elementLayer.toString(): - case setMultiplePositions.toString(): - const page = getSelectedPage(getState()); - const elements = getNodes(getState(), page); - - // TODO: add a better check for elements changing, including their position, ids, etc. - const shouldResetState = - prevPage !== page || !shallowEqual(prevElements.map(id), elements.map(id)); - if (shouldResetState) { - populateWithElements(page); - } - - if ( - action.type !== setMultiplePositions.toString() && - action.type !== elementLayer.toString() - ) { - unselectShape(prevPage); - } - - break; - } - }; -}; diff --git a/x-pack/plugins/canvas/public/state/middleware/index.js b/x-pack/plugins/canvas/public/state/middleware/index.js index 43c148b5af8a..25933b851ba9 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'; @@ -26,7 +25,6 @@ const middlewares = [ resolvedArgs, esPersistMiddleware, historyMiddleware, - aeroelastic, breadcrumbs, fullscreen, inFlight, diff --git a/x-pack/plugins/canvas/public/state/middleware/workpad_update.js b/x-pack/plugins/canvas/public/state/middleware/workpad_update.js index cfb588048ecb..76d699c68a19 100644 --- a/x-pack/plugins/canvas/public/state/middleware/workpad_update.js +++ b/x-pack/plugins/canvas/public/state/middleware/workpad_update.js @@ -5,9 +5,9 @@ */ import { duplicatePage } from '../actions/pages'; -import { fetchRenderable } from '../actions/elements'; +import { fetchAllRenderables } from '../actions/elements'; import { setWriteable } from '../actions/workpad'; -import { getPages, getWorkpadName, isWriteable } from '../selectors/workpad'; +import { getWorkpadName, isWriteable } from '../selectors/workpad'; import { getWindow } from '../../lib/get_window'; import { setDocTitle } from '../../lib/doc_title'; @@ -24,12 +24,7 @@ export const workpadUpdate = ({ dispatch, getState }) => next => action => { // This middleware fetches all of the renderable elements on new, duplicate page if (action.type === duplicatePage.toString()) { - // When a page has been duplicated, it will be added as the last page, so fetch it - const pages = getPages(getState()); - const newPage = pages[pages.length - 1]; - - // For each element on that page, dispatch the action to update it - newPage.elements.forEach(element => dispatch(fetchRenderable(element))); + dispatch(fetchAllRenderables()); } // This middleware clears any page selection when the writeable mode changes diff --git a/x-pack/plugins/canvas/public/state/reducers/__tests__/resolved_args.js b/x-pack/plugins/canvas/public/state/reducers/__tests__/resolved_args.js index ef52a80b8960..ee1d0fc1ca9b 100644 --- a/x-pack/plugins/canvas/public/state/reducers/__tests__/resolved_args.js +++ b/x-pack/plugins/canvas/public/state/reducers/__tests__/resolved_args.js @@ -16,7 +16,7 @@ describe('resolved args reducer', () => { beforeEach(() => { state = { selectedPage: 'page-1', - selectedElement: 'element-1', + selectedToplevelNodes: ['element-1'], resolvedArgs: { 'element-0': [ { @@ -148,7 +148,7 @@ describe('resolved args reducer', () => { it('removes expression context from a given index to the end', () => { state = { selectedPage: 'page-1', - selectedElement: 'element-1', + selectedToplevelNodes: ['element-1'], resolvedArgs: { 'element-1': { expressionContext: { diff --git a/x-pack/plugins/canvas/public/state/reducers/pages.js b/x-pack/plugins/canvas/public/state/reducers/pages.js index b7c46cee09b5..0adc54561f88 100644 --- a/x-pack/plugins/canvas/public/state/reducers/pages.js +++ b/x-pack/plugins/canvas/public/state/reducers/pages.js @@ -11,13 +11,12 @@ import { getId } from '../../lib/get_id'; import { routerProvider } from '../../lib/router_provider'; import { getDefaultPage } from '../defaults'; import * as actions from '../actions/pages'; +import { getSelectedPageIndex } from '../selectors/workpad'; -function setPageIndex(workpadState, index) { - if (index < 0 || !workpadState.pages[index]) { - return workpadState; - } - return set(workpadState, 'page', index); -} +const setPageIndex = (workpadState, index) => + index < 0 || !workpadState.pages[index] || getSelectedPageIndex(workpadState) === index + ? workpadState + : set(workpadState, 'page', index); function getPageIndexById(workpadState, id) { return workpadState.pages.findIndex(page => page.id === id); diff --git a/x-pack/plugins/canvas/public/state/reducers/transient.js b/x-pack/plugins/canvas/public/state/reducers/transient.js index 157fafb84d3d..bb40404da488 100644 --- a/x-pack/plugins/canvas/public/state/reducers/transient.js +++ b/x-pack/plugins/canvas/public/state/reducers/transient.js @@ -7,7 +7,8 @@ import { handleActions } from 'redux-actions'; import { set, del } from 'object-path-immutable'; import { restoreHistory } from '../actions/history'; -import * as actions from '../actions/transient'; +import * as pageActions from '../actions/pages'; +import * as transientActions from '../actions/transient'; import { removeElements } from '../actions/elements'; import { setRefreshInterval } from '../actions/workpad'; @@ -18,39 +19,51 @@ export const transientReducer = handleActions( [restoreHistory]: transientState => set(transientState, 'resolvedArgs', {}), [removeElements]: (transientState, { payload: { elementIds } }) => { - const { selectedElement } = transientState; + const { selectedToplevelNodes } = transientState; return del( { ...transientState, - selectedElement: elementIds.indexOf(selectedElement) === -1 ? selectedElement : null, + selectedToplevelNodes: selectedToplevelNodes.filter(n => elementIds.indexOf(n) < 0), }, ['resolvedArgs', elementIds] ); }, - [actions.setCanUserWrite]: (transientState, { payload }) => { + [transientActions.setCanUserWrite]: (transientState, { payload }) => { return set(transientState, 'canUserWrite', Boolean(payload)); }, - [actions.setFirstLoad]: (transientState, { payload }) => { + [transientActions.setFirstLoad]: (transientState, { payload }) => { return set(transientState, 'isFirstLoad', Boolean(payload)); }, - [actions.setFullscreen]: (transientState, { payload }) => { + [transientActions.setFullscreen]: (transientState, { payload }) => { return set(transientState, 'fullscreen', Boolean(payload)); }, - [actions.setElementStats]: (transientState, { payload }) => { + [transientActions.setElementStats]: (transientState, { payload }) => { return set(transientState, 'elementStats', payload); }, - [actions.selectElement]: (transientState, { payload }) => { + [transientActions.selectToplevelNodes]: (transientState, { payload }) => { return { ...transientState, - selectedElement: payload || null, + selectedToplevelNodes: payload, }; }, + [pageActions.setPage]: transientState => { + return { ...transientState, selectedToplevelNodes: [] }; + }, + + [pageActions.addPage]: transientState => { + return { ...transientState, selectedToplevelNodes: [] }; + }, + + [pageActions.duplicatePage]: transientState => { + return { ...transientState, selectedToplevelNodes: [] }; + }, + [setRefreshInterval]: (transientState, { payload }) => { return { ...transientState, refresh: { interval: Number(payload) || 0 } }; }, diff --git a/x-pack/plugins/canvas/public/state/selectors/__tests__/workpad.js b/x-pack/plugins/canvas/public/state/selectors/__tests__/workpad.js index bd56e1cd6e1d..5adf16d131b4 100644 --- a/x-pack/plugins/canvas/public/state/selectors/__tests__/workpad.js +++ b/x-pack/plugins/canvas/public/state/selectors/__tests__/workpad.js @@ -70,7 +70,7 @@ describe('workpad selectors', () => { state = { transient: { - selectedElement: 'element-1', + selectedToplevelNodes: ['element-1'], resolvedArgs: { 'element-0': 'test resolved arg, el 0', 'element-1': 'test resolved arg, el 1', @@ -128,7 +128,6 @@ describe('workpad selectors', () => { expect(selector.getSelectedPage({})).to.be(undefined); expect(selector.getPageById({}, 'page-1')).to.be(undefined); expect(selector.getSelectedElement({})).to.be(undefined); - expect(selector.getSelectedElementId({})).to.be(undefined); expect(selector.getElementById({}, 'element-1')).to.be(undefined); expect(selector.getResolvedArgs({}, 'element-1')).to.be(undefined); expect(selector.getSelectedResolvedArgs({})).to.be(undefined); @@ -168,12 +167,6 @@ describe('workpad selectors', () => { }); }); - describe('getSelectedElementId', () => { - it('returns selected element id', () => { - expect(selector.getSelectedElementId(state)).to.equal('element-1'); - }); - }); - describe('getElements', () => { it('is an empty array with no state', () => { expect(selector.getElements({})).to.eql([]); @@ -225,7 +218,7 @@ describe('workpad selectors', () => { ...state, transient: { ...state.transient, - selectedElement: 'element-2', + selectedToplevelNodes: ['element-2'], }, }; const arg = selector.getSelectedResolvedArgs(tmpState, 'example2'); @@ -237,7 +230,7 @@ describe('workpad selectors', () => { ...state, transient: { ...state.transient, - selectedElement: 'element-2', + selectedToplevelNodes: ['element-2'], }, }; const arg = selector.getSelectedResolvedArgs(tmpState, ['example3', 'deeper', 'object']); diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.js b/x-pack/plugins/canvas/public/state/selectors/workpad.js index 103bed6e5283..6959fb8eeeca 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.js +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.js @@ -128,8 +128,9 @@ export function getGlobalFilterExpression(state) { } // element getters -export function getSelectedElementId(state) { - return get(state, 'transient.selectedElement'); +function getSelectedElementId(state) { + const toplevelNodes = get(state, 'transient.selectedToplevelNodes') || []; + return toplevelNodes.length === 1 ? toplevelNodes[0] : null; } export function getSelectedElement(state) { @@ -170,14 +171,7 @@ const getNodesOfPage = page => .map(augment('element')) .concat((get(page, 'groups') || []).map(augment('group'))); -// todo unify or DRY up with `getElements` -export function getNodes(state, pageId, withAst = true) { - const id = pageId || getSelectedPage(state); - if (!id) { - return []; - } - - const page = getPageById(state, id); +export const getNodesForPage = (page, withAst) => { const elements = getNodesOfPage(page); if (!elements) { @@ -192,6 +186,16 @@ export function getNodes(state, pageId, withAst = true) { } return elements.map(appendAst); +}; + +// todo unify or DRY up with `getElements` +export function getNodes(state, pageId, withAst = true) { + const id = pageId || getSelectedPage(state); + if (!id) { + return []; + } + + return getNodesForPage(getPageById(state, id), withAst); } export function getElementById(state, id, pageId) {