[Canvas] Layout engine integration simplification (#33702)

* Refactor: layout engine integration
This commit is contained in:
Robert Monfera 2019-04-11 20:57:39 +02:00 committed by GitHub
parent 8444dd9472
commit b7871a5532
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 817 additions and 1161 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -131,7 +131,7 @@ export class Workpad extends React.PureComponent {
{pages.map((page, i) => (
<WorkpadPage
key={page.id}
page={page}
pageId={page.id}
height={height}
width={width}
isSelected={i + 1 === selectedPageNumber}

View file

@ -44,27 +44,26 @@ const setupHandler = (commit, canvasOrigin) => {
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(),
};

View file

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

View file

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

View file

@ -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 = <WorkpadShortcuts {...shortcutProps} />;
return (
<div
key={pageId}
id={pageId}
ref={node => {
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 <AlignmentGuide {...props} />;
case 'adHocChildAnnotation': // now sharing aesthetics but may diverge in the future
case 'hoverAnnotation': // fixme: with the upcoming TS work, use enumerative types here
return <HoverAnnotation {...props} />;
case 'rotationHandle':
return <RotationHandle {...props} />;
case 'resizeHandle':
return <BorderResizeHandle {...props} />;
case 'resizeConnector':
return <BorderConnection {...props} />;
case 'rotationTooltip':
return <TooltipAnnotation {...props} />;
default:
return [];
}
} else if (node.type !== 'group') {
return <ElementWrapper key={node.id} element={node} />;
}
})
.filter(element => !!element)}
</div>
);
}
}

View file

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

View file

@ -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 (
<div
key={pageId}
id={pageId}
data-test-subj="canvasWorkpadPage"
className={`canvasPage ${className}`}
data-shared-items-container
style={{ ...pageStyle, ...animationStyle, height, width }}
>
{elements.map(element => (
<ElementWrapper key={element.id} element={element} />
))}
</div>
);
}
}

View file

@ -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 = <WorkpadShortcuts {...shortcutProps} />;
}
return (
<div
key={page.id}
id={page.id}
ref={element => {
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 <AlignmentGuide {...props} />;
case 'adHocChildAnnotation': // now sharing aesthetics but may diverge in the future
case 'hoverAnnotation': // fixme: with the upcoming TS work, use enumerative types here
return <HoverAnnotation {...props} />;
case 'rotationHandle':
return <RotationHandle {...props} />;
case 'resizeHandle':
return <BorderResizeHandle {...props} />;
case 'resizeConnector':
return <BorderConnection {...props} />;
case 'rotationTooltip':
return <TooltipAnnotation {...props} />;
default:
return [];
}
} else if (element.type !== 'group') {
return <ElementWrapper key={element.id} element={element} />;
}
})
.filter(element => !!element)}
</div>
);
}
}

View file

@ -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<Props> {
public render() {
const { pageId, forceUpdate } = this.props;
return (
<Shortcuts
name="ELEMENT"
handler={(action: string, event: Event) => {
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<Props> {
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);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@ export const getInitialState = path => {
error: 0,
},
fullscreen: false,
selectedElement: null,
selectedToplevelNodes: [],
resolvedArgs: {},
refresh: {
interval: 0,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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