feat: implements image export

This commit is contained in:
Mark Mankarious
2023-10-18 11:31:50 +01:00
parent 425744585e
commit 9f11cce6e0
28 changed files with 572 additions and 239 deletions

24
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@mui/material": "^5.11.10",
"auto-bind": "^5.0.1",
"chroma-js": "^2.4.2",
"dom-to-image": "^2.6.0",
"file-saver": "^2.0.5",
"gsap": "^3.11.4",
"immer": "^10.0.2",
@@ -33,6 +34,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/chroma-js": "^2.4.0",
"@types/dom-to-image": "^2.6.5",
"@types/file-saver": "^2.0.5",
"@types/jest": "^27.5.2",
"@types/jsdom": "^21.1.0",
@@ -2649,6 +2651,12 @@
"integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==",
"dev": true
},
"node_modules/@types/dom-to-image": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@types/dom-to-image/-/dom-to-image-2.6.5.tgz",
"integrity": "sha512-SMYQf4urvjHfSsaEMhIULyjfawUv2a92OfglcGF7dQdDHBjfnGPtWoxw6hmKMiwsdmowHn70mxLw+F5cA7Imyg==",
"dev": true
},
"node_modules/@types/eslint": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.1.tgz",
@@ -5396,6 +5404,11 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/dom-to-image": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz",
"integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA=="
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
@@ -16481,6 +16494,12 @@
"integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g==",
"dev": true
},
"@types/dom-to-image": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@types/dom-to-image/-/dom-to-image-2.6.5.tgz",
"integrity": "sha512-SMYQf4urvjHfSsaEMhIULyjfawUv2a92OfglcGF7dQdDHBjfnGPtWoxw6hmKMiwsdmowHn70mxLw+F5cA7Imyg==",
"dev": true
},
"@types/eslint": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.1.tgz",
@@ -18586,6 +18605,11 @@
}
}
},
"dom-to-image": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/dom-to-image/-/dom-to-image-2.6.0.tgz",
"integrity": "sha512-Dt0QdaHmLpjURjU7Tnu3AgYSF2LuOmksSGsUcE6ItvJoCWTBEmiMXcqBdNSAm9+QbbwD7JMoVsuuKX6ZVQv1qA=="
},
"domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",

View File

@@ -27,6 +27,7 @@
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/chroma-js": "^2.4.0",
"@types/dom-to-image": "^2.6.5",
"@types/file-saver": "^2.0.5",
"@types/jest": "^27.5.2",
"@types/jsdom": "^21.1.0",
@@ -73,6 +74,7 @@
"@mui/material": "^5.11.10",
"auto-bind": "^5.0.1",
"chroma-js": "^2.4.2",
"dom-to-image": "^2.6.0",
"file-saver": "^2.0.5",
"gsap": "^3.11.4",
"immer": "^10.0.2",

View File

@@ -12,7 +12,7 @@ import {
IsoflowProps,
InitialScene
} from 'src/types';
import { sceneToSceneInput, setWindowCursor } from 'src/utils';
import { sceneToSceneInput, setWindowCursor, CoordsUtils } from 'src/utils';
import { useSceneStore, SceneProvider } from 'src/stores/sceneStore';
import { GlobalStyles } from 'src/styles/GlobalStyles';
import { Renderer } from 'src/components/Renderer/Renderer';
@@ -32,9 +32,9 @@ const App = ({
enableDebugTools = false,
editorMode = 'EDITABLE'
}: IsoflowProps) => {
useWindowUtils();
const prevInitialScene = useRef<SceneInput>(INITIAL_SCENE);
const [isReady, setIsReady] = useState(false);
useWindowUtils();
const scene = useSceneStore(
({ title, nodes, connectors, textBoxes, rectangles, icons }) => {
return { title, nodes, connectors, textBoxes, rectangles, icons };
@@ -51,10 +51,15 @@ const App = ({
useEffect(() => {
uiActions.setZoom(initialScene?.zoom ?? 1);
uiActions.setScroll({
position: initialScene?.scroll ?? CoordsUtils.zero(),
offset: CoordsUtils.zero()
});
uiActions.setEditorMode(editorMode);
uiActions.setMainMenuOptions(mainMenuOptions);
}, [
initialScene?.zoom,
initialScene?.scroll,
editorMode,
sceneActions,
uiActions,
@@ -125,6 +130,10 @@ export const Isoflow = (props: IsoflowProps) => {
};
const useIsoflow = () => {
const rendererEl = useUiStateStore((state) => {
return state.rendererEl;
});
const sceneActions = useSceneStore((state) => {
return state.actions;
});
@@ -135,7 +144,8 @@ const useIsoflow = () => {
return {
scene: sceneActions,
uiState: uiStateActions
uiState: uiStateActions,
rendererEl
};
};

View File

@@ -2,17 +2,19 @@ import React from 'react';
import { Box } from '@mui/material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { LineItem } from './LineItem';
export const DebugUtils = () => {
const uiState = useUiStateStore(
({ scroll, mouse, zoom, rendererSize, mode }) => {
return { scroll, mouse, zoom, rendererSize, mode };
({ scroll, mouse, zoom, mode, rendererEl }) => {
return { scroll, mouse, zoom, mode, rendererEl };
}
);
const scene = useSceneStore((state) => {
return state;
});
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
return (
<Box
@@ -51,7 +53,7 @@ export const DebugUtils = () => {
<LineItem title="Zoom" value={uiState.zoom} />
<LineItem
title="Size"
value={`${uiState.rendererSize.width}, ${uiState.rendererSize.height}`}
value={`${rendererSize.width}, ${rendererSize.height}`}
/>
<LineItem title="Scene info" value={`${scene.nodes.length} nodes`} />
<LineItem title="Mode" value={uiState.mode.type} />

View File

@@ -2,6 +2,8 @@ import React, { useMemo } from 'react';
import { Box } from '@mui/material';
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
const BORDER_WIDTH = 6;
export const SizeIndicator = () => {
const { getUnprojectedBounds } = useDiagramUtils();
const diagramBoundingBox = useMemo(() => {
@@ -12,13 +14,13 @@ export const SizeIndicator = () => {
<Box
sx={{
position: 'absolute',
border: '5px solid red'
border: `${BORDER_WIDTH}px solid red`
}}
style={{
width: diagramBoundingBox.width,
height: diagramBoundingBox.height,
left: diagramBoundingBox.x,
top: diagramBoundingBox.y
left: diagramBoundingBox.x - BORDER_WIDTH,
top: diagramBoundingBox.y - BORDER_WIDTH
}}
/>
);

View File

@@ -0,0 +1,165 @@
import React, { useRef, useEffect, useMemo, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogTitle,
Box,
Button,
Stack,
Alert
} from '@mui/material';
import { useSceneStore } from 'src/stores/sceneStore';
import {
sceneToSceneInput,
exportAsImage,
downloadFile as downloadFileUtil,
getTileScrollPosition,
base64ToBlob,
generateGenericFilename
} from 'src/utils';
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { Isoflow } from 'src/Isoflow';
import { Loader } from 'src/components/Loader/Loader';
interface Props {
onClose: () => void;
}
export const ExportImageDialog = ({ onClose }: Props) => {
const containerRef = useRef<HTMLDivElement>();
const debounceRef = useRef<NodeJS.Timeout>();
const [imageData, setImageData] = React.useState<string>();
const { getUnprojectedBounds, getFitToViewParams } = useDiagramUtils();
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const scene = useSceneStore((state) => {
return {
title: state.title,
nodes: state.nodes,
connectors: state.connectors,
textBoxes: state.textBoxes,
rectangles: state.rectangles,
icons: state.icons
};
});
const unprojectedBounds = useMemo(() => {
return getUnprojectedBounds();
}, [getUnprojectedBounds]);
const previewParams = useMemo(() => {
return getFitToViewParams(unprojectedBounds);
}, [unprojectedBounds, getFitToViewParams]);
useEffect(() => {
uiStateActions.setMode({
type: 'INTERACTIONS_DISABLED',
showCursor: false
});
}, [uiStateActions]);
const exportImage = useCallback(async () => {
if (!containerRef.current) return;
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
exportAsImage(containerRef.current as HTMLDivElement).then((data) => {
return setImageData(data);
});
}, 2000);
}, []);
const downloadFile = useCallback(() => {
if (!imageData) return;
const data = base64ToBlob(
imageData.replace('data:image/png;base64,', ''),
'image/png;charset=utf-8'
);
downloadFileUtil(data, generateGenericFilename('png'));
}, [imageData]);
return (
<Dialog open onClose={onClose}>
<DialogTitle>Export as image</DialogTitle>
<DialogContent>
<Stack spacing={2}>
<Alert severity="info">
<strong>
Certain browsers may not support exporting images properly.
</strong>{' '}
<br />
For best results, please use the latest version of either Chrome or
Firefox.
</Alert>
{!imageData && (
<>
<Box
sx={{
position: 'absolute',
width: 0,
height: 0,
overflow: 'hidden'
}}
>
<Box
ref={containerRef}
sx={{
position: 'absolute',
top: 0,
left: 0,
width: unprojectedBounds.width,
height: unprojectedBounds.height
}}
>
<Isoflow
editorMode="NON_INTERACTIVE"
onSceneUpdated={exportImage}
initialScene={{
...sceneToSceneInput(scene),
zoom: previewParams.zoom,
scroll: getTileScrollPosition(previewParams.scrollTarget)
}}
/>
</Box>
</Box>
<Box
sx={{
position: 'relative',
top: 0,
left: 0,
width: 500,
height: 300,
bgcolor: 'common.white'
}}
>
<Loader size={2} />
</Box>
</>
)}
{imageData && (
<>
<Box
component="img"
sx={{ width: 900, maxWidth: '100%' }}
src={imageData}
alt="preview"
/>
<Stack alignItems="flex-end">
<Stack direction="row" spacing={2}>
<Button variant="text" onClick={onClose}>
Cancel
</Button>
<Button onClick={downloadFile}>Download as PNG</Button>
</Stack>
</Stack>
</>
)}
</Stack>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Box, CircularProgress, CircularProgressProps } from '@mui/material';
interface Props {
size?: number;
color?: CircularProgressProps['color'];
isInline?: boolean;
}
export const Loader = ({ size = 1, color = 'primary', isInline }: Props) => {
return (
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: isInline ? 'auto' : '100%',
height: isInline ? 'auto' : '100%'
}}
>
<CircularProgress size={size * 20} color={color} />
</Box>
);
};

View File

@@ -4,16 +4,16 @@ import {
Menu as MenuIcon,
GitHub as GitHubIcon,
QuestionAnswer as QuestionAnswerIcon,
Download as DownloadIcon,
DataObject as ExportJsonIcon,
ImageOutlined as ExportImageIcon,
FolderOpen as FolderOpenIcon,
DeleteOutline as DeleteOutlineIcon
} from '@mui/icons-material';
import FileSaver from 'file-saver';
import { UiElement } from 'src/components/UiElement/UiElement';
import { IconButton } from 'src/components/IconButton/IconButton';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { sceneToSceneInput } from 'src/utils';
import { exportAsJSON } from 'src/utils';
import { INITIAL_SCENE } from 'src/config';
import { MenuItem } from './MenuItem';
@@ -35,8 +35,8 @@ export const MainMenu = () => {
rectangles: state.rectangles
};
});
const setScene = useSceneStore((state) => {
return state.actions.setScene;
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const uiStateActions = useUiStateStore((state) => {
return state.actions;
@@ -70,7 +70,7 @@ export const MainMenu = () => {
fileReader.onload = async (e) => {
const sceneInput = JSON.parse(e.target?.result as string);
setScene(sceneInput);
sceneActions.setScene(sceneInput);
};
fileReader.readAsText(file);
@@ -79,31 +79,31 @@ export const MainMenu = () => {
await fileInput.click();
uiStateActions.setIsMainMenuOpen(false);
}, [setScene, uiStateActions]);
}, [uiStateActions, sceneActions]);
const onSaveAs = useCallback(async () => {
const parsedScene = sceneToSceneInput(scene);
const blob = new Blob([JSON.stringify(parsedScene)], {
type: 'application/json;charset=utf-8'
});
FileSaver.saveAs(blob, `isoflow-${new Date().toISOString()}.json`);
const onExportAsJSON = useCallback(async () => {
exportAsJSON(scene);
uiStateActions.setIsMainMenuOpen(false);
}, [scene, uiStateActions]);
const onExportAsImage = useCallback(() => {
uiStateActions.setIsMainMenuOpen(false);
uiStateActions.setDialog('EXPORT_IMAGE');
}, [uiStateActions]);
const onClearCanvas = useCallback(() => {
setScene({ ...INITIAL_SCENE, icons: scene.icons });
sceneActions.setScene({ ...INITIAL_SCENE, icons: scene.icons });
uiStateActions.resetUiState();
uiStateActions.setIsMainMenuOpen(false);
}, [uiStateActions, setScene, scene.icons]);
}, [sceneActions, uiStateActions, scene.icons]);
const sectionVisibility = useMemo(() => {
return {
actions: Boolean(
mainMenuOptions.includes('OPEN') ||
mainMenuOptions.includes('SAVE_JSON') ||
mainMenuOptions.includes('CLEAR')
mainMenuOptions.includes('EXPORT_JSON') ||
mainMenuOptions.includes('EXPORT_PNG') ||
mainMenuOptions.includes('CLEAR_CANVAS')
),
links: Boolean(
mainMenuOptions.includes('GITHUB') ||
@@ -145,13 +145,19 @@ export const MainMenu = () => {
</MenuItem>
)}
{mainMenuOptions.includes('SAVE_JSON') && (
<MenuItem onClick={onSaveAs} Icon={<DownloadIcon />}>
Download diagram
{mainMenuOptions.includes('EXPORT_JSON') && (
<MenuItem onClick={onExportAsJSON} Icon={<ExportJsonIcon />}>
Export as JSON
</MenuItem>
)}
{mainMenuOptions.includes('CLEAR') && (
{mainMenuOptions.includes('EXPORT_PNG') && (
<MenuItem onClick={onExportAsImage} Icon={<ExportImageIcon />}>
Export as image
</MenuItem>
)}
{mainMenuOptions.includes('CLEAR_CANVAS') && (
<MenuItem onClick={onClearCanvas} Icon={<DeleteOutlineIcon />}>
Clear the canvas
</MenuItem>

View File

@@ -10,42 +10,33 @@ import { Connectors } from 'src/components/SceneLayers/Connectors/Connectors';
import { ConnectorLabels } from 'src/components/SceneLayers/ConnectorLabels/ConnectorLabels';
import { TextBoxes } from 'src/components/SceneLayers/TextBoxes/TextBoxes';
import { SizeIndicator } from 'src/components/DebugUtils/SizeIndicator';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { SceneLayer } from 'src/components/SceneLayer/SceneLayer';
import { TransformControlsManager } from 'src/components/TransformControlsManager/TransformControlsManager';
export const Renderer = () => {
const containerRef = useRef<HTMLDivElement>();
const interactionsRef = useRef<HTMLDivElement>();
const enableDebugTools = useUiStateStore((state) => {
return state.enableDebugTools;
});
const mode = useUiStateStore((state) => {
return state.mode;
});
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const { setElement: setInteractionsElement } = useInteractionManager();
const { observe, disconnect, size: rendererSize } = useResizeObserver();
useEffect(() => {
if (!containerRef.current) return;
if (!containerRef.current || !interactionsRef.current) return;
observe(containerRef.current);
setInteractionsElement(containerRef.current);
return () => {
disconnect();
};
}, [setInteractionsElement, observe, disconnect]);
useEffect(() => {
uiStateActions.setRendererSize(rendererSize);
}, [rendererSize, uiStateActions]);
setInteractionsElement(interactionsRef.current);
uiStateActions.setRendererEl(containerRef.current);
}, [setInteractionsElement, uiStateActions]);
return (
<Box
ref={containerRef}
sx={{
position: 'absolute',
top: 0,
@@ -96,7 +87,7 @@ export const Renderer = () => {
</SceneLayer>
{/* Interaction layer: this is where events are detected */}
<Box
ref={containerRef}
ref={interactionsRef}
sx={{
position: 'absolute',
left: 0,

View File

@@ -11,6 +11,8 @@ import { MainMenu } from 'src/components/MainMenu/MainMenu';
import { ZoomControls } from 'src/components/ZoomControls/ZoomControls';
import { useSceneStore } from 'src/stores/sceneStore';
import { DebugUtils } from 'src/components/DebugUtils/DebugUtils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { ExportImageDialog } from '../ExportImageDialog/ExportImageDialog';
const ToolsEnum = {
MAIN_MENU: 'MAIN_MENU',
@@ -51,6 +53,9 @@ export const UiOverlay = () => {
},
[theme]
);
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const enableDebugTools = useUiStateStore((state) => {
return state.enableDebugTools;
});
@@ -60,6 +65,9 @@ export const UiOverlay = () => {
const mouse = useUiStateStore((state) => {
return state.mouse;
});
const dialog = useUiStateStore((state) => {
return state.dialog;
});
const itemControls = useUiStateStore((state) => {
return state.itemControls;
});
@@ -72,9 +80,10 @@ export const UiOverlay = () => {
const availableTools = useMemo(() => {
return getEditorModeMapping(editorMode);
}, [editorMode]);
const rendererSize = useUiStateStore((state) => {
return state.rendererSize;
const rendererEl = useUiStateStore((state) => {
return state.rendererEl;
});
const { size: rendererSize } = useResizeObserver(rendererEl);
return (
<>
@@ -195,6 +204,14 @@ export const UiOverlay = () => {
<DragAndDrop icon={mode.icon} tile={mouse.position.tile} />
</SceneLayer>
)}
{dialog === 'EXPORT_IMAGE' && (
<ExportImageDialog
onClose={() => {
return uiStateActions.setDialog(null);
}}
/>
)}
</>
);
};

View File

@@ -1,11 +1,16 @@
import React from 'react';
import { Add as ZoomInIcon, Remove as ZoomOutIcon } from '@mui/icons-material';
import {
Add as ZoomInIcon,
Remove as ZoomOutIcon,
CropFreeOutlined as FitToScreenIcon
} from '@mui/icons-material';
import { Stack, Box, Typography, Divider } from '@mui/material';
import { toPx } from 'src/utils';
import { UiElement } from 'src/components/UiElement/UiElement';
import { IconButton } from 'src/components/IconButton/IconButton';
import { MAX_ZOOM, MIN_ZOOM } from 'src/config';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
export const ZoomControls = () => {
const uiStateStoreActions = useUiStateStore((state) => {
@@ -14,37 +19,47 @@ export const ZoomControls = () => {
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const { fitToView } = useDiagramUtils();
return (
<UiElement>
<Stack direction="row">
<Stack direction="row" spacing={1}>
<UiElement>
<Stack direction="row">
<IconButton
name="Zoom in"
Icon={<ZoomOutIcon />}
onClick={uiStateStoreActions.decrementZoom}
disabled={zoom >= MAX_ZOOM}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minWidth: toPx(60)
}}
>
<Typography variant="body2" color="text.secondary">
{Math.ceil(zoom * 100)}%
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<IconButton
name="Zoom out"
Icon={<ZoomInIcon />}
onClick={uiStateStoreActions.incrementZoom}
disabled={zoom <= MIN_ZOOM}
/>
</Stack>
</UiElement>
<UiElement>
<IconButton
name="Zoom in"
Icon={<ZoomOutIcon />}
onClick={uiStateStoreActions.decrementZoom}
disabled={zoom >= MAX_ZOOM}
name="Fit to screen"
Icon={<FitToScreenIcon />}
onClick={fitToView}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minWidth: toPx(60)
}}
>
<Typography variant="body2" color="text.secondary">
{zoom * 100}%
</Typography>
</Box>
<Divider orientation="vertical" flexItem />
<IconButton
name="Zoom out"
Icon={<ZoomInIcon />}
onClick={uiStateStoreActions.incrementZoom}
disabled={zoom <= MIN_ZOOM}
/>
</Stack>
</UiElement>
</UiElement>
</Stack>
);
};

View File

@@ -60,10 +60,10 @@ export const INITIAL_SCENE: SceneInput = {
rectangles: []
};
export const MAIN_MENU_OPTIONS: MainMenuOptions = [
'CLEAR',
'OPEN',
'SAVE_JSON',
'CLEAR',
'EXPORT_JSON',
'EXPORT_PNG',
'CLEAR_CANVAS',
'DISCORD',
'GITHUB',
'VERSION'

2
src/global.d.ts vendored
View File

@@ -7,7 +7,7 @@ declare global {
interface Window {
Isoflow: {
getUnprojectedBounds: () => Size & Coords;
fitProjectToScreen: () => void;
fitToView: () => void;
setScene: (scene: SceneInput) => void;
};
}

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { Size, Coords, Node, Rectangle, Connector } from 'src/types';
import { Size, Coords } from 'src/types';
import {
getBoundingBox,
getBoundingBoxSize,
@@ -9,70 +9,88 @@ import {
clamp,
getAnchorTile,
getAllAnchors,
getTilePosition
getTilePosition,
CoordsUtils
} from 'src/utils';
import { useScroll } from 'src/hooks/useScroll';
import { MAX_ZOOM } from 'src/config';
import { useResizeObserver } from './useResizeObserver';
const BOUNDING_BOX_PADDING = 3;
const BOUNDING_BOX_PADDING = 1;
export const useDiagramUtils = () => {
const scene = useSceneStore(({ nodes, rectangles, connectors, icons }) => {
return {
nodes,
rectangles,
connectors,
icons
};
});
const { scrollToTile } = useScroll();
const rendererSize = useUiStateStore((state) => {
return state.rendererSize;
const scene = useSceneStore(
({ nodes, rectangles, connectors, icons, textBoxes }) => {
return {
nodes,
rectangles,
connectors,
icons,
textBoxes
};
}
);
const rendererEl = useUiStateStore((state) => {
return state.rendererEl;
});
const { size: rendererSize } = useResizeObserver(rendererEl);
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const getProjectBounds = useCallback(
(items: (Node | Rectangle | Connector)[]): Coords[] => {
const positions = items.reduce<Coords[]>((acc, item) => {
switch (item.type) {
case 'NODE':
return [...acc, item.tile];
case 'CONNECTOR':
return [
...acc,
...item.anchors.map((anchor) => {
return getAnchorTile(
anchor,
scene.nodes,
getAllAnchors(scene.connectors)
);
})
];
case 'RECTANGLE':
return [...acc, item.from, item.to];
default:
return acc;
}
}, []);
const corners = getBoundingBox(positions, {
x: BOUNDING_BOX_PADDING,
y: BOUNDING_BOX_PADDING
});
return corners;
},
[scene.nodes, scene.connectors]
);
const getUnprojectedBounds = useCallback((): Size & Coords => {
const projectBounds = getProjectBounds([
const getProjectBounds = useCallback((): Coords[] => {
const items = [
...scene.nodes,
...scene.connectors,
...scene.rectangles
]);
...scene.rectangles,
...scene.textBoxes
];
let tiles = items.reduce<Coords[]>((acc, item) => {
switch (item.type) {
case 'NODE':
return [...acc, item.tile];
case 'CONNECTOR':
return [
...acc,
...item.anchors.map((anchor) => {
return getAnchorTile(
anchor,
scene.nodes,
getAllAnchors(scene.connectors)
);
})
];
case 'RECTANGLE':
return [...acc, item.from, item.to];
case 'TEXTBOX':
return [
...acc,
item.tile,
CoordsUtils.add(item.tile, {
x: item.size.width,
y: item.size.height
})
];
default:
return acc;
}
}, []);
if (tiles.length === 0) {
const centerTile = CoordsUtils.zero();
tiles = [centerTile, centerTile, centerTile, centerTile];
}
const corners = getBoundingBox(tiles, {
x: BOUNDING_BOX_PADDING,
y: BOUNDING_BOX_PADDING
});
return corners;
}, [scene]);
const getUnprojectedBounds = useCallback((): Size & Coords => {
const projectBounds = getProjectBounds();
const cornerPositions = projectBounds.map((corner) => {
return getTilePosition({
@@ -89,36 +107,45 @@ export const useDiagramUtils = () => {
x: topLeft.x,
y: topLeft.y
};
}, [scene, getProjectBounds]);
}, [getProjectBounds]);
const fitProjectToScreen = useCallback(() => {
const boundingBox = getProjectBounds(scene.nodes);
const sortedCornerPositions = sortByPosition(boundingBox);
const boundingBoxSize = getBoundingBoxSize(boundingBox);
const centralTile: Coords = {
x: sortedCornerPositions.lowX + Math.floor(boundingBoxSize.width / 2),
y: sortedCornerPositions.lowY + Math.floor(boundingBoxSize.height / 2)
};
const getFitToViewParams = useCallback(
(viewportSize: Size) => {
const projectBounds = getProjectBounds();
const sortedCornerPositions = sortByPosition(projectBounds);
const boundingBoxSize = getBoundingBoxSize(projectBounds);
const unprojectedBounds = getUnprojectedBounds();
const newZoom = clamp(
Math.min(
viewportSize.width / unprojectedBounds.width,
viewportSize.height / unprojectedBounds.height
),
0,
MAX_ZOOM
);
const scrollTarget: Coords = {
x: (sortedCornerPositions.lowX + boundingBoxSize.width / 2) * newZoom,
y: (sortedCornerPositions.lowY + boundingBoxSize.height / 2) * newZoom
};
const unprojectedBounds = getUnprojectedBounds();
const newZoom = Math.min(
rendererSize.width / unprojectedBounds.width,
rendererSize.height / unprojectedBounds.height
);
return {
zoom: newZoom,
scrollTarget
};
},
[getProjectBounds, getUnprojectedBounds]
);
uiStateActions.setZoom(clamp(newZoom, 0, MAX_ZOOM));
scrollToTile(centralTile);
}, [
getProjectBounds,
scene,
scrollToTile,
rendererSize,
uiStateActions,
getUnprojectedBounds
]);
const fitToView = useCallback(async () => {
const { zoom, scrollTarget } = getFitToViewParams(rendererSize);
uiStateActions.scrollToTile(scrollTarget);
uiStateActions.setZoom(zoom);
}, [uiStateActions, getFitToViewParams, rendererSize]);
return {
getUnprojectedBounds,
fitProjectToScreen
fitToView,
getFitToViewParams
};
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Size } from 'src/types';
export const useResizeObserver = () => {
export const useResizeObserver = (el?: HTMLElement | null) => {
const resizeObserverRef = useRef<ResizeObserver>();
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
@@ -31,9 +31,13 @@ export const useResizeObserver = () => {
};
}, [disconnect]);
useEffect(() => {
if (el) observe(el);
}, [observe, el]);
return {
observe,
size,
disconnect
disconnect,
observe
};
};

View File

@@ -1,37 +0,0 @@
import { useCallback } from 'react';
import { CoordsUtils, getTilePosition } from 'src/utils';
import { Coords, TileOriginEnum } from 'src/types';
import { useUiStateStore } from 'src/stores/uiStateStore';
export const useScroll = () => {
const scroll = useUiStateStore((state) => {
return state.scroll;
});
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const rendererSize = useUiStateStore((state) => {
return state.rendererSize;
});
const scrollToTile = useCallback(
(tile: Coords, origin?: TileOriginEnum) => {
const tilePosition = getTilePosition({ tile, origin });
const scrollTo: Coords = {
x: scroll.position.x - tilePosition.x + rendererSize.width / 2,
y: scroll.position.y - tilePosition.y + rendererSize.height / 2
};
uiStateActions.setScroll({
offset: CoordsUtils.zero(),
position: scrollTo
});
},
[scroll.position, uiStateActions, rendererSize]
);
return {
scroll,
scrollToTile
};
};

View File

@@ -14,13 +14,13 @@ export const useWindowUtils = () => {
const sceneActions = useSceneStore(({ actions }) => {
return actions;
});
const { fitProjectToScreen, getUnprojectedBounds } = useDiagramUtils();
const { fitToView, getUnprojectedBounds } = useDiagramUtils();
useEffect(() => {
window.Isoflow = {
getUnprojectedBounds,
fitProjectToScreen,
fitToView,
setScene: sceneActions.setScene
};
}, [getUnprojectedBounds, fitProjectToScreen, scene, sceneActions]);
}, [getUnprojectedBounds, fitToView, scene, sceneActions]);
};

View File

@@ -66,7 +66,7 @@ export const TransformRectangle: ModeActions = {
}
}
},
mousedown: ({ uiState, scene }) => {
mousedown: ({ uiState, scene, rendererSize }) => {
if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return;
const { item: rectangle } = getItemById(scene.rectangles, uiState.mode.id);
@@ -77,7 +77,7 @@ export const TransformRectangle: ModeActions = {
return isoToScreen({
tile: corner,
origin: outermostCornerPositions[i],
rendererSize: uiState.rendererSize
rendererSize
});
});
const activeAnchorIndex = anchorPositions.findIndex((anchorPosition) => {

View File

@@ -3,6 +3,7 @@ import { useSceneStore } from 'src/stores/sceneStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { ModeActions, State, SlimMouseEvent } from 'src/types';
import { getMouse } from 'src/utils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { Cursor } from './modes/Cursor';
import { DragItems } from './modes/DragItems';
import { DrawRectangle } from './modes/Rectangle/DrawRectangle';
@@ -46,6 +47,7 @@ export const useInteractionManager = () => {
const scene = useSceneStore((state) => {
return state;
});
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
const onMouseEvent = useCallback(
(e: SlimMouseEvent) => {
@@ -62,7 +64,7 @@ export const useInteractionManager = () => {
scroll: uiState.scroll,
lastMouse: uiState.mouse,
mouseEvent: e,
rendererSize: uiState.rendererSize
rendererSize
});
uiState.actions.setMouse(nextMouse);
@@ -71,6 +73,7 @@ export const useInteractionManager = () => {
scene,
uiState,
rendererRef: rendererRef.current,
rendererSize,
isRendererInteraction: rendererRef.current === e.target
};
@@ -91,11 +94,11 @@ export const useInteractionManager = () => {
modeFunction(baseState);
reducerTypeRef.current = uiState.mode.type;
},
[scene, uiState]
[scene, uiState, rendererSize]
);
useEffect(() => {
if (uiState.editorMode === 'NON_INTERACTIVE') return;
if (uiState.mode.type === 'INTERACTIONS_DISABLED') return;
const el = window;
@@ -141,7 +144,7 @@ export const useInteractionManager = () => {
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
};
}, [uiState.editorMode, onMouseEvent]);
}, [uiState.editorMode, onMouseEvent, uiState.mode.type]);
const setElement = useCallback((element: HTMLElement) => {
rendererRef.current = element;

View File

@@ -4,9 +4,11 @@ import {
CoordsUtils,
incrementZoom,
decrementZoom,
getStartingMode
getStartingMode,
getTilePosition,
getTileScrollPosition
} from 'src/utils';
import { UiStateStore } from 'src/types';
import { UiStateStore, Coords } from 'src/types';
const initialState = () => {
return createStore<UiStateStore>((set, get) => {
@@ -15,22 +17,21 @@ const initialState = () => {
editorMode: 'EXPLORABLE_READONLY',
mode: getStartingMode('EXPLORABLE_READONLY'),
iconCategoriesState: [],
disableInteractions: false,
isMainMenuOpen: false,
dialog: null,
rendererEl: null,
mouse: {
position: { screen: CoordsUtils.zero(), tile: CoordsUtils.zero() },
mousedown: null,
delta: null
},
itemControls: null,
contextMenu: null,
scroll: {
position: { x: 0, y: 0 },
offset: { x: 0, y: 0 }
},
enableDebugTools: false,
zoom: 1,
rendererSize: { width: 0, height: 0 },
actions: {
setMainMenuOptions: (mainMenuOptions) => {
set({ mainMenuOptions });
@@ -49,13 +50,15 @@ const initialState = () => {
offset: CoordsUtils.zero()
},
itemControls: null,
contextMenu: null,
zoom: 1
});
},
setMode: (mode) => {
set({ mode });
},
setDialog: (dialog) => {
set({ dialog });
},
setIsMainMenuOpen: (isMainMenuOpen) => {
set({ isMainMenuOpen, itemControls: null });
},
@@ -73,20 +76,25 @@ const initialState = () => {
setScroll: ({ position, offset }) => {
set({ scroll: { position, offset: offset ?? get().scroll.offset } });
},
scrollToTile: (tile, origin) => {
const scrollTo = getTileScrollPosition(tile, origin);
get().actions.setScroll({
offset: CoordsUtils.zero(),
position: scrollTo
});
},
setItemControls: (itemControls) => {
set({ itemControls });
},
setContextMenu: (contextMenu) => {
set({ contextMenu });
},
setMouse: (mouse) => {
set({ mouse });
},
setRendererSize: (rendererSize) => {
set({ rendererSize });
},
setenableDebugTools: (enableDebugTools) => {
set({ enableDebugTools });
},
setRendererEl: (el) => {
set({ rendererEl: el });
}
}
};

View File

@@ -47,6 +47,8 @@ const createShadows = () => {
const shadows = Array(25)
.fill('none')
.map((shadow, i) => {
if (i === 0) return 'none';
return `0px 10px 20px ${i - 10}px rgba(0,0,0,0.25)`;
}) as Required<ThemeOptions>['shadows'];

View File

@@ -33,8 +33,9 @@ export const EditorModeEnum = {
export const MainMenuOptionsEnum = {
OPEN: 'OPEN',
SAVE_JSON: 'SAVE_JSON',
CLEAR: 'CLEAR',
EXPORT_JSON: 'EXPORT_JSON',
EXPORT_PNG: 'EXPORT_PNG',
CLEAR_CANVAS: 'CLEAR_CANVAS',
GITHUB: 'GITHUB',
DISCORD: 'DISCORD',
VERSION: 'VERSION'

View File

@@ -8,6 +8,7 @@ import {
rectangleInput,
connectorStyleEnum
} from 'src/validation/sceneItems';
import { Coords } from 'src/types';
import { sceneInput } from 'src/validation/scene';
import type { EditorModeEnum, MainMenuOptions } from './common';
@@ -22,6 +23,7 @@ export type SceneInput = z.infer<typeof sceneInput>;
export type InitialScene = Partial<SceneInput> & {
zoom?: number;
scroll?: Coords;
};
export interface IsoflowProps {

View File

@@ -1,9 +1,10 @@
import { SceneStore, UiStateStore } from 'src/types';
import { SceneStore, UiStateStore, Size } from 'src/types';
export interface State {
scene: SceneStore;
uiState: UiStateStore;
rendererRef: HTMLElement;
rendererSize: Size;
isRendererInteraction: boolean;
}

View File

@@ -1,5 +1,5 @@
import { Coords, Size, EditorModeEnum, MainMenuOptions } from './common';
import { SceneItem, Connector, SceneItemReference } from './scene';
import { Coords, EditorModeEnum, MainMenuOptions } from './common';
import { Connector, SceneItemReference, TileOriginEnum } from './scene';
import { IconInput } from './inputs';
interface NodeControls {
@@ -131,14 +131,6 @@ export type Mode =
| TextBoxMode;
// End mode types
export type ContextMenu =
| SceneItem
| {
type: 'EMPTY_TILE';
position: Coords;
}
| null;
export interface Scroll {
position: Coords;
offset: Coords;
@@ -153,18 +145,22 @@ export type IconCollectionStateWithIcons = IconCollectionState & {
icons: IconInput[];
};
export const DialogTypeEnum = {
EXPORT_IMAGE: 'EXPORT_IMAGE'
} as const;
export interface UiState {
mainMenuOptions: MainMenuOptions;
editorMode: keyof typeof EditorModeEnum;
iconCategoriesState: IconCollectionState[];
mode: Mode;
dialog: keyof typeof DialogTypeEnum | null;
isMainMenuOpen: boolean;
itemControls: ItemControls;
contextMenu: ContextMenu;
zoom: number;
scroll: Scroll;
mouse: Mouse;
rendererSize: Size;
rendererEl: HTMLDivElement | null;
enableDebugTools: boolean;
}
@@ -177,12 +173,13 @@ export interface UiStateActions {
incrementZoom: () => void;
decrementZoom: () => void;
setIsMainMenuOpen: (isOpen: boolean) => void;
setDialog: (dialog: keyof typeof DialogTypeEnum | null) => void;
setZoom: (zoom: number) => void;
setScroll: (scroll: Scroll) => void;
scrollToTile: (tile: Coords, origin?: TileOriginEnum) => void;
setItemControls: (itemControls: ItemControls) => void;
setContextMenu: (contextMenu: ContextMenu) => void;
setMouse: (mouse: Mouse) => void;
setRendererSize: (rendererSize: Size) => void;
setRendererEl: (el: HTMLDivElement) => void;
setenableDebugTools: (enabled: boolean) => void;
}

View File

@@ -0,0 +1,54 @@
import domtoimage from 'dom-to-image';
import FileSaver from 'file-saver';
import { Scene, Size } from '../types';
import { sceneToSceneInput } from './inputs';
export const generateGenericFilename = (extension: string) => {
return `isoflow-export-${new Date().toISOString()}.${extension}`;
};
export const base64ToBlob = (
base64: string,
contentType: string,
sliceSize = 512
) => {
const byteCharacters = atob(base64);
const byteArrays = [];
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i += 1) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
const blob = new Blob(byteArrays, { type: contentType });
return blob;
};
export const downloadFile = (data: Blob, filename: string) => {
FileSaver.saveAs(data, filename);
};
export const exportAsJSON = (scene: Scene) => {
const parsedScene = sceneToSceneInput(scene);
const data = new Blob([JSON.stringify(parsedScene)], {
type: 'application/json;charset=utf-8'
});
downloadFile(data, generateGenericFilename('json'));
};
export const exportAsImage = async (el: HTMLDivElement, size?: Size) => {
const imageData = await domtoimage.toPng(el, { ...size });
return imageData;
};

View File

@@ -4,3 +4,4 @@ export * from './common';
export * from './inputs';
export * from './pathfinder';
export * from './renderer';
export * from './exportOptions';

View File

@@ -577,3 +577,15 @@ export const getAnchorParent = (anchorId: string, connectors: Connector[]) => {
return connector;
};
export const getTileScrollPosition = (
tile: Coords,
origin?: TileOriginEnum
): Coords => {
const tilePosition = getTilePosition({ tile, origin });
return {
x: -tilePosition.x,
y: -tilePosition.y
};
};