mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2026-04-23 08:31:16 -04:00
feat: implements image export
This commit is contained in:
24
package-lock.json
generated
24
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
165
src/components/ExportImageDialog/ExportImageDialog.tsx
Normal file
165
src/components/ExportImageDialog/ExportImageDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/components/Loader/Loader.tsx
Normal file
24
src/components/Loader/Loader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
2
src/global.d.ts
vendored
@@ -7,7 +7,7 @@ declare global {
|
||||
interface Window {
|
||||
Isoflow: {
|
||||
getUnprojectedBounds: () => Size & Coords;
|
||||
fitProjectToScreen: () => void;
|
||||
fitToView: () => void;
|
||||
setScene: (scene: SceneInput) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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'];
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
54
src/utils/exportOptions.ts
Normal file
54
src/utils/exportOptions.ts
Normal 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;
|
||||
};
|
||||
@@ -4,3 +4,4 @@ export * from './common';
|
||||
export * from './inputs';
|
||||
export * from './pathfinder';
|
||||
export * from './renderer';
|
||||
export * from './exportOptions';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user