feat: allows layer order of rectangles to be changed

This commit is contained in:
Mark Mankarious
2023-10-28 12:27:21 +01:00
parent ad5a4e06f3
commit 56591cb102
22 changed files with 241 additions and 23 deletions

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Menu, MenuItem } from '@mui/material';
import { Coords } from 'src/types';
interface MenuItemI {
label: string;
onClick: () => void;
}
interface Props {
onClose: () => void;
position: Coords;
anchorEl?: HTMLElement;
menuItems: MenuItemI[];
}
export const ContextMenu = ({
onClose,
position,
anchorEl,
menuItems
}: Props) => {
return (
<Menu
open
anchorEl={anchorEl}
style={{
left: position.x,
top: position.y
}}
onClose={onClose}
>
{menuItems.map((item) => {
return <MenuItem onClick={item.onClick}>{item.label}</MenuItem>;
})}
</Menu>
);
};

View File

@@ -0,0 +1,66 @@
import React, { useCallback } from 'react';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { getTilePosition } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
import { ContextMenu } from './ContextMenu';
interface Props {
anchorEl?: HTMLElement;
}
export const ContextMenuManager = ({ anchorEl }: Props) => {
const scene = useScene();
const contextMenu = useUiStateStore((state) => {
return state.contextMenu;
});
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const onClose = useCallback(() => {
uiStateActions.setContextMenu(null);
}, [uiStateActions]);
if (!contextMenu) {
return null;
}
return (
<ContextMenu
anchorEl={anchorEl}
onClose={onClose}
position={getTilePosition({ tile: contextMenu.tile })}
menuItems={[
{
label: 'Send backward',
onClick: () => {
scene.changeLayerOrder('SEND_BACKWARD', contextMenu.item);
onClose();
}
},
{
label: 'Bring forward',
onClick: () => {
scene.changeLayerOrder('BRING_FORWARD', contextMenu.item);
onClose();
}
},
{
label: 'Send to back',
onClick: () => {
scene.changeLayerOrder('SEND_TO_BACK', contextMenu.item);
onClose();
}
},
{
label: 'Bring to front',
onClick: () => {
scene.changeLayerOrder('BRING_TO_FRONT', contextMenu.item);
onClose();
}
}
]}
/>
);
};

View File

@@ -19,10 +19,9 @@ export const DragAndDrop = ({ iconId, tile }: Props) => {
return (
<Box
sx={{
position: 'absolute',
left: tilePosition.x,
top: tilePosition.y
position: 'absolute'
}}
style={{ left: tilePosition.x, top: tilePosition.y }}
>
{iconComponent}
</Box>

View File

@@ -9,7 +9,7 @@ interface Props {
export const Connectors = ({ connectors }: Props) => {
return (
<>
{connectors.map((connector) => {
{[...connectors].reverse().map((connector) => {
return <Connector key={connector.id} connector={connector} />;
})}
</>

View File

@@ -37,14 +37,14 @@ export const Node = ({ node, order }: Props) => {
return (
<Box
style={{
sx={{
position: 'absolute',
zIndex: order
}}
>
<Box
sx={{ position: 'absolute' }}
style={{
position: 'absolute',
left: position.x,
top: position.y
}}

View File

@@ -9,7 +9,7 @@ interface Props {
export const Nodes = ({ nodes }: Props) => {
return (
<>
{nodes.map((node) => {
{[...nodes].reverse().map((node) => {
return (
<Node key={node.id} order={-node.tile.x - node.tile.y} node={node} />
);

View File

@@ -9,7 +9,7 @@ interface Props {
export const Rectangles = ({ rectangles }: Props) => {
return (
<>
{rectangles.map((rectangle) => {
{[...rectangles].reverse().map((rectangle) => {
return <Rectangle key={rectangle.id} {...rectangle} />;
})}
</>

View File

@@ -9,7 +9,7 @@ interface Props {
export const TextBoxes = ({ textBoxes }: Props) => {
return (
<>
{textBoxes.map((textBox) => {
{[...textBoxes].reverse().map((textBox) => {
return <TextBox key={textBox.id} textBox={textBox} />;
})}
</>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { Box, useTheme, Typography } from '@mui/material';
import { EditorModeEnum } from 'src/types';
import { UiElement } from 'components/UiElement/UiElement';
@@ -12,6 +12,7 @@ import { ZoomControls } from 'src/components/ZoomControls/ZoomControls';
import { useModelStore } from 'src/stores/modelStore';
import { DebugUtils } from 'src/components/DebugUtils/DebugUtils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { ContextMenuManager } from 'src/components/ContextMenu/ContextMenuManager';
import { ExportImageDialog } from '../ExportImageDialog/ExportImageDialog';
const ToolsEnum = {
@@ -46,6 +47,7 @@ const getEditorModeMapping = (editorMode: keyof typeof EditorModeEnum) => {
export const UiOverlay = () => {
const theme = useTheme();
const contextMenuAnchorRef = useRef();
const { appPadding } = theme.customVars;
const spacing = useCallback(
(multiplier: number) => {
@@ -199,6 +201,7 @@ export const UiOverlay = () => {
</UiElement>
)}
</Box>
{mode.type === 'PLACE_ICON' && mode.id && (
<SceneLayer>
<DragAndDrop iconId={mode.id} tile={mouse.position.tile} />
@@ -212,6 +215,11 @@ export const UiOverlay = () => {
}}
/>
)}
<SceneLayer>
<Box ref={contextMenuAnchorRef} />
<ContextMenuManager anchorEl={contextMenuAnchorRef.current} />
</SceneLayer>
</>
);
};

View File

@@ -1,5 +1,13 @@
import { useCallback, useMemo } from 'react';
import { ModelItem, ViewItem, Connector, TextBox, Rectangle } from 'src/types';
import {
ModelItem,
ViewItem,
Connector,
TextBox,
Rectangle,
ItemReference,
LayerOrderingAction
} from 'src/types';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useModelStore } from 'src/stores/modelStore';
import { useSceneStore } from 'src/stores/sceneStore';
@@ -226,6 +234,19 @@ export const useScene = () => {
[getState, setState, currentViewId]
);
const changeLayerOrder = useCallback(
(action: LayerOrderingAction, item: ItemReference) => {
const newState = reducers.changeLayerOrder(
action,
item,
currentViewId,
getState()
);
setState(newState);
},
[getState, setState, currentViewId]
);
return {
items,
connectors,
@@ -246,6 +267,7 @@ export const useScene = () => {
deleteTextBox,
createRectangle,
updateRectangle,
deleteRectangle
deleteRectangle,
changeLayerOrder
};
};

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
import { useModelStore } from 'src/stores/modelStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { ModeActions, State, SlimMouseEvent } from 'src/types';
import { getMouse } from 'src/utils';
import { getMouse, getItemAtTile } from 'src/utils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { useScene } from 'src/hooks/useScene';
import { Cursor } from './modes/Cursor';
@@ -100,6 +100,27 @@ export const useInteractionManager = () => {
[model, scene, uiState, rendererSize]
);
const onContextMenu = useCallback(
(e: SlimMouseEvent) => {
e.preventDefault();
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
if (itemAtTile?.type === 'RECTANGLE') {
uiState.actions.setContextMenu({
item: itemAtTile,
tile: uiState.mouse.position.tile
});
} else if (uiState.contextMenu) {
uiState.actions.setContextMenu(null);
}
},
[uiState.mouse, scene, uiState.contextMenu, uiState.actions]
);
useEffect(() => {
if (uiState.mode.type === 'INTERACTIONS_DISABLED') return;
@@ -135,6 +156,7 @@ export const useInteractionManager = () => {
el.addEventListener('mousemove', onMouseEvent);
el.addEventListener('mousedown', onMouseEvent);
el.addEventListener('mouseup', onMouseEvent);
el.addEventListener('contextmenu', onContextMenu);
el.addEventListener('touchstart', onTouchStart);
el.addEventListener('touchmove', onTouchMove);
el.addEventListener('touchend', onTouchEnd);
@@ -143,11 +165,12 @@ export const useInteractionManager = () => {
el.removeEventListener('mousemove', onMouseEvent);
el.removeEventListener('mousedown', onMouseEvent);
el.removeEventListener('mouseup', onMouseEvent);
el.removeEventListener('contextmenu', onContextMenu);
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
};
}, [uiState.editorMode, onMouseEvent, uiState.mode.type]);
}, [uiState.editorMode, onMouseEvent, uiState.mode.type, onContextMenu]);
const setInteractionsElement = useCallback((element: HTMLElement) => {
rendererRef.current = element;

View File

@@ -87,7 +87,7 @@ export const createConnector = (
if (!connectors) {
draft.model.views[view.index].connectors = [newConnector];
} else {
draft.model.views[view.index].connectors?.push(newConnector);
draft.model.views[view.index].connectors?.unshift(newConnector);
}
});

View File

@@ -4,3 +4,4 @@ export * from './viewItem';
export * from './rectangle';
export * from './textBox';
export * from './view';
export * from './layerOrdering';

View File

@@ -0,0 +1,42 @@
import { produce } from 'immer';
import { ItemReference, LayerOrderingAction, View } from 'src/types';
import { getItemByIdOrThrow } from 'src/utils';
import { State } from './types';
export const changeLayerOrder = (
action: LayerOrderingAction,
item: ItemReference,
viewId: string,
state: State
): State => {
const newState = produce(state, (draft) => {
const view = getItemByIdOrThrow(draft.model.views, viewId);
let arr: View['rectangles'];
switch (item.type) {
case 'RECTANGLE':
arr = view.value.rectangles ?? [];
break;
default:
throw new Error('Invalid item type');
}
const target = getItemByIdOrThrow(arr, item.id);
if (action === 'SEND_BACKWARD' && target.index < arr.length - 1) {
arr.splice(target.index, 1);
arr.splice(target.index + 1, 0, target.value);
} else if (action === 'SEND_TO_BACK' && target.index !== arr.length - 1) {
arr.splice(target.index, 1);
arr.splice(arr.length, 0, target.value);
} else if (action === 'BRING_FORWARD' && target.index > 0) {
arr.splice(target.index, 1);
arr.splice(target.index - 1, 0, target.value);
} else if (action === 'BRING_TO_FRONT' && target.index !== 0) {
arr.splice(target.index, 1);
arr.splice(0, 0, target.value);
}
});
return newState;
};

View File

@@ -37,7 +37,7 @@ export const createRectangle = (
if (!rectangles) {
draft.model.views[view.index].rectangles = [newRectangle];
} else {
draft.model.views[view.index].rectangles?.push(newRectangle);
draft.model.views[view.index].rectangles?.unshift(newRectangle);
}
});

View File

@@ -57,7 +57,7 @@ export const createTextBox = (
if (!textBoxes) {
draft.model.views[view.index].textBoxes = [newTextBox];
} else {
draft.model.views[view.index].textBoxes?.push(newTextBox);
draft.model.views[view.index].textBoxes?.unshift(newTextBox);
}
});

View File

@@ -57,7 +57,7 @@ export const createViewItem = (
const newState = produce(state, (draft) => {
const { items } = draft.model.views[view.index];
items.push(newViewItem);
items.unshift(newViewItem);
});
return updateViewItem(newViewItem.id, newViewItem, viewId, newState);

View File

@@ -23,6 +23,7 @@ const initialState = () => {
isMainMenuOpen: false,
dialog: null,
rendererEl: null,
contextMenu: null,
mouse: {
position: { screen: CoordsUtils.zero(), tile: CoordsUtils.zero() },
mousedown: null,
@@ -88,6 +89,9 @@ const initialState = () => {
setItemControls: (itemControls) => {
set({ itemControls });
},
setContextMenu: (contextMenu) => {
set({ contextMenu });
},
setMouse: (mouse) => {
set({ mouse });
},

View File

@@ -22,7 +22,7 @@ export type BoundingBox = [Coords, Coords, Coords, Coords];
export type SlimMouseEvent = Pick<
MouseEvent,
'clientX' | 'clientY' | 'target' | 'type'
'clientX' | 'clientY' | 'target' | 'type' | 'preventDefault'
>;
export const EditorModeEnum = {

View File

@@ -6,7 +6,7 @@ interface AddItemControls {
type: 'ADD_ITEM';
}
export type ItemControls = ItemReference | AddItemControls | null;
export type ItemControls = ItemReference | AddItemControls;
export interface Mouse {
position: {
@@ -117,6 +117,20 @@ export const DialogTypeEnum = {
EXPORT_IMAGE: 'EXPORT_IMAGE'
} as const;
export interface ContextMenu {
item: ItemReference;
tile: Coords;
}
export const LayerOrderingActionOptions = {
BRING_TO_FRONT: 'BRING_TO_FRONT',
SEND_TO_BACK: 'SEND_TO_BACK',
BRING_FORWARD: 'BRING_FORWARD',
SEND_BACKWARD: 'SEND_BACKWARD'
} as const;
export type LayerOrderingAction = keyof typeof LayerOrderingActionOptions;
export interface UiState {
view: string;
mainMenuOptions: MainMenuOptions;
@@ -125,7 +139,8 @@ export interface UiState {
mode: Mode;
dialog: keyof typeof DialogTypeEnum | null;
isMainMenuOpen: boolean;
itemControls: ItemControls;
itemControls: ItemControls | null;
contextMenu: ContextMenu | null;
zoom: number;
scroll: Scroll;
mouse: Mouse;
@@ -147,7 +162,8 @@ export interface UiStateActions {
setZoom: (zoom: number) => void;
setScroll: (scroll: Scroll) => void;
scrollToTile: (tile: Coords, origin?: TileOrigin) => void;
setItemControls: (itemControls: ItemControls) => void;
setItemControls: (itemControls: ItemControls | null) => void;
setContextMenu: (contextMenu: ContextMenu | null) => void;
setMouse: (mouse: Mouse) => void;
setRendererEl: (el: HTMLDivElement) => void;
setenableDebugTools: (enabled: boolean) => void;

View File

@@ -19,7 +19,6 @@ import {
ItemReference,
Rect,
ProjectionOrientationEnum,
AnchorPositionOptions,
BoundingBox,
TextBox,
SlimMouseEvent,