[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 { connect } from 'react-redux';
|
||||||
import { compose, branch, renderComponent } from 'recompose';
|
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 { canUserWrite, getAppReady } from '../../../state/selectors/app';
|
||||||
import { getWorkpad, isWriteable } from '../../../state/selectors/workpad';
|
import { getWorkpad, isWriteable } from '../../../state/selectors/workpad';
|
||||||
import { LoadWorkpad } from './load_workpad';
|
import { LoadWorkpad } from './load_workpad';
|
||||||
|
@ -25,7 +25,7 @@ const mapStateToProps = state => {
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
deselectElement(ev) {
|
deselectElement(ev) {
|
||||||
ev && ev.stopPropagation();
|
ev && ev.stopPropagation();
|
||||||
dispatch(selectElement(null));
|
dispatch(selectToplevelNodes([]));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -37,9 +37,12 @@ export class DomPreview extends React.Component {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.observer) {
|
const currentOriginal = document.querySelector(`#${this.props.elementId}`);
|
||||||
this.original = this.original || document.querySelector(`#${this.props.elementId}`);
|
const originalChanged = currentOriginal !== this.original;
|
||||||
if (this.original) {
|
if (originalChanged) {
|
||||||
|
this.observer && this.observer.disconnect();
|
||||||
|
this.original = currentOriginal;
|
||||||
|
if (currentOriginal) {
|
||||||
const slowUpdate = debounce(this.update, 100);
|
const slowUpdate = debounce(this.update, 100);
|
||||||
this.observer = new MutationObserver(slowUpdate);
|
this.observer = new MutationObserver(slowUpdate);
|
||||||
// configuration of the observer
|
// configuration of the observer
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
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 { getFullscreen } from '../../state/selectors/app';
|
||||||
import { FullscreenControl as Component } from './fullscreen_control';
|
import { FullscreenControl as Component } from './fullscreen_control';
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ const mapStateToProps = state => ({
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
setFullscreen: value => {
|
setFullscreen: value => {
|
||||||
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 { cloneSubgraphs } from '../../lib/clone_subgraphs';
|
||||||
import { insertNodes, elementLayer } from '../../state/actions/elements';
|
import { insertNodes, elementLayer } from '../../state/actions/elements';
|
||||||
import { getSelectedPage, getSelectedElement } from '../../state/selectors/workpad';
|
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';
|
import { Sidebar as Component } from './sidebar';
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ const mapDispatchToProps = dispatch => ({
|
||||||
// todo: more unification w/ copy/paste; group cloning
|
// todo: more unification w/ copy/paste; group cloning
|
||||||
const newElements = cloneSubgraphs([selectedElement]);
|
const newElements = cloneSubgraphs([selectedElement]);
|
||||||
dispatch(insertNodes(newElements, pageId));
|
dispatch(insertNodes(newElements, pageId));
|
||||||
dispatch(selectElement(newElements[0].id));
|
dispatch(selectToplevelNodes(newElements.map(e => e.id)));
|
||||||
},
|
},
|
||||||
elementLayer: (pageId, selectedElement) => movement =>
|
elementLayer: (pageId, selectedElement) => movement =>
|
||||||
dispatch(
|
dispatch(
|
||||||
|
|
|
@ -131,7 +131,7 @@ export class Workpad extends React.PureComponent {
|
||||||
{pages.map((page, i) => (
|
{pages.map((page, i) => (
|
||||||
<WorkpadPage
|
<WorkpadPage
|
||||||
key={page.id}
|
key={page.id}
|
||||||
page={page}
|
pageId={page.id}
|
||||||
height={height}
|
height={height}
|
||||||
width={width}
|
width={width}
|
||||||
isSelected={i + 1 === selectedPageNumber}
|
isSelected={i + 1 === selectedPageNumber}
|
||||||
|
|
|
@ -44,27 +44,26 @@ const setupHandler = (commit, canvasOrigin) => {
|
||||||
const handleMouseMove = (
|
const handleMouseMove = (
|
||||||
commit,
|
commit,
|
||||||
{ clientX, clientY, altKey, metaKey, shiftKey, ctrlKey },
|
{ clientX, clientY, altKey, metaKey, shiftKey, ctrlKey },
|
||||||
isEditable,
|
|
||||||
canvasOrigin
|
canvasOrigin
|
||||||
) => {
|
) => {
|
||||||
if (isEditable) {
|
const { x, y } = localMousePosition(canvasOrigin, clientX, clientY);
|
||||||
const { x, y } = localMousePosition(canvasOrigin, clientX, clientY);
|
if (commit) {
|
||||||
commit('cursorPosition', { x, y, altKey, metaKey, shiftKey, ctrlKey });
|
commit('cursorPosition', { x, y, altKey, metaKey, shiftKey, ctrlKey });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseLeave = (commit, { buttons }) => {
|
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)
|
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();
|
e.stopPropagation();
|
||||||
const { clientX, clientY, buttons, altKey, metaKey, shiftKey, ctrlKey } = e;
|
const { clientX, clientY, buttons, altKey, metaKey, shiftKey, ctrlKey } = e;
|
||||||
if (buttons !== 1 || !isEditable) {
|
if (buttons !== 1 || !commit) {
|
||||||
resetHandler();
|
resetHandler();
|
||||||
return; // left-click and edit mode only
|
return; // left-click only
|
||||||
}
|
}
|
||||||
setupHandler(commit, canvasOrigin);
|
setupHandler(commit, canvasOrigin);
|
||||||
const { x, y } = localMousePosition(canvasOrigin, clientX, clientY);
|
const { x, y } = localMousePosition(canvasOrigin, clientX, clientY);
|
||||||
|
@ -72,9 +71,9 @@ const handleMouseDown = (commit, e, isEditable, canvasOrigin) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const eventHandlers = {
|
export const eventHandlers = {
|
||||||
onMouseDown: props => e => handleMouseDown(props.commit, e, props.isEditable, props.canvasOrigin),
|
onMouseDown: props => e => handleMouseDown(props.commit, e, props.canvasOrigin),
|
||||||
onMouseMove: props => e => handleMouseMove(props.commit, e, props.isEditable, props.canvasOrigin),
|
onMouseMove: props => e => handleMouseMove(props.commit, e, props.canvasOrigin),
|
||||||
onMouseLeave: props => e => handleMouseLeave(props.commit, e),
|
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(),
|
resetHandler: () => () => resetHandler(),
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,53 +4,54 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* 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 { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { compose, withState, withProps, withHandlers } from 'recompose';
|
import { branch, compose, withHandlers, withProps, withState, shouldUpdate } from 'recompose';
|
||||||
import { aeroelastic } from '../../lib/aeroelastic_kibana';
|
import { elementLayer, insertNodes, removeElements } from '../../state/actions/elements';
|
||||||
import { removeElements, insertNodes, elementLayer } from '../../state/actions/elements';
|
import { canUserWrite, getFullscreen } from '../../state/selectors/app';
|
||||||
import { getFullscreen, canUserWrite } from '../../state/selectors/app';
|
import { getNodes, getPageById, isWriteable } from '../../state/selectors/workpad';
|
||||||
import { getNodes, isWriteable } from '../../state/selectors/workpad';
|
import { updater } from '../../lib/aeroelastic/layout';
|
||||||
import { flatten } from '../../lib/aeroelastic/functional';
|
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 { eventHandlers } from './event_handlers';
|
||||||
import { WorkpadPage as Component } from './workpad_page';
|
import { InteractiveWorkpadPage as InteractiveComponent } from './interactive_workpad_page';
|
||||||
import { selectElement } from './../../state/actions/transient';
|
import { StaticWorkpadPage as StaticComponent } from './static_workpad_page';
|
||||||
|
import { selectToplevelNodes } from './../../state/actions/transient';
|
||||||
|
|
||||||
const mapStateToProps = (state, ownProps) => {
|
const configuration = {
|
||||||
return {
|
getAdHocChildAnnotationName: 'adHocChildAnnotation',
|
||||||
isEditable: !getFullscreen(state) && isWriteable(state) && canUserWrite(state),
|
adHocGroupName: 'adHocGroup',
|
||||||
elements: getNodes(state, ownProps.page.id),
|
alignmentGuideName: 'alignmentGuide',
|
||||||
};
|
atopZ: 1000,
|
||||||
};
|
depthSelect: true,
|
||||||
|
devColor: 'magenta',
|
||||||
const mapDispatchToProps = dispatch => {
|
groupName: 'group',
|
||||||
return {
|
groupResize: true,
|
||||||
insertNodes: pageId => selectedElements => dispatch(insertNodes(selectedElements, pageId)),
|
guideDistance: 3,
|
||||||
removeElements: pageId => elementIds => dispatch(removeElements(elementIds, pageId)),
|
hoverAnnotationName: 'hoverAnnotation',
|
||||||
selectElement: selectedElement => dispatch(selectElement(selectedElement)),
|
hoverLift: 100,
|
||||||
// TODO: Abstract this out. This is the same code as in sidebar/index.js
|
intraGroupManipulation: false,
|
||||||
elementLayer: (pageId, selectedElement, movement) => {
|
intraGroupSnapOnly: false,
|
||||||
dispatch(
|
minimumElementSize: 2,
|
||||||
elementLayer({
|
persistentGroupName: 'persistentGroup',
|
||||||
pageId,
|
resizeAnnotationConnectorOffset: 0,
|
||||||
elementId: selectedElement.id,
|
resizeAnnotationOffset: 0,
|
||||||
movement,
|
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,
|
||||||
// eslint-disable-next-line
|
rotationHandleName: 'rotationHandle',
|
||||||
const getRootElementId = (lookup, id) => {
|
rotationHandleSize: 14,
|
||||||
if (!lookup.has(id)) {
|
rotationTooltipName: 'rotationTooltip',
|
||||||
return null;
|
shortcuts: false,
|
||||||
}
|
singleSelect: false,
|
||||||
|
snapConstraint: true,
|
||||||
const element = lookup.get(id);
|
tooltipZ: 1100,
|
||||||
return element.parent && element.parent.subtype !== 'adHocGroup'
|
|
||||||
? getRootElementId(lookup, element.parent)
|
|
||||||
: element.id;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const animationProps = ({ isSelected, animation }) => {
|
const animationProps = ({ isSelected, animation }) => {
|
||||||
|
@ -78,84 +79,156 @@ const animationProps = ({ isSelected, animation }) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const layoutProps = ({ forceUpdate, page, elements: pageElements }) => {
|
const simplePositioning = ({ elements }) => ({
|
||||||
const { shapes, selectedPrimaryShapes = [], cursor } = aeroelastic.getStore(page.id).currentScene;
|
elements: elements.map((element, i) => {
|
||||||
const elementLookup = new Map(pageElements.map(element => [element.id, element]));
|
const { type, subtype, transformMatrix } = elementToShape(element, i);
|
||||||
const recurseGroupTree = shapeId => {
|
return {
|
||||||
return [
|
id: element.id,
|
||||||
shapeId,
|
filter: element.filter,
|
||||||
...flatten(
|
width: element.position.width,
|
||||||
shapes
|
height: element.position.height,
|
||||||
.filter(s => s.parent === shapeId && s.type !== 'annotation')
|
type,
|
||||||
.map(s => s.id)
|
subtype,
|
||||||
.map(recurseGroupTree)
|
transformMatrix,
|
||||||
),
|
};
|
||||||
];
|
}),
|
||||||
};
|
});
|
||||||
|
|
||||||
const selectedPrimaryShapeObjects = selectedPrimaryShapes
|
|
||||||
.map(id => shapes.find(s => s.id === id))
|
|
||||||
.filter(shape => shape);
|
|
||||||
|
|
||||||
const selectedPersistentPrimaryShapes = flatten(
|
|
||||||
selectedPrimaryShapeObjects.map(shape =>
|
|
||||||
shape.subtype === 'adHocGroup'
|
|
||||||
? shapes.filter(s => s.parent === shape.id && s.type !== 'annotation').map(s => s.id)
|
|
||||||
: [shape.id]
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const selectedElementIds = flatten(selectedPersistentPrimaryShapes.map(recurseGroupTree));
|
|
||||||
const selectedElements = [];
|
|
||||||
const elements = shapes.map(shape => {
|
|
||||||
let element = null;
|
|
||||||
if (elementLookup.has(shape.id)) {
|
|
||||||
element = elementLookup.get(shape.id);
|
|
||||||
if (selectedElementIds.indexOf(shape.id) > -1) {
|
|
||||||
selectedElements.push({ ...element, id: shape.id });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// instead of just combining `element` with `shape`, we make property transfer explicit
|
|
||||||
return element ? { ...shape, filter: element.filter, expression: element.expression } : shape;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
elements,
|
|
||||||
cursor,
|
|
||||||
selectedElementIds,
|
|
||||||
selectedElements,
|
|
||||||
selectedPrimaryShapes,
|
|
||||||
commit: (...args) => {
|
|
||||||
aeroelastic.commit(page.id, ...args);
|
|
||||||
forceUpdate();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupHandlerCreators = {
|
const groupHandlerCreators = {
|
||||||
groupElements: ({ commit }) => () =>
|
groupNodes: ({ commit }) => () => commit('actionEvent', { event: 'group' }),
|
||||||
commit('actionEvent', {
|
ungroupNodes: ({ commit }) => () => commit('actionEvent', { event: 'ungroup' }),
|
||||||
event: 'group',
|
|
||||||
}),
|
|
||||||
ungroupElements: ({ 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(
|
export const WorkpadPage = compose(
|
||||||
|
shouldUpdate(not(isEqual)), // this is critical, else random unrelated rerenders in the parent cause glitches here
|
||||||
|
withProps(animationProps),
|
||||||
connect(
|
connect(
|
||||||
mapStateToProps,
|
mapStateToProps,
|
||||||
mapDispatchToProps
|
mapDispatchToProps,
|
||||||
|
mergeProps
|
||||||
),
|
),
|
||||||
withProps(animationProps),
|
branch(({ isInteractive }) => isInteractive, InteractivePage, StaticPage)
|
||||||
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);
|
|
||||||
|
|
||||||
WorkpadPage.propTypes = {
|
WorkpadPage.propTypes = {
|
||||||
page: PropTypes.shape({
|
pageId: PropTypes.string.isRequired,
|
||||||
id: PropTypes.string.isRequired,
|
|
||||||
}).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.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import _ from 'lodash';
|
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
import isEqual from 'react-fast-compare';
|
import isEqual from 'react-fast-compare';
|
||||||
|
@ -19,29 +18,95 @@ import { notify } from '../../lib/notify';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
selectedElementIds: string[];
|
selectedNodes: any[];
|
||||||
selectedElements: any[];
|
selectToplevelNodes: (...nodeIds: string[]) => void;
|
||||||
selectedPrimaryShapes: any[];
|
insertNodes: (pageId: string) => (selectedNodes: any[]) => void;
|
||||||
selectElement: (elementId: string) => void;
|
removeNodes: (pageId: string) => (selectedNodeIds: string[]) => void;
|
||||||
insertNodes: (pageId: string) => (selectedElements: any[]) => void;
|
elementLayer: (pageId: string, selectedNode: any, movement: any) => void;
|
||||||
removeElements: (pageId: string) => (selectedElementIds: string[]) => void;
|
groupNodes: () => void;
|
||||||
elementLayer: (pageId: string, selectedElement: any, movement: any) => void;
|
ungroupNodes: () => void;
|
||||||
groupElements: () => void;
|
|
||||||
ungroupElements: () => void;
|
|
||||||
forceUpdate: () => 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> {
|
export class WorkpadShortcuts extends Component<Props> {
|
||||||
public render() {
|
public render() {
|
||||||
const { pageId, forceUpdate } = this.props;
|
|
||||||
return (
|
return (
|
||||||
<Shortcuts
|
<Shortcuts
|
||||||
name="ELEMENT"
|
name="ELEMENT"
|
||||||
handler={(action: string, event: Event) => {
|
handler={(action: string, event: Event) => {
|
||||||
this._keyHandler(action, event);
|
event.preventDefault();
|
||||||
forceUpdate();
|
keyMap[action](this.props);
|
||||||
}}
|
}}
|
||||||
targetNodeSelector={`#${pageId}`}
|
targetNodeSelector={`#${this.props.pageId}`}
|
||||||
global
|
global
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -50,164 +115,4 @@ export class WorkpadShortcuts extends Component<Props> {
|
||||||
public shouldComponentUpdate(nextProps: Props) {
|
public shouldComponentUpdate(nextProps: Props) {
|
||||||
return !isEqual(nextProps, this.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 {
|
return {
|
||||||
transient: {
|
transient: {
|
||||||
selectedPage: 'page-f3ce-4bb7-86c8-0417606d6592',
|
selectedPage: 'page-f3ce-4bb7-86c8-0417606d6592',
|
||||||
selectedElement: 'element-d88c-4bbd-9453-db22e949b92e',
|
selectedToplevelNodes: ['element-d88c-4bbd-9453-db22e949b92e'],
|
||||||
resolvedArgs: {},
|
resolvedArgs: {},
|
||||||
},
|
},
|
||||||
persistent: {
|
persistent: {
|
||||||
|
|
|
@ -27,17 +27,7 @@ const appleKeyboard = Boolean(
|
||||||
|
|
||||||
const primaryUpdate = state => state.primaryUpdate;
|
const primaryUpdate = state => state.primaryUpdate;
|
||||||
|
|
||||||
const gestureStatePrev = select(
|
const gestureStatePrev = select(scene => scene.gestureState)(scene);
|
||||||
scene =>
|
|
||||||
scene.gestureState || {
|
|
||||||
cursor: {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
},
|
|
||||||
mouseIsDown: false,
|
|
||||||
mouseButtonState: { buttonState: 'up', downX: null, downY: null },
|
|
||||||
}
|
|
||||||
)(scene);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gestures - derived selectors for transient state
|
* Gestures - derived selectors for transient state
|
||||||
|
|
|
@ -40,14 +40,6 @@ export type PlainFun = (...args: Json[]) => Json;
|
||||||
export type Selector = (...fns: Resolve[]) => Resolve;
|
export type Selector = (...fns: Resolve[]) => Resolve;
|
||||||
type Resolve = ((obj: State) => Json);
|
type Resolve = ((obj: State) => Json);
|
||||||
|
|
||||||
//
|
|
||||||
export interface Meta {
|
|
||||||
silent: boolean;
|
|
||||||
}
|
|
||||||
export type TypeName = string;
|
export type TypeName = string;
|
||||||
export type Payload = JsonMap;
|
export type Payload = JsonMap;
|
||||||
export type UpdaterFunction = (arg: State) => State;
|
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,
|
getConfiguration,
|
||||||
getConstrainedShapesWithPreexistingAnnotations,
|
getConstrainedShapesWithPreexistingAnnotations,
|
||||||
getCursor,
|
getCursor,
|
||||||
getDirectSelect,
|
|
||||||
getDraggedPrimaryShape,
|
getDraggedPrimaryShape,
|
||||||
getFocusedShape,
|
getFocusedShape,
|
||||||
getGroupAction,
|
getGroupAction,
|
||||||
|
@ -48,9 +47,7 @@ import {
|
||||||
getMouseTransformGesturePrev,
|
getMouseTransformGesturePrev,
|
||||||
getMouseTransformState,
|
getMouseTransformState,
|
||||||
getNextScene,
|
getNextScene,
|
||||||
getNextShapes,
|
|
||||||
getResizeManipulator,
|
getResizeManipulator,
|
||||||
getRestateShapesEvent,
|
|
||||||
getRotationAnnotations,
|
getRotationAnnotations,
|
||||||
getRotationTooltipAnnotation,
|
getRotationTooltipAnnotation,
|
||||||
getSelectedPrimaryShapeIds,
|
getSelectedPrimaryShapeIds,
|
||||||
|
@ -58,6 +55,7 @@ import {
|
||||||
getSelectedShapes,
|
getSelectedShapes,
|
||||||
getSelectedShapesPrev,
|
getSelectedShapesPrev,
|
||||||
getSelectionState,
|
getSelectionState,
|
||||||
|
getSelectionStateFull,
|
||||||
getShapes,
|
getShapes,
|
||||||
getSnappedShapes,
|
getSnappedShapes,
|
||||||
getTransformIntents,
|
getTransformIntents,
|
||||||
|
@ -95,28 +93,23 @@ const mouseTransformGesture = select(getMouseTransformGesture)(mouseTransformSta
|
||||||
|
|
||||||
const transformGestures = mouseTransformGesture;
|
const transformGestures = mouseTransformGesture;
|
||||||
|
|
||||||
const restateShapesEvent = select(getRestateShapesEvent)(primaryUpdate);
|
const selectedShapeObjects = select(getSelectedShapeObjects)(scene, shapes);
|
||||||
|
|
||||||
// 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 selectedShapesPrev = select(getSelectedShapesPrev)(scene);
|
const selectedShapesPrev = select(getSelectedShapesPrev)(scene);
|
||||||
|
|
||||||
const selectionState = select(getSelectionState)(
|
const selectionStateFull = select(getSelectionStateFull)(
|
||||||
selectedShapesPrev,
|
selectedShapesPrev,
|
||||||
configuration,
|
configuration,
|
||||||
selectedShapeObjects,
|
selectedShapeObjects,
|
||||||
hoveredShapes,
|
hoveredShapes,
|
||||||
mouseButton,
|
mouseButton,
|
||||||
metaHeld,
|
metaHeld,
|
||||||
multiselectModifier,
|
multiselectModifier
|
||||||
directSelect,
|
|
||||||
shapes
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedShapes = select(getSelectedShapes)(selectionState);
|
const selectionState = select(getSelectionState)(selectionStateFull);
|
||||||
|
|
||||||
|
const selectedShapes = select(getSelectedShapes)(selectionStateFull);
|
||||||
|
|
||||||
const selectedPrimaryShapeIds = select(getSelectedPrimaryShapeIds)(selectedShapes); // fixme unify with contentShape
|
const selectedPrimaryShapeIds = select(getSelectedPrimaryShapeIds)(selectedShapes); // fixme unify with contentShape
|
||||||
|
|
||||||
|
@ -134,13 +127,9 @@ const transformIntents = select(getTransformIntents)(
|
||||||
resizeManipulator
|
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 draggedPrimaryShape = select(getDraggedPrimaryShape)(shapes, draggedShape);
|
||||||
|
|
||||||
const transformedShapes = select(applyLocalTransforms)(nextShapes, transformIntents);
|
|
||||||
|
|
||||||
const draggedPrimaryShape = select(getDraggedPrimaryShape)(nextShapes, draggedShape);
|
|
||||||
|
|
||||||
const alignmentGuideAnnotations = select(getAlignmentGuideAnnotations)(
|
const alignmentGuideAnnotations = select(getAlignmentGuideAnnotations)(
|
||||||
configuration,
|
configuration,
|
||||||
|
@ -239,7 +228,6 @@ export const nextScene = select(getNextScene)(
|
||||||
cursor,
|
cursor,
|
||||||
selectionState,
|
selectionState,
|
||||||
mouseTransformState,
|
mouseTransformState,
|
||||||
groupedSelectedShapes,
|
|
||||||
gestureState
|
gestureState
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,6 @@ import {
|
||||||
mean,
|
mean,
|
||||||
not,
|
not,
|
||||||
removeDuplicates,
|
removeDuplicates,
|
||||||
shallowEqual,
|
|
||||||
} from './functional';
|
} from './functional';
|
||||||
|
|
||||||
import { getId as rawGetId } from './../../lib/get_id';
|
import { getId as rawGetId } from './../../lib/get_id';
|
||||||
|
@ -136,34 +135,18 @@ export const getMouseTransformGesture = tuple =>
|
||||||
.filter(tpl => tpl.transform)
|
.filter(tpl => tpl.transform)
|
||||||
.map(({ transform, cumulativeTransform }) => ({ transform, cumulativeTransform }));
|
.map(({ transform, cumulativeTransform }) => ({ transform, cumulativeTransform }));
|
||||||
|
|
||||||
export const getRestateShapesEvent = action => {
|
export const getLocalTransformMatrix = shapes => shape => {
|
||||||
if (!action || action.type !== 'restateShapesEvent') {
|
if (!shape.parent) {
|
||||||
return null;
|
return shape.transformMatrix;
|
||||||
}
|
}
|
||||||
const shapes = action.payload.newShapes;
|
return multiply(
|
||||||
const local = shape => {
|
invert(shapes.find(s => s.id === shape.parent).transformMatrix),
|
||||||
if (!shape.parent) {
|
shape.transformMatrix
|
||||||
return shape.transformMatrix;
|
);
|
||||||
}
|
};
|
||||||
return multiply(
|
|
||||||
invert(shapes.find(s => s.id === shape.parent).transformMatrix),
|
|
||||||
shape.transformMatrix
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const newShapes = shapes.map(s => ({ ...s, localTransformMatrix: local(s) }));
|
|
||||||
return { newShapes, uid: action.payload.uid };
|
|
||||||
}; // is selected, as otherwise selection is driven by gestures and knowledge of element positions
|
|
||||||
|
|
||||||
export const getDirectSelect = action =>
|
export const getSelectedShapeObjects = (scene, shapes) =>
|
||||||
action && action.type === 'shapeSelect' ? action.payload : null;
|
(scene.selectedShapes || []).map(s => shapes.find(ss => ss.id === s));
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
const contentShape = allShapes => shape =>
|
const contentShape = allShapes => shape =>
|
||||||
shape.type === 'annotation'
|
shape.type === 'annotation'
|
||||||
|
@ -1201,44 +1184,24 @@ export const getCursor = (config, shape, draggedPrimaryShape) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSelectedShapesPrev = scene =>
|
export const getSelectedShapesPrev = scene => scene.selectionState;
|
||||||
scene.selectionState || {
|
|
||||||
shapes: [],
|
|
||||||
uid: null,
|
|
||||||
depthIndex: 0,
|
|
||||||
down: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSelectionState = (
|
export const getSelectionStateFull = (
|
||||||
prev,
|
prev,
|
||||||
config,
|
config,
|
||||||
selectedShapeObjects,
|
selectedShapeObjects,
|
||||||
hoveredShapes,
|
hoveredShapes,
|
||||||
{ down, uid },
|
{ down, uid },
|
||||||
metaHeld,
|
metaHeld,
|
||||||
multiselect,
|
multiselect
|
||||||
directSelect,
|
|
||||||
allShapes
|
|
||||||
) => {
|
) => {
|
||||||
const uidUnchanged = uid === prev.uid;
|
const uidUnchanged = uid === prev.uid;
|
||||||
const mouseButtonUp = !down;
|
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) {
|
if (selectedShapeObjects) {
|
||||||
prev.shapes = selectedShapeObjects.slice();
|
prev.shapes = selectedShapeObjects.slice();
|
||||||
}
|
}
|
||||||
// take action on mouse down only, and if the uid changed (except with directSelect), ie. bail otherwise
|
// 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 };
|
return { ...prev, down, uid, metaHeld };
|
||||||
}
|
}
|
||||||
const selectFunction = config.singleSelect || !multiselect ? singleSelect : multiSelect;
|
const selectFunction = config.singleSelect || !multiselect ? singleSelect : multiSelect;
|
||||||
|
@ -1247,6 +1210,8 @@ export const getSelectionState = (
|
||||||
|
|
||||||
export const getSelectedShapes = selectionTuple => selectionTuple.shapes;
|
export const getSelectedShapes = selectionTuple => selectionTuple.shapes;
|
||||||
|
|
||||||
|
export const getSelectionState = ({ uid, depthIndex, down }) => ({ uid, depthIndex, down });
|
||||||
|
|
||||||
export const getSelectedPrimaryShapeIds = shapes => shapes.map(primaryShape);
|
export const getSelectedPrimaryShapeIds = shapes => shapes.map(primaryShape);
|
||||||
|
|
||||||
export const getResizeManipulator = (config, toggle) =>
|
export const getResizeManipulator = (config, toggle) =>
|
||||||
|
@ -1276,15 +1241,6 @@ export const getTransformIntents = (
|
||||||
...resizeAnnotationManipulation(config, transformGestures, directShapes, shapes, manipulator),
|
...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) =>
|
export const getDraggedPrimaryShape = (shapes, draggedShape) =>
|
||||||
draggedShape && shapes.find(shape => shape.id === primaryShape(draggedShape));
|
draggedShape && shapes.find(shape => shape.id === primaryShape(draggedShape));
|
||||||
|
|
||||||
|
@ -1418,9 +1374,9 @@ export const getAnnotatedShapes = (
|
||||||
}; // collection of shapes themselves
|
}; // collection of shapes themselves
|
||||||
|
|
||||||
export const getNextScene = (
|
export const getNextScene = (
|
||||||
config,
|
configuration,
|
||||||
hoveredShape,
|
hoveredShape,
|
||||||
selectedShapeIds,
|
selectedShapes,
|
||||||
selectedPrimaryShapes,
|
selectedPrimaryShapes,
|
||||||
shapes,
|
shapes,
|
||||||
gestureEnd,
|
gestureEnd,
|
||||||
|
@ -1428,34 +1384,20 @@ export const getNextScene = (
|
||||||
cursor,
|
cursor,
|
||||||
selectionState,
|
selectionState,
|
||||||
mouseTransformState,
|
mouseTransformState,
|
||||||
selectedShapes,
|
|
||||||
gestureState
|
gestureState
|
||||||
) => {
|
) => ({
|
||||||
const selectedLeafShapes = getLeafs(
|
configuration,
|
||||||
shape => shape.type === config.groupName,
|
hoveredShape,
|
||||||
shapes,
|
selectedShapes,
|
||||||
selectionState.shapes
|
selectedPrimaryShapes,
|
||||||
.map(s => (s.type === 'annotation' ? shapes.find(ss => ss.id === s.parent) : s))
|
shapes,
|
||||||
.filter(identity)
|
gestureEnd,
|
||||||
)
|
draggedShape,
|
||||||
.filter(shape => shape.type !== 'annotation')
|
cursor,
|
||||||
.map(s => s.id);
|
selectionState,
|
||||||
return {
|
mouseTransformState,
|
||||||
configuration: config,
|
gestureState,
|
||||||
hoveredShape,
|
});
|
||||||
selectedShapes: selectedShapeIds,
|
|
||||||
selectedLeafShapes,
|
|
||||||
selectedPrimaryShapes,
|
|
||||||
shapes,
|
|
||||||
gestureEnd,
|
|
||||||
draggedShape,
|
|
||||||
cursor,
|
|
||||||
selectionState,
|
|
||||||
gestureState,
|
|
||||||
mouseTransformState,
|
|
||||||
selectedShapeObjects: selectedShapes,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updaterFun = (nextScene, primaryUpdate) => ({
|
export const updaterFun = (nextScene, primaryUpdate) => ({
|
||||||
primaryUpdate,
|
primaryUpdate,
|
||||||
|
|
|
@ -4,39 +4,29 @@
|
||||||
* you may not use this file except in compliance with the Elastic License.
|
* you may not use this file except in compliance with the Elastic License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { ActionId, Payload, State, TypeName, UpdaterFunction } from '.';
|
||||||
ActionId,
|
|
||||||
ChangeCallbackFunction,
|
|
||||||
Meta,
|
|
||||||
Payload,
|
|
||||||
State,
|
|
||||||
TypeName,
|
|
||||||
UpdaterFunction,
|
|
||||||
} from '.';
|
|
||||||
|
|
||||||
let counter = 0 as ActionId;
|
let counter = 0 as ActionId;
|
||||||
|
|
||||||
export const createStore = (
|
export const createStore = (initialState: State, updater: UpdaterFunction) => {
|
||||||
initialState: State,
|
|
||||||
updater: UpdaterFunction,
|
|
||||||
onChangeCallback: ChangeCallbackFunction
|
|
||||||
) => {
|
|
||||||
let currentState = initialState;
|
let currentState = initialState;
|
||||||
|
|
||||||
const getCurrentState = () => currentState;
|
const commit = (type: TypeName, payload: Payload) => {
|
||||||
|
return (currentState = updater({
|
||||||
const commit = (type: TypeName, payload: Payload, meta: Meta = { silent: false }) => {
|
|
||||||
currentState = updater({
|
|
||||||
...currentState,
|
...currentState,
|
||||||
primaryUpdate: {
|
primaryUpdate: {
|
||||||
type,
|
type,
|
||||||
payload: { ...payload, uid: counter++ },
|
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 { notify } from '../../lib/notify';
|
||||||
import { runInterpreter } from '../../lib/run_interpreter';
|
import { runInterpreter } from '../../lib/run_interpreter';
|
||||||
import { subMultitree } from '../../lib/aeroelastic/functional';
|
import { subMultitree } from '../../lib/aeroelastic/functional';
|
||||||
import { selectElement } from './transient';
|
import { selectToplevelNodes } from './transient';
|
||||||
import * as args from './resolved_args';
|
import * as args from './resolved_args';
|
||||||
|
|
||||||
export function getSiblingContext(state, elementId, checkIndex) {
|
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
|
// todo consider doing the group membership collation in aeroelastic, or the Redux reducer, when adding templates
|
||||||
const allElements = getNodes(state, pageId);
|
const allElements = getNodes(state, pageId);
|
||||||
const allRoots = rootElementIds.map(id => allElements.find(e => id === e.id));
|
const allRoots = rootElementIds.map(id => allElements.find(e => id === e.id)).filter(d => d);
|
||||||
if (allRoots.indexOf(undefined) !== -1) {
|
|
||||||
throw new Error('Some of the elements to be deleted do not exist');
|
|
||||||
}
|
|
||||||
const elementIds = subMultitree(e => e.id, e => e.position.parent, allElements, allRoots).map(
|
const elementIds = subMultitree(e => e.id, e => e.position.parent, allElements, allRoots).map(
|
||||||
e => e.id
|
e => e.id
|
||||||
);
|
);
|
||||||
|
@ -236,8 +233,8 @@ export const removeElements = createThunk(
|
||||||
});
|
});
|
||||||
|
|
||||||
const _removeElements = createAction('removeElements', (elementIds, pageId) => ({
|
const _removeElements = createAction('removeElements', (elementIds, pageId) => ({
|
||||||
pageId,
|
|
||||||
elementIds,
|
elementIds,
|
||||||
|
pageId,
|
||||||
}));
|
}));
|
||||||
dispatch(_removeElements(elementIds, pageId));
|
dispatch(_removeElements(elementIds, pageId));
|
||||||
|
|
||||||
|
@ -411,5 +408,5 @@ export const addElement = createThunk('addElement', ({ dispatch }, pageId, eleme
|
||||||
}
|
}
|
||||||
|
|
||||||
// select the new element
|
// 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 setCanUserWrite = createAction('setCanUserWrite');
|
||||||
export const setFullscreen = createAction('setFullscreen');
|
export const setFullscreen = createAction('setFullscreen');
|
||||||
export const selectElement = createAction('selectElement');
|
export const selectToplevelNodes = createAction('selectToplevelNodes');
|
||||||
export const setFirstLoad = createAction('setFirstLoad');
|
export const setFirstLoad = createAction('setFirstLoad');
|
||||||
export const setElementStats = createAction('setElementStats');
|
export const setElementStats = createAction('setElementStats');
|
||||||
|
|
|
@ -21,7 +21,7 @@ export const getInitialState = path => {
|
||||||
error: 0,
|
error: 0,
|
||||||
},
|
},
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
selectedElement: null,
|
selectedToplevelNodes: [],
|
||||||
resolvedArgs: {},
|
resolvedArgs: {},
|
||||||
refresh: {
|
refresh: {
|
||||||
interval: 0,
|
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 { applyMiddleware, compose } from 'redux';
|
||||||
import thunkMiddleware from 'redux-thunk';
|
import thunkMiddleware from 'redux-thunk';
|
||||||
import { getWindow } from '../../lib/get_window';
|
import { getWindow } from '../../lib/get_window';
|
||||||
import { aeroelastic } from './aeroelastic';
|
|
||||||
import { breadcrumbs } from './breadcrumbs';
|
import { breadcrumbs } from './breadcrumbs';
|
||||||
import { esPersistMiddleware } from './es_persist';
|
import { esPersistMiddleware } from './es_persist';
|
||||||
import { fullscreen } from './fullscreen';
|
import { fullscreen } from './fullscreen';
|
||||||
|
@ -26,7 +25,6 @@ const middlewares = [
|
||||||
resolvedArgs,
|
resolvedArgs,
|
||||||
esPersistMiddleware,
|
esPersistMiddleware,
|
||||||
historyMiddleware,
|
historyMiddleware,
|
||||||
aeroelastic,
|
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
inFlight,
|
inFlight,
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { duplicatePage } from '../actions/pages';
|
import { duplicatePage } from '../actions/pages';
|
||||||
import { fetchRenderable } from '../actions/elements';
|
import { fetchAllRenderables } from '../actions/elements';
|
||||||
import { setWriteable } from '../actions/workpad';
|
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 { getWindow } from '../../lib/get_window';
|
||||||
import { setDocTitle } from '../../lib/doc_title';
|
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
|
// This middleware fetches all of the renderable elements on new, duplicate page
|
||||||
if (action.type === duplicatePage.toString()) {
|
if (action.type === duplicatePage.toString()) {
|
||||||
// When a page has been duplicated, it will be added as the last page, so fetch it
|
dispatch(fetchAllRenderables());
|
||||||
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)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This middleware clears any page selection when the writeable mode changes
|
// This middleware clears any page selection when the writeable mode changes
|
||||||
|
|
|
@ -16,7 +16,7 @@ describe('resolved args reducer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
state = {
|
state = {
|
||||||
selectedPage: 'page-1',
|
selectedPage: 'page-1',
|
||||||
selectedElement: 'element-1',
|
selectedToplevelNodes: ['element-1'],
|
||||||
resolvedArgs: {
|
resolvedArgs: {
|
||||||
'element-0': [
|
'element-0': [
|
||||||
{
|
{
|
||||||
|
@ -148,7 +148,7 @@ describe('resolved args reducer', () => {
|
||||||
it('removes expression context from a given index to the end', () => {
|
it('removes expression context from a given index to the end', () => {
|
||||||
state = {
|
state = {
|
||||||
selectedPage: 'page-1',
|
selectedPage: 'page-1',
|
||||||
selectedElement: 'element-1',
|
selectedToplevelNodes: ['element-1'],
|
||||||
resolvedArgs: {
|
resolvedArgs: {
|
||||||
'element-1': {
|
'element-1': {
|
||||||
expressionContext: {
|
expressionContext: {
|
||||||
|
|
|
@ -11,13 +11,12 @@ import { getId } from '../../lib/get_id';
|
||||||
import { routerProvider } from '../../lib/router_provider';
|
import { routerProvider } from '../../lib/router_provider';
|
||||||
import { getDefaultPage } from '../defaults';
|
import { getDefaultPage } from '../defaults';
|
||||||
import * as actions from '../actions/pages';
|
import * as actions from '../actions/pages';
|
||||||
|
import { getSelectedPageIndex } from '../selectors/workpad';
|
||||||
|
|
||||||
function setPageIndex(workpadState, index) {
|
const setPageIndex = (workpadState, index) =>
|
||||||
if (index < 0 || !workpadState.pages[index]) {
|
index < 0 || !workpadState.pages[index] || getSelectedPageIndex(workpadState) === index
|
||||||
return workpadState;
|
? workpadState
|
||||||
}
|
: set(workpadState, 'page', index);
|
||||||
return set(workpadState, 'page', index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPageIndexById(workpadState, id) {
|
function getPageIndexById(workpadState, id) {
|
||||||
return workpadState.pages.findIndex(page => page.id === id);
|
return workpadState.pages.findIndex(page => page.id === id);
|
||||||
|
|
|
@ -7,7 +7,8 @@
|
||||||
import { handleActions } from 'redux-actions';
|
import { handleActions } from 'redux-actions';
|
||||||
import { set, del } from 'object-path-immutable';
|
import { set, del } from 'object-path-immutable';
|
||||||
import { restoreHistory } from '../actions/history';
|
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 { removeElements } from '../actions/elements';
|
||||||
import { setRefreshInterval } from '../actions/workpad';
|
import { setRefreshInterval } from '../actions/workpad';
|
||||||
|
|
||||||
|
@ -18,39 +19,51 @@ export const transientReducer = handleActions(
|
||||||
[restoreHistory]: transientState => set(transientState, 'resolvedArgs', {}),
|
[restoreHistory]: transientState => set(transientState, 'resolvedArgs', {}),
|
||||||
|
|
||||||
[removeElements]: (transientState, { payload: { elementIds } }) => {
|
[removeElements]: (transientState, { payload: { elementIds } }) => {
|
||||||
const { selectedElement } = transientState;
|
const { selectedToplevelNodes } = transientState;
|
||||||
return del(
|
return del(
|
||||||
{
|
{
|
||||||
...transientState,
|
...transientState,
|
||||||
selectedElement: elementIds.indexOf(selectedElement) === -1 ? selectedElement : null,
|
selectedToplevelNodes: selectedToplevelNodes.filter(n => elementIds.indexOf(n) < 0),
|
||||||
},
|
},
|
||||||
['resolvedArgs', elementIds]
|
['resolvedArgs', elementIds]
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
[actions.setCanUserWrite]: (transientState, { payload }) => {
|
[transientActions.setCanUserWrite]: (transientState, { payload }) => {
|
||||||
return set(transientState, 'canUserWrite', Boolean(payload));
|
return set(transientState, 'canUserWrite', Boolean(payload));
|
||||||
},
|
},
|
||||||
|
|
||||||
[actions.setFirstLoad]: (transientState, { payload }) => {
|
[transientActions.setFirstLoad]: (transientState, { payload }) => {
|
||||||
return set(transientState, 'isFirstLoad', Boolean(payload));
|
return set(transientState, 'isFirstLoad', Boolean(payload));
|
||||||
},
|
},
|
||||||
|
|
||||||
[actions.setFullscreen]: (transientState, { payload }) => {
|
[transientActions.setFullscreen]: (transientState, { payload }) => {
|
||||||
return set(transientState, 'fullscreen', Boolean(payload));
|
return set(transientState, 'fullscreen', Boolean(payload));
|
||||||
},
|
},
|
||||||
|
|
||||||
[actions.setElementStats]: (transientState, { payload }) => {
|
[transientActions.setElementStats]: (transientState, { payload }) => {
|
||||||
return set(transientState, 'elementStats', payload);
|
return set(transientState, 'elementStats', payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
[actions.selectElement]: (transientState, { payload }) => {
|
[transientActions.selectToplevelNodes]: (transientState, { payload }) => {
|
||||||
return {
|
return {
|
||||||
...transientState,
|
...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 }) => {
|
[setRefreshInterval]: (transientState, { payload }) => {
|
||||||
return { ...transientState, refresh: { interval: Number(payload) || 0 } };
|
return { ...transientState, refresh: { interval: Number(payload) || 0 } };
|
||||||
},
|
},
|
||||||
|
|
|
@ -70,7 +70,7 @@ describe('workpad selectors', () => {
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
transient: {
|
transient: {
|
||||||
selectedElement: 'element-1',
|
selectedToplevelNodes: ['element-1'],
|
||||||
resolvedArgs: {
|
resolvedArgs: {
|
||||||
'element-0': 'test resolved arg, el 0',
|
'element-0': 'test resolved arg, el 0',
|
||||||
'element-1': 'test resolved arg, el 1',
|
'element-1': 'test resolved arg, el 1',
|
||||||
|
@ -128,7 +128,6 @@ describe('workpad selectors', () => {
|
||||||
expect(selector.getSelectedPage({})).to.be(undefined);
|
expect(selector.getSelectedPage({})).to.be(undefined);
|
||||||
expect(selector.getPageById({}, 'page-1')).to.be(undefined);
|
expect(selector.getPageById({}, 'page-1')).to.be(undefined);
|
||||||
expect(selector.getSelectedElement({})).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.getElementById({}, 'element-1')).to.be(undefined);
|
||||||
expect(selector.getResolvedArgs({}, 'element-1')).to.be(undefined);
|
expect(selector.getResolvedArgs({}, 'element-1')).to.be(undefined);
|
||||||
expect(selector.getSelectedResolvedArgs({})).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', () => {
|
describe('getElements', () => {
|
||||||
it('is an empty array with no state', () => {
|
it('is an empty array with no state', () => {
|
||||||
expect(selector.getElements({})).to.eql([]);
|
expect(selector.getElements({})).to.eql([]);
|
||||||
|
@ -225,7 +218,7 @@ describe('workpad selectors', () => {
|
||||||
...state,
|
...state,
|
||||||
transient: {
|
transient: {
|
||||||
...state.transient,
|
...state.transient,
|
||||||
selectedElement: 'element-2',
|
selectedToplevelNodes: ['element-2'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const arg = selector.getSelectedResolvedArgs(tmpState, 'example2');
|
const arg = selector.getSelectedResolvedArgs(tmpState, 'example2');
|
||||||
|
@ -237,7 +230,7 @@ describe('workpad selectors', () => {
|
||||||
...state,
|
...state,
|
||||||
transient: {
|
transient: {
|
||||||
...state.transient,
|
...state.transient,
|
||||||
selectedElement: 'element-2',
|
selectedToplevelNodes: ['element-2'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const arg = selector.getSelectedResolvedArgs(tmpState, ['example3', 'deeper', 'object']);
|
const arg = selector.getSelectedResolvedArgs(tmpState, ['example3', 'deeper', 'object']);
|
||||||
|
|
|
@ -128,8 +128,9 @@ export function getGlobalFilterExpression(state) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// element getters
|
// element getters
|
||||||
export function getSelectedElementId(state) {
|
function getSelectedElementId(state) {
|
||||||
return get(state, 'transient.selectedElement');
|
const toplevelNodes = get(state, 'transient.selectedToplevelNodes') || [];
|
||||||
|
return toplevelNodes.length === 1 ? toplevelNodes[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSelectedElement(state) {
|
export function getSelectedElement(state) {
|
||||||
|
@ -170,14 +171,7 @@ const getNodesOfPage = page =>
|
||||||
.map(augment('element'))
|
.map(augment('element'))
|
||||||
.concat((get(page, 'groups') || []).map(augment('group')));
|
.concat((get(page, 'groups') || []).map(augment('group')));
|
||||||
|
|
||||||
// todo unify or DRY up with `getElements`
|
export const getNodesForPage = (page, withAst) => {
|
||||||
export function getNodes(state, pageId, withAst = true) {
|
|
||||||
const id = pageId || getSelectedPage(state);
|
|
||||||
if (!id) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const page = getPageById(state, id);
|
|
||||||
const elements = getNodesOfPage(page);
|
const elements = getNodesOfPage(page);
|
||||||
|
|
||||||
if (!elements) {
|
if (!elements) {
|
||||||
|
@ -192,6 +186,16 @@ export function getNodes(state, pageId, withAst = true) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return elements.map(appendAst);
|
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) {
|
export function getElementById(state, id, pageId) {
|
||||||
|
|
Loading…
Reference in a new issue