[Canvas] Layout engine integration simplification (#33702)
* Refactor: layout engine integration
This commit is contained in:
parent
8444dd9472
commit
b7871a5532
|
@ -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([]));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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([]));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
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(),
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
const simplePositioning = ({ elements }) => ({
|
||||
elements: elements.map((element, i) => {
|
||||
const { type, subtype, transformMatrix } = elementToShape(element, i);
|
||||
return {
|
||||
elements,
|
||||
cursor,
|
||||
selectedElementIds,
|
||||
selectedElements,
|
||||
selectedPrimaryShapes,
|
||||
commit: (...args) => {
|
||||
aeroelastic.commit(page.id, ...args);
|
||||
forceUpdate();
|
||||
},
|
||||
};
|
||||
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,
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
@ -37,7 +37,6 @@ import {
|
|||
mean,
|
||||
not,
|
||||
removeDuplicates,
|
||||
shallowEqual,
|
||||
} from './functional';
|
||||
|
||||
import { getId as rawGetId } from './../../lib/get_id';
|
||||
|
@ -136,12 +135,7 @@ export const getMouseTransformGesture = tuple =>
|
|||
.filter(tpl => tpl.transform)
|
||||
.map(({ transform, cumulativeTransform }) => ({ transform, cumulativeTransform }));
|
||||
|
||||
export const getRestateShapesEvent = action => {
|
||||
if (!action || action.type !== 'restateShapesEvent') {
|
||||
return null;
|
||||
}
|
||||
const shapes = action.payload.newShapes;
|
||||
const local = shape => {
|
||||
export const getLocalTransformMatrix = shapes => shape => {
|
||||
if (!shape.parent) {
|
||||
return shape.transformMatrix;
|
||||
}
|
||||
|
@ -150,20 +144,9 @@ export const getRestateShapesEvent = action => {
|
|||
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
|
||||
|
||||
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,44 +1374,30 @@ export const getAnnotatedShapes = (
|
|||
}; // collection of shapes themselves
|
||||
|
||||
export const getNextScene = (
|
||||
config,
|
||||
configuration,
|
||||
hoveredShape,
|
||||
selectedShapeIds,
|
||||
selectedPrimaryShapes,
|
||||
shapes,
|
||||
gestureEnd,
|
||||
draggedShape,
|
||||
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,
|
||||
};
|
||||
};
|
||||
gestureState
|
||||
) => ({
|
||||
configuration,
|
||||
hoveredShape,
|
||||
selectedShapes,
|
||||
selectedPrimaryShapes,
|
||||
shapes,
|
||||
gestureEnd,
|
||||
draggedShape,
|
||||
cursor,
|
||||
selectionState,
|
||||
mouseTransformState,
|
||||
gestureState,
|
||||
});
|
||||
|
||||
export const updaterFun = (nextScene, primaryUpdate) => ({
|
||||
primaryUpdate,
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
};
|
|
@ -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]));
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -21,7 +21,7 @@ export const getInitialState = path => {
|
|||
error: 0,
|
||||
},
|
||||
fullscreen: false,
|
||||
selectedElement: null,
|
||||
selectedToplevelNodes: [],
|
||||
resolvedArgs: {},
|
||||
refresh: {
|
||||
interval: 0,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 } };
|
||||
},
|
||||
|
|
|
@ -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']);
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue