├── .nvmrc ├── docs ├── .gitignore ├── pages │ ├── _meta.json │ ├── docs │ │ ├── api │ │ │ ├── _meta.json │ │ │ └── index.mdx │ │ ├── _meta.json │ │ ├── index.mdx │ │ ├── contributing.mdx │ │ ├── installation.mdx │ │ ├── quickstart.mdx │ │ └── isopacks.mdx │ └── index.tsx ├── next.config.js ├── next-env.d.ts ├── package.json ├── tsconfig.json └── theme.config.tsx ├── jest.setup.js ├── src ├── types │ ├── rendererProps.ts │ ├── index.ts │ ├── isoflowProps.ts │ ├── interactions.ts │ ├── common.ts │ ├── scene.ts │ └── model.ts ├── index.ts ├── stores │ └── reducers │ │ ├── index.ts │ │ ├── modelItem.ts │ │ ├── layerOrdering.ts │ │ ├── __tests__ │ │ ├── modelItem.test.ts │ │ └── layerOrdering.test.ts │ │ ├── rectangle.ts │ │ ├── textBox.ts │ │ ├── types.ts │ │ ├── viewItem.ts │ │ └── connector.ts ├── module.d.ts ├── schemas │ ├── rectangle.ts │ ├── colors.ts │ ├── index.ts │ ├── common.ts │ ├── modelItems.ts │ ├── icons.ts │ ├── textBox.ts │ ├── connector.ts │ ├── __tests__ │ │ ├── textBox.test.ts │ │ ├── rectangle.test.ts │ │ ├── connector.test.ts │ │ ├── colors.test.ts │ │ ├── icons.test.ts │ │ ├── modelItems.test.ts │ │ └── views.test.ts │ ├── views.ts │ └── model.ts ├── utils │ ├── index.ts │ ├── __tests__ │ │ ├── common.test.ts │ │ └── immer.test.ts │ ├── pathfinder.ts │ ├── CoordsUtils.ts │ ├── SizeUtils.ts │ ├── model.ts │ └── common.ts ├── fixtures │ ├── colors.ts │ ├── icons.ts │ ├── modelItems.ts │ ├── model.ts │ └── views.ts ├── examples │ ├── BasicEditor │ │ └── BasicEditor.tsx │ ├── ReadonlyMode │ │ └── ReadonlyMode.tsx │ ├── DebugTools │ │ └── DebugTools.tsx │ └── index.tsx ├── components │ ├── DebugUtils │ │ ├── __tests__ │ │ │ ├── __snapshots__ │ │ │ │ ├── SizeIndicator.test.tsx.snap │ │ │ │ ├── Value.test.tsx.snap │ │ │ │ └── LineItem.test.tsx.snap │ │ │ ├── Value.test.tsx │ │ │ ├── LineItem.test.tsx │ │ │ ├── DebugUtils.test.tsx │ │ │ └── SizeIndicator.test.tsx │ │ ├── Value.tsx │ │ ├── SizeIndicator.tsx │ │ ├── LineItem.tsx │ │ └── DebugUtils.tsx │ ├── Circle │ │ └── Circle.tsx │ ├── Gradient │ │ └── Gradient.tsx │ ├── SceneLayers │ │ ├── Nodes │ │ │ ├── Nodes.tsx │ │ │ └── Node │ │ │ │ ├── IconTypes │ │ │ │ ├── NonIsometricIcon.tsx │ │ │ │ └── IsometricIcon.tsx │ │ │ │ └── Node.tsx │ │ ├── TextBoxes │ │ │ ├── TextBoxes.tsx │ │ │ └── TextBox.tsx │ │ ├── Rectangles │ │ │ ├── Rectangles.tsx │ │ │ └── Rectangle.tsx │ │ ├── ConnectorLabels │ │ │ ├── ConnectorLabels.tsx │ │ │ └── ConnectorLabel.tsx │ │ └── Connectors │ │ │ └── Connectors.tsx │ ├── TransformControlsManager │ │ ├── NodeTransformControls.tsx │ │ ├── TextBoxTransformControls.tsx │ │ ├── TransformControlsManager.tsx │ │ ├── RectangleTransformControls.tsx │ │ ├── TransformAnchor.tsx │ │ └── TransformControls.tsx │ ├── ItemControls │ │ ├── components │ │ │ ├── DeleteButton.tsx │ │ │ ├── Header.tsx │ │ │ ├── Section.tsx │ │ │ └── ControlsContainer.tsx │ │ ├── IconSelectionControls │ │ │ ├── Searchbox.tsx │ │ │ ├── IconGrid.tsx │ │ │ ├── Icons.tsx │ │ │ ├── Icon.tsx │ │ │ ├── IconCollection.tsx │ │ │ └── IconSelectionControls.tsx │ │ ├── ItemControlsManager.tsx │ │ ├── RectangleControls │ │ │ └── RectangleControls.tsx │ │ ├── NodeControls │ │ │ └── NodeSettings │ │ │ │ └── NodeSettings.tsx │ │ └── ConnectorControls │ │ │ └── ConnectorControls.tsx │ ├── UiElement │ │ └── UiElement.tsx │ ├── MainMenu │ │ └── MenuItem.tsx │ ├── Loader │ │ └── Loader.tsx │ ├── Cursor │ │ └── Cursor.tsx │ ├── DragAndDrop │ │ └── DragAndDrop.tsx │ ├── ColorSelector │ │ ├── ColorPicker.tsx │ │ ├── ColorSelector.tsx │ │ └── ColorSwatch.tsx │ ├── Svg │ │ └── Svg.tsx │ ├── ContextMenu │ │ └── ContextMenu.tsx │ ├── Label │ │ ├── ExpandButton.tsx │ │ ├── Label.tsx │ │ └── ExpandableLabel.tsx │ ├── IsoTileArea │ │ └── IsoTileArea.tsx │ ├── SceneLayer │ │ └── SceneLayer.tsx │ ├── MarkdownEditor │ │ └── MarkdownEditor.tsx │ ├── IconButton │ │ └── IconButton.tsx │ ├── Grid │ │ └── Grid.tsx │ ├── ZoomControls │ │ └── ZoomControls.tsx │ └── Lasso │ │ └── Lasso.tsx ├── styles │ ├── GlobalStyles.tsx │ └── theme.ts ├── hooks │ ├── useWindowUtils.ts │ ├── useViewItem.ts │ ├── useTextBox.ts │ ├── useConnector.ts │ ├── useRectangle.ts │ ├── useColor.ts │ ├── useModelItem.ts │ ├── useTextBoxProps.ts │ ├── useIconFiltering.ts │ ├── useIconCategories.ts │ ├── useView.ts │ ├── useResizeObserver.ts │ ├── useIcon.tsx │ ├── useDiagramUtils.ts │ └── useIsoProjection.ts ├── standaloneExports.ts ├── assets │ └── grid-tile-bg.svg ├── index.html ├── index.tsx ├── index-docker.tsx ├── interaction │ └── modes │ │ ├── TextBox.ts │ │ ├── Pan.ts │ │ ├── PlaceIcon.ts │ │ ├── Rectangle │ │ ├── DrawRectangle.ts │ │ └── TransformRectangle.ts │ │ ├── Lasso.ts │ │ └── Connector.ts ├── global.d.ts ├── config.ts └── Isoflow.tsx ├── .prettierrc ├── tsconfig.dev.json ├── .npmignore ├── .vscode └── settings.json ├── .gitignore ├── tsconfig.declaration.json ├── jest.config.js ├── tsconfig.json ├── Dockerfile ├── README.md ├── .codesandbox └── tasks.json ├── LICENSE ├── .eslintrc ├── webpack ├── docker.config.js ├── dev.config.js └── prod.config.js └── .circleci └── config.yml /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.19.0 -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require('@testing-library/jest-dom'); -------------------------------------------------------------------------------- /docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": "Isoflow Community Edition" 3 | } -------------------------------------------------------------------------------- /docs/pages/docs/api/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Props", 3 | "initialData": "InitialData" 4 | } -------------------------------------------------------------------------------- /src/types/rendererProps.ts: -------------------------------------------------------------------------------- 1 | export interface RendererProps { 2 | showGrid?: boolean; 3 | backgroundColor?: string; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "trailingComma": "none", 4 | "singleQuote": true, 5 | "printWidth": 80, 6 | "tabWidth": 2 7 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Isoflow, useIsoflow } from './Isoflow'; 2 | export * from './standaloneExports'; 3 | export { default } from './Isoflow'; -------------------------------------------------------------------------------- /src/stores/reducers/index.ts: -------------------------------------------------------------------------------- 1 | export { view } from './view'; 2 | export * from './modelItem'; 3 | export { syncConnector } from './connector'; 4 | -------------------------------------------------------------------------------- /src/module.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: React.FunctionComponent>; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": false, 5 | "emitDeclarationOnly": false 6 | } 7 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './common'; 2 | export * from './model'; 3 | export * from './scene'; 4 | export * from './ui'; 5 | export * from './interactions'; 6 | export * from './isoflowProps'; 7 | -------------------------------------------------------------------------------- /docs/next.config.js: -------------------------------------------------------------------------------- 1 | const withNextra = require('nextra')({ 2 | theme: 'nextra-theme-docs', 3 | themeConfig: './theme.config.tsx', 4 | basePath: '/docs', 5 | }); 6 | 7 | module.exports = withNextra(); 8 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/schemas/rectangle.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { id, coords } from './common'; 3 | 4 | export const rectangleSchema = z.object({ 5 | id, 6 | color: id.optional(), 7 | from: coords, 8 | to: coords 9 | }); 10 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CoordsUtils'; 2 | export * from './SizeUtils'; 3 | export * from './common'; 4 | export * from './pathfinder'; 5 | export * from './renderer'; 6 | export * from './exportOptions'; 7 | export * from './model'; 8 | -------------------------------------------------------------------------------- /src/fixtures/colors.ts: -------------------------------------------------------------------------------- 1 | import { Colors } from 'src/types'; 2 | 3 | export const colors: Colors = [ 4 | { 5 | id: 'color1', 6 | value: '#000000' 7 | }, 8 | { 9 | id: 'color2', 10 | value: '#ffffff' 11 | } 12 | ]; 13 | -------------------------------------------------------------------------------- /src/schemas/colors.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { id } from './common'; 3 | 4 | export const colorSchema = z.object({ 5 | id, 6 | value: z.string().max(7) 7 | }); 8 | 9 | export const colorsSchema = z.array(colorSchema); 10 | -------------------------------------------------------------------------------- /docs/pages/docs/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "About the Community Edition", 3 | "installation": "Installation", 4 | "quickstart": "Quick start", 5 | "isopacks": "Loading Isopacks", 6 | "api": "API", 7 | "contributing": "Contributing" 8 | } -------------------------------------------------------------------------------- /src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export * from './model'; 2 | export * from './colors'; 3 | export * from './icons'; 4 | export * from './modelItems'; 5 | export * from './views'; 6 | export * from './connector'; 7 | export * from './rectangle'; 8 | export * from './textBox'; 9 | -------------------------------------------------------------------------------- /src/examples/BasicEditor/BasicEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Isoflow from 'src/Isoflow'; 3 | import { initialData } from '../initialData'; 4 | 5 | export const BasicEditor = () => { 6 | return ; 7 | }; 8 | -------------------------------------------------------------------------------- /docs/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | export default function Home() { 5 | const { push } = useRouter(); 6 | 7 | useEffect(() => { 8 | push('/docs'); 9 | }, [push]); 10 | 11 | return null; 12 | } 13 | -------------------------------------------------------------------------------- /src/schemas/common.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const coords = z.object({ 4 | x: z.number(), 5 | y: z.number() 6 | }); 7 | 8 | export const id = z.string(); 9 | export const color = z.string(); 10 | 11 | export const constrainedStrings = { 12 | name: z.string().max(100), 13 | description: z.string().max(1000) 14 | }; 15 | -------------------------------------------------------------------------------- /src/examples/ReadonlyMode/ReadonlyMode.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Isoflow from 'src/Isoflow'; 3 | import { initialData } from '../initialData'; 4 | 5 | export const ReadonlyMode = () => { 6 | return ( 7 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/schemas/modelItems.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { id, constrainedStrings } from './common'; 3 | 4 | export const modelItemSchema = z.object({ 5 | id, 6 | name: constrainedStrings.name, 7 | description: constrainedStrings.description.optional(), 8 | icon: id.optional() 9 | }); 10 | 11 | export const modelItemsSchema = z.array(modelItemSchema); 12 | -------------------------------------------------------------------------------- /src/components/DebugUtils/__tests__/__snapshots__/SizeIndicator.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SizeIndicator matches snapshot 1`] = ` 4 | 5 |
9 | 10 | `; 11 | -------------------------------------------------------------------------------- /src/examples/DebugTools/DebugTools.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Isoflow from 'src/Isoflow'; 3 | import { initialData } from '../initialData'; 4 | 5 | export const DebugTools = () => { 6 | return ( 7 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/schemas/icons.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { id, constrainedStrings } from './common'; 3 | 4 | export const iconSchema = z.object({ 5 | id, 6 | name: constrainedStrings.name, 7 | url: z.string(), 8 | collection: constrainedStrings.name.optional(), 9 | isIsometric: z.boolean().optional() 10 | }); 11 | 12 | export const iconsSchema = z.array(iconSchema); 13 | -------------------------------------------------------------------------------- /src/components/Circle/Circle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Coords } from 'src/types'; 3 | 4 | interface Props { 5 | tile: Coords; 6 | radius?: number; 7 | } 8 | 9 | export const Circle = ({ 10 | tile, 11 | radius, 12 | ...rest 13 | }: Props & React.SVGProps) => { 14 | return ; 15 | }; 16 | -------------------------------------------------------------------------------- /src/fixtures/icons.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'src/types'; 2 | 3 | export const icons: Model['icons'] = [ 4 | { 5 | id: 'icon1', 6 | name: 'Icon1', 7 | url: 'https://isoflow.io/static/assets/icons/networking/server.svg' 8 | }, 9 | { 10 | id: 'icon2', 11 | name: 'Icon2', 12 | url: 'https://isoflow.io/static/assets/icons/networking/block.svg' 13 | } 14 | ]; 15 | -------------------------------------------------------------------------------- /src/styles/GlobalStyles.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { GlobalStyles as MUIGlobalStyles } from '@mui/material'; 3 | import 'react-quill/dist/quill.snow.css'; 4 | 5 | export const GlobalStyles = () => { 6 | return ( 7 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/hooks/useWindowUtils.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDiagramUtils } from 'src/hooks/useDiagramUtils'; 3 | 4 | export const useWindowUtils = () => { 5 | const { fitToView, getUnprojectedBounds } = useDiagramUtils(); 6 | 7 | useEffect(() => { 8 | window.Isoflow = { 9 | getUnprojectedBounds, 10 | fitToView 11 | }; 12 | }, [getUnprojectedBounds, fitToView]); 13 | }; 14 | -------------------------------------------------------------------------------- /src/fixtures/modelItems.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'src/types'; 2 | 3 | export const modelItems: Model['items'] = [ 4 | { 5 | id: 'node1', 6 | name: 'Node1', 7 | icon: 'icon1', 8 | description: 'Node1Description' 9 | }, 10 | { 11 | id: 'node2', 12 | name: 'Node2', 13 | icon: 'icon2' 14 | }, 15 | { 16 | id: 'node3', 17 | name: 'Node3', 18 | icon: 'icon1' 19 | } 20 | ]; 21 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | webpack/ 4 | docs/ 5 | .circleci/ 6 | .codesandbox/ 7 | .vscode/ 8 | 9 | # Config files 10 | .eslintrc 11 | .prettierrc 12 | .nvmrc 13 | jest.config.js 14 | tsconfig.json 15 | *.config.js 16 | 17 | # Build artifacts 18 | node_modules/ 19 | *.log 20 | 21 | # Documentation 22 | *.md 23 | !README.md 24 | !LICENSE 25 | 26 | # Git 27 | .git/ 28 | .gitignore 29 | 30 | # Other 31 | Dockerfile -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "eslint.validate": [ 6 | "javascript", 7 | "javascriptreact", 8 | "typescript", 9 | "typescriptreact" 10 | ], 11 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 12 | "editor.formatOnSave": true, 13 | "vs-code-prettier-eslint.prettierLast": false, 14 | } -------------------------------------------------------------------------------- /src/fixtures/model.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'src/types'; 2 | import { icons } from './icons'; 3 | import { modelItems } from './modelItems'; 4 | import { views } from './views'; 5 | import { colors } from './colors'; 6 | 7 | export const model: Model = { 8 | version: '1.0.0', 9 | title: 'TestModel', 10 | description: 'TestModelDescription', 11 | colors, 12 | icons, 13 | items: modelItems, 14 | views 15 | } as const; 16 | -------------------------------------------------------------------------------- /src/components/DebugUtils/__tests__/__snapshots__/Value.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Value matches snapshot 1`] = ` 4 | 5 |
8 |

11 | Snapshot Value 12 |

13 |
14 |
15 | `; 16 | -------------------------------------------------------------------------------- /src/hooks/useViewItem.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { getItemById } from 'src/utils'; 3 | import { useScene } from 'src/hooks/useScene'; 4 | 5 | export const useViewItem = (id: string) => { 6 | const { items } = useScene(); 7 | 8 | const viewItem = useMemo(() => { 9 | const item = getItemById(items, id); 10 | return item ? item.value : null; 11 | }, [items, id]); 12 | 13 | return viewItem; 14 | }; 15 | -------------------------------------------------------------------------------- /src/hooks/useTextBox.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { getItemById } from 'src/utils'; 3 | import { useScene } from 'src/hooks/useScene'; 4 | 5 | export const useTextBox = (id: string) => { 6 | const { textBoxes } = useScene(); 7 | 8 | const textBox = useMemo(() => { 9 | const item = getItemById(textBoxes, id); 10 | return item ? item.value : null; 11 | }, [textBoxes, id]); 12 | 13 | return textBox; 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | documentation.json -------------------------------------------------------------------------------- /tsconfig.declaration.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "emitDeclarationOnly": true, 5 | "declaration": true, 6 | "declarationMap": false, 7 | "noEmit": false, 8 | "outDir": "./dist" 9 | }, 10 | "exclude": ["node_modules", "./dist", "./docs", "**/*.test.ts", "**/*.test.tsx"], 11 | "include": [ 12 | "src/**/*.ts", 13 | "src/**/*.tsx", 14 | "src/global.d.ts" 15 | ] 16 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "jsdom", 5 | modulePaths: ['node_modules', ''], 6 | setupFilesAfterEnv: ['/jest.setup.js'] 7 | testEnvironment: "node", 8 | modulePaths: ['node_modules', ''], 9 | testPathIgnorePatterns: [ 10 | '/node_modules/', 11 | '/dist/', 12 | '\\.d\\.ts$' 13 | ] 14 | }; 15 | -------------------------------------------------------------------------------- /docs/pages/docs/index.mdx: -------------------------------------------------------------------------------- 1 | # About 2 | Isoflow is an open-core project. We offer the [Isoflow Community Edition](https://github.com/markmanx/isoflow) as fully-functional, open-source software under the MIT license. In addition, we also support our development efforts by offering **Isoflow Pro** with additional features for commercial use. You can read more about the differences between Pro and the Community Edition [here](https://isoflow.io/pro-vs-community-edition). -------------------------------------------------------------------------------- /src/hooks/useConnector.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { getItemById } from 'src/utils'; 3 | import { useScene } from 'src/hooks/useScene'; 4 | 5 | export const useConnector = (id: string) => { 6 | const { connectors } = useScene(); 7 | 8 | const connector = useMemo(() => { 9 | const item = getItemById(connectors, id); 10 | return item ? item.value : null; 11 | }, [connectors, id]); 12 | 13 | return connector; 14 | }; 15 | -------------------------------------------------------------------------------- /src/hooks/useRectangle.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { getItemById } from 'src/utils'; 3 | import { useScene } from 'src/hooks/useScene'; 4 | 5 | export const useRectangle = (id: string) => { 6 | const { rectangles } = useScene(); 7 | 8 | const rectangle = useMemo(() => { 9 | const item = getItemById(rectangles, id); 10 | return item ? item.value : null; 11 | }, [rectangles, id]); 12 | 13 | return rectangle; 14 | }; 15 | -------------------------------------------------------------------------------- /src/components/Gradient/Gradient.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, SxProps } from '@mui/material'; 3 | 4 | interface Props { 5 | sx?: SxProps; 6 | } 7 | 8 | export const Gradient = ({ sx }: Props) => { 9 | return ( 10 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "isoflow-docs", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "next dev -p 3002", 8 | "build": "next build", 9 | "start": "next start -p 3002" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "next": "^13.4.19", 15 | "nextra": "^2.12.1", 16 | "nextra-theme-docs": "^2.12.1", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/SceneLayers/Nodes/Nodes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ViewItem } from 'src/types'; 3 | import { Node } from './Node/Node'; 4 | 5 | interface Props { 6 | nodes: ViewItem[]; 7 | } 8 | 9 | export const Nodes = ({ nodes }: Props) => { 10 | return ( 11 | <> 12 | {[...nodes].reverse().map((node) => { 13 | return ( 14 | 15 | ); 16 | })} 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/TransformControlsManager/NodeTransformControls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useViewItem } from 'src/hooks/useViewItem'; 3 | import { TransformControls } from './TransformControls'; 4 | 5 | interface Props { 6 | id: string; 7 | } 8 | 9 | export const NodeTransformControls = ({ id }: Props) => { 10 | const node = useViewItem(id); 11 | 12 | if (!node) { 13 | return null; 14 | } 15 | 16 | return ; 17 | }; 18 | -------------------------------------------------------------------------------- /src/schemas/textBox.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { ProjectionOrientationEnum } from 'src/types/common'; 3 | import { id, coords, constrainedStrings } from './common'; 4 | 5 | export const textBoxSchema = z.object({ 6 | id, 7 | tile: coords, 8 | content: constrainedStrings.name, 9 | fontSize: z.number().optional(), 10 | orientation: z 11 | .union([ 12 | z.literal(ProjectionOrientationEnum.X), 13 | z.literal(ProjectionOrientationEnum.Y) 14 | ]) 15 | .optional() 16 | }); 17 | -------------------------------------------------------------------------------- /src/standaloneExports.ts: -------------------------------------------------------------------------------- 1 | // This file will be exported as it's own bundle (separate to the main bundle). This is because the main 2 | // bundle requires `window` to be present and so can't be imported into a Node environment. 3 | export const version = PACKAGE_VERSION; 4 | export * as reducers from 'src/stores/reducers'; 5 | export { INITIAL_DATA, INITIAL_SCENE_STATE } from 'src/config'; 6 | export * from 'src/schemas'; 7 | export type { IsoflowProps, InitialData } from 'src/types'; 8 | export * from 'src/types/model'; 9 | -------------------------------------------------------------------------------- /src/components/SceneLayers/TextBoxes/TextBoxes.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useScene } from 'src/hooks/useScene'; 3 | import { TextBox } from './TextBox'; 4 | 5 | interface Props { 6 | textBoxes: ReturnType['textBoxes']; 7 | } 8 | 9 | export const TextBoxes = ({ textBoxes }: Props) => { 10 | return ( 11 | <> 12 | {[...textBoxes].reverse().map((textBox) => { 13 | return ; 14 | })} 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/SceneLayers/Rectangles/Rectangles.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useScene } from 'src/hooks/useScene'; 3 | import { Rectangle } from './Rectangle'; 4 | 5 | interface Props { 6 | rectangles: ReturnType['rectangles']; 7 | } 8 | 9 | export const Rectangles = ({ rectangles }: Props) => { 10 | return ( 11 | <> 12 | {[...rectangles].reverse().map((rectangle) => { 13 | return ; 14 | })} 15 | 16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /src/hooks/useColor.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { getItemById } from 'src/utils'; 3 | import { useScene } from 'src/hooks/useScene'; 4 | 5 | export const useColor = (colorId?: string) => { 6 | const { colors } = useScene(); 7 | 8 | const color = useMemo(() => { 9 | if (colorId === undefined) { 10 | return colors.length > 0 ? colors[0] : null; 11 | } 12 | 13 | const item = getItemById(colors, colorId); 14 | return item ? item.value : null; 15 | }, [colorId, colors]); 16 | 17 | return color; 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/DebugUtils/__tests__/Value.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { Value } from '../Value'; 4 | 5 | describe('Value', () => { 6 | it('renders value', () => { 7 | render(); 8 | expect(screen.getByText('Test Value')).toBeInTheDocument(); 9 | }); 10 | 11 | it('matches snapshot', () => { 12 | const { asFragment } = render(); 13 | expect(asFragment()).toMatchSnapshot(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/hooks/useModelItem.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { ModelItem } from 'src/types'; 3 | import { useModelStore } from 'src/stores/modelStore'; 4 | import { getItemById } from 'src/utils'; 5 | 6 | export const useModelItem = (id: string): ModelItem | null => { 7 | const model = useModelStore((state) => { 8 | return state; 9 | }); 10 | 11 | const modelItem = useMemo(() => { 12 | const item = getItemById(model.items, id); 13 | return item ? item.value : null; 14 | }, [id, model.items]); 15 | 16 | return modelItem; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/ItemControls/components/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DeleteOutlined as DeleteIcon } from '@mui/icons-material'; 3 | import { Button } from '@mui/material'; 4 | 5 | interface Props { 6 | onClick: () => void; 7 | } 8 | 9 | export const DeleteButton = ({ onClick }: Props) => { 10 | return ( 11 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/assets/grid-tile-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/utils/__tests__/common.test.ts: -------------------------------------------------------------------------------- 1 | import { clamp } from '../common'; 2 | 3 | describe('Tests common utilities', () => { 4 | test('clamp() works correctly', () => { 5 | const clampNoChange = clamp(5, 0, 10); 6 | const clampMin = clamp(5, 6, 10); 7 | const clampMax = clamp(5, 0, 3); 8 | const clampDraw1 = clamp(5, 5, 10); 9 | const clampDraw2 = clamp(5, 0, 5); 10 | 11 | expect(clampNoChange).toBe(5); 12 | expect(clampMin).toBe(6); 13 | expect(clampMax).toBe(3); 14 | expect(clampDraw1).toBe(5); 15 | expect(clampDraw2).toBe(5); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/components/UiElement/UiElement.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card, SxProps } from '@mui/material'; 3 | 4 | interface Props { 5 | children: React.ReactNode; 6 | sx?: SxProps; 7 | style?: React.CSSProperties; 8 | } 9 | 10 | export const UiElement = ({ children, sx, style }: Props) => { 11 | return ( 12 | 22 | {children} 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": false, 11 | "noEmit": true, 12 | "incremental": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve" 19 | }, 20 | "include": [ 21 | "next-env.d.ts", 22 | "**/*.ts", 23 | "**/*.tsx" 24 | ], 25 | "exclude": [ 26 | "node_modules" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/types/isoflowProps.ts: -------------------------------------------------------------------------------- 1 | import type { EditorModeEnum, MainMenuOptions } from './common'; 2 | import type { Model } from './model'; 3 | import type { RendererProps } from './rendererProps'; 4 | 5 | export type InitialData = Model & { 6 | fitToView?: boolean; 7 | view?: string; 8 | }; 9 | 10 | export interface IsoflowProps { 11 | initialData?: InitialData; 12 | mainMenuOptions?: MainMenuOptions; 13 | onModelUpdated?: (Model: Model) => void; 14 | width?: number | string; 15 | height?: number | string; 16 | enableDebugTools?: boolean; 17 | editorMode?: keyof typeof EditorModeEnum; 18 | renderer?: RendererProps; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/MainMenu/MenuItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MenuItem as MuiMenuItem, ListItemIcon } from '@mui/material'; 3 | 4 | export interface Props { 5 | onClick?: () => void; 6 | Icon?: React.ReactNode; 7 | children: string | React.ReactNode; 8 | disabled?: boolean; 9 | } 10 | 11 | export const MenuItem = ({ 12 | onClick, 13 | Icon, 14 | children, 15 | disabled = false 16 | }: Props) => { 17 | return ( 18 | 19 | {Icon} 20 | {children} 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/types/interactions.ts: -------------------------------------------------------------------------------- 1 | import { ModelStore, UiStateStore, Size } from 'src/types'; 2 | import { useScene } from 'src/hooks/useScene'; 3 | 4 | export interface State { 5 | model: ModelStore; 6 | scene: ReturnType; 7 | uiState: UiStateStore; 8 | rendererRef: HTMLElement; 9 | rendererSize: Size; 10 | isRendererInteraction: boolean; 11 | } 12 | 13 | export type ModeActionsAction = (state: State) => void; 14 | 15 | export type ModeActions = { 16 | entry?: ModeActionsAction; 17 | exit?: ModeActionsAction; 18 | mousemove?: ModeActionsAction; 19 | mousedown?: ModeActionsAction; 20 | mouseup?: ModeActionsAction; 21 | }; 22 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | Development | Isoflow 11 | 12 | 18 | 19 | 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /src/components/SceneLayers/ConnectorLabels/ConnectorLabels.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useScene } from 'src/hooks/useScene'; 3 | import { ConnectorLabel } from './ConnectorLabel'; 4 | 5 | interface Props { 6 | connectors: ReturnType['connectors']; 7 | } 8 | 9 | export const ConnectorLabels = ({ connectors }: Props) => { 10 | return ( 11 | <> 12 | {connectors 13 | .filter((connector) => { 14 | return Boolean(connector.description); 15 | }) 16 | .map((connector) => { 17 | return ; 18 | })} 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/schemas/connector.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { coords, id, constrainedStrings } from './common'; 3 | 4 | export const connectorStyleOptions = ['SOLID', 'DOTTED', 'DASHED'] as const; 5 | 6 | export const anchorSchema = z.object({ 7 | id, 8 | ref: z 9 | .object({ 10 | item: id, 11 | anchor: id, 12 | tile: coords 13 | }) 14 | .partial() 15 | }); 16 | 17 | export const connectorSchema = z.object({ 18 | id, 19 | description: constrainedStrings.description.optional(), 20 | color: id.optional(), 21 | width: z.number().optional(), 22 | style: z.enum(connectorStyleOptions).optional(), 23 | anchors: z.array(anchorSchema) 24 | }); 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "paths": { 5 | "src/*": ["./*"] 6 | }, 7 | "outDir": "./dist", 8 | "noImplicitAny": true, 9 | "target": "es6", 10 | "jsx": "react-jsx", 11 | "allowJs": true, 12 | "allowSyntheticDefaultImports": true, 13 | "declaration": false, 14 | "declarationMap": false, 15 | "strict": true, 16 | "module": "es6", 17 | "moduleResolution": "node", 18 | "skipLibCheck": true, 19 | "useUnknownInCatchVariables": false 20 | }, 21 | "exclude": ["node_modules", "./dist", "./docs"], 22 | "include": [ 23 | "**/*.ts", 24 | "**/*.tsx", 25 | "src/global.d.ts" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/DebugUtils/__tests__/LineItem.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { LineItem } from '../LineItem'; 4 | 5 | describe('LineItem', () => { 6 | it('renders title and value', () => { 7 | render(); 8 | expect(screen.getByText('Test Title')).toBeInTheDocument(); 9 | expect(screen.getByText('Test Value')).toBeInTheDocument(); 10 | }); 11 | 12 | it('matches snapshot', () => { 13 | const { asFragment } = render( 14 | 15 | ); 16 | expect(asFragment()).toMatchSnapshot(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, CircularProgress, CircularProgressProps } from '@mui/material'; 3 | 4 | interface Props { 5 | size?: number; 6 | color?: CircularProgressProps['color']; 7 | isInline?: boolean; 8 | } 9 | 10 | export const Loader = ({ size = 1, color = 'primary', isInline }: Props) => { 11 | return ( 12 | 21 | 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/utils/pathfinder.ts: -------------------------------------------------------------------------------- 1 | import PF from 'pathfinding'; 2 | import { Size, Coords } from 'src/types'; 3 | 4 | interface Args { 5 | gridSize: Size; 6 | from: Coords; 7 | to: Coords; 8 | } 9 | 10 | export const findPath = ({ gridSize, from, to }: Args): Coords[] => { 11 | const grid = new PF.Grid(gridSize.width, gridSize.height); 12 | const finder = new PF.AStarFinder({ 13 | heuristic: PF.Heuristic.manhattan, 14 | diagonalMovement: PF.DiagonalMovement.Always 15 | }); 16 | const path = finder.findPath(from.x, from.y, to.x, to.y, grid); 17 | 18 | const pathTiles = path.map((tile) => { 19 | return { 20 | x: tile[0], 21 | y: tile[1] 22 | }; 23 | }); 24 | 25 | return pathTiles; 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/TransformControlsManager/TextBoxTransformControls.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { getTextBoxEndTile } from 'src/utils'; 3 | import { useTextBox } from 'src/hooks/useTextBox'; 4 | import { TransformControls } from './TransformControls'; 5 | 6 | interface Props { 7 | id: string; 8 | } 9 | 10 | export const TextBoxTransformControls = ({ id }: Props) => { 11 | const textBox = useTextBox(id); 12 | 13 | const to = useMemo(() => { 14 | if (!textBox) return { x: 0, y: 0 }; 15 | return getTextBoxEndTile(textBox, textBox.size); 16 | }, [textBox]); 17 | 18 | if (!textBox) { 19 | return null; 20 | } 21 | 22 | return ; 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/Cursor/Cursor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import chroma from 'chroma-js'; 3 | import { useTheme } from '@mui/material'; 4 | import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea'; 5 | import { useUiStateStore } from 'src/stores/uiStateStore'; 6 | 7 | export const Cursor = () => { 8 | const theme = useTheme(); 9 | const tile = useUiStateStore((state) => { 10 | return state.mouse.position.tile; 11 | }); 12 | const zoom = useUiStateStore((state) => { 13 | return state.zoom; 14 | }); 15 | 16 | return ( 17 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/DebugUtils/Value.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Typography } from '@mui/material'; 3 | 4 | interface Props { 5 | value: string; 6 | } 7 | 8 | export const Value = ({ value }: Props) => { 9 | return ( 10 | { 18 | return `1px solid ${theme.palette.grey[400]}`; 19 | }, 20 | borderRadius: 2, 21 | maxWidth: 200 22 | }} 23 | > 24 | 25 | {value} 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/schemas/__tests__/textBox.test.ts: -------------------------------------------------------------------------------- 1 | import { textBoxSchema } from '../textBox'; 2 | 3 | describe('textBoxSchema', () => { 4 | it('validates a correct text box', () => { 5 | const valid = { id: 'tb1', tile: { x: 0, y: 0 }, content: 'Text' }; 6 | expect(textBoxSchema.safeParse(valid).success).toBe(true); 7 | }); 8 | it('fails if content is missing', () => { 9 | const invalid = { id: 'tb1', tile: { x: 0, y: 0 } }; 10 | const result = textBoxSchema.safeParse(invalid); 11 | expect(result.success).toBe(false); 12 | if (!result.success) { 13 | expect( 14 | result.error.issues.some((issue: any) => { 15 | return issue.path.includes('content'); 16 | }) 17 | ).toBe(true); 18 | } 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/schemas/__tests__/rectangle.test.ts: -------------------------------------------------------------------------------- 1 | import { rectangleSchema } from '../rectangle'; 2 | 3 | describe('rectangleSchema', () => { 4 | it('validates a correct rectangle', () => { 5 | const valid = { id: 'rect1', from: { x: 0, y: 0 }, to: { x: 1, y: 1 } }; 6 | expect(rectangleSchema.safeParse(valid).success).toBe(true); 7 | }); 8 | it('fails if from is missing', () => { 9 | const invalid = { id: 'rect1', to: { x: 1, y: 1 } }; 10 | const result = rectangleSchema.safeParse(invalid); 11 | expect(result.success).toBe(false); 12 | if (!result.success) { 13 | expect( 14 | result.error.issues.some((issue: any) => { 15 | return issue.path.includes('from'); 16 | }) 17 | ).toBe(true); 18 | } 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /docs/theme.config.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default { 4 | darkMode: false, 5 | logo: () => { 6 | return ( 7 | 15 | Isoflow Developer Documentation 16 | 17 | ); 18 | }, 19 | nextThemes: { 20 | defaultTheme: 'light' 21 | }, 22 | project: { 23 | link: 'https://github.com/markmanx/isoflow' 24 | }, 25 | feedback: { 26 | content: null 27 | }, 28 | editLink: { 29 | component: () => { 30 | return null; 31 | } 32 | }, 33 | footer: { 34 | component: null 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/ItemControls/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typography from '@mui/material/Typography'; 3 | import Box from '@mui/material/Box'; 4 | import Grid from '@mui/material/Grid'; 5 | import { Section } from './Section'; 6 | 7 | interface Props { 8 | title: string; 9 | } 10 | 11 | export const Header = ({ title }: Props) => { 12 | return ( 13 |
14 | 15 | 16 | 17 | 18 | {title} 19 | 20 | 21 | 22 | 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/hooks/useTextBoxProps.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { TextBox } from 'src/types'; 3 | import { 4 | UNPROJECTED_TILE_SIZE, 5 | DEFAULT_FONT_FAMILY, 6 | TEXTBOX_DEFAULTS, 7 | TEXTBOX_FONT_WEIGHT, 8 | TEXTBOX_PADDING 9 | } from 'src/config'; 10 | 11 | export const useTextBoxProps = (textBox: TextBox) => { 12 | const fontProps = useMemo(() => { 13 | return { 14 | fontSize: 15 | UNPROJECTED_TILE_SIZE * (textBox.fontSize ?? TEXTBOX_DEFAULTS.fontSize), 16 | fontFamily: DEFAULT_FONT_FAMILY, 17 | fontWeight: TEXTBOX_FONT_WEIGHT 18 | }; 19 | }, [textBox.fontSize]); 20 | 21 | const paddingX = useMemo(() => { 22 | return UNPROJECTED_TILE_SIZE * TEXTBOX_PADDING; 23 | }, []); 24 | 25 | return { paddingX, fontProps }; 26 | }; 27 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Node.js runtime as the base image 2 | FROM node:21 as build 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy package.json and package-lock.json to the working directory 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm install 12 | 13 | # Copy the entire application code to the container 14 | COPY . . 15 | 16 | # Build the React app for production 17 | RUN npm run docker:build 18 | 19 | # Use Nginx as the production server 20 | FROM nginx:alpine 21 | 22 | # Copy the built React app to Nginx's web server directory 23 | COPY --from=build /app/dist /usr/share/nginx/html 24 | 25 | # Expose port 80 for the Nginx server 26 | EXPOSE 80 27 | 28 | # Start Nginx when the container runs 29 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | // This is an entry point for running the app in dev mode. 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom/client'; 4 | import GlobalStyles from '@mui/material/GlobalStyles'; 5 | import { ThemeProvider, createTheme } from '@mui/material'; 6 | import { Examples } from './examples'; 7 | import { themeConfig } from './styles/theme'; 8 | 9 | const root = ReactDOM.createRoot( 10 | document.getElementById('root') as HTMLElement 11 | ); 12 | 13 | root.render( 14 | 15 | 22 | 23 | 24 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /src/hooks/useIconFiltering.ts: -------------------------------------------------------------------------------- 1 | import { useState, useMemo } from 'react'; 2 | import { useModelStore } from 'src/stores/modelStore'; 3 | import { Icon } from 'src/types'; 4 | 5 | export const useIconFiltering = () => { 6 | const [filter, setFilter] = useState(''); 7 | 8 | const icons = useModelStore((state) => { 9 | return state.icons; 10 | }); 11 | 12 | const filteredIcons = useMemo(() => { 13 | if (filter === '') return null; 14 | 15 | const regex = new RegExp(filter, 'gi'); 16 | 17 | return icons.filter((icon: Icon) => { 18 | if (!filter) { 19 | return true; 20 | } 21 | 22 | return regex.test(icon.name); 23 | }); 24 | }, [icons, filter]); 25 | 26 | return { 27 | setFilter, 28 | filter, 29 | filteredIcons 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/DragAndDrop/DragAndDrop.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Box } from '@mui/material'; 3 | import { Coords } from 'src/types'; 4 | import { getTilePosition } from 'src/utils'; 5 | import { useIcon } from 'src/hooks/useIcon'; 6 | 7 | interface Props { 8 | iconId: string; 9 | tile: Coords; 10 | } 11 | 12 | export const DragAndDrop = ({ iconId, tile }: Props) => { 13 | const { iconComponent } = useIcon(iconId); 14 | 15 | const tilePosition = useMemo(() => { 16 | return getTilePosition({ tile, origin: 'BOTTOM' }); 17 | }, [tile]); 18 | 19 | return ( 20 | 26 | {iconComponent} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/ItemControls/IconSelectionControls/Searchbox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TextField, InputAdornment } from '@mui/material'; 3 | import { Search as SearchIcon } from '@mui/icons-material'; 4 | 5 | interface Props { 6 | value: string; 7 | onChange: (value: string) => void; 8 | } 9 | 10 | export const Searchbox = ({ value, onChange }: Props) => { 11 | return ( 12 | { 17 | return onChange(e.target.value as string); 18 | }} 19 | InputProps={{ 20 | startAdornment: ( 21 | 22 | 23 | 24 | ) 25 | }} 26 | /> 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/ItemControls/components/Section.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, SxProps, Typography, Stack } from '@mui/material'; 3 | 4 | interface Props { 5 | children: React.ReactNode; 6 | title?: string; 7 | sx?: SxProps; 8 | } 9 | 10 | export const Section = ({ children, sx, title }: Props) => { 11 | return ( 12 | 19 | 20 | {title && ( 21 | 27 | {title} 28 | 29 | )} 30 | {children} 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/index-docker.tsx: -------------------------------------------------------------------------------- 1 | // This is an entry point for the Docker image build. 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom/client'; 4 | import { Box } from '@mui/material'; 5 | import GlobalStyles from '@mui/material/GlobalStyles'; 6 | import Isoflow, { INITIAL_DATA } from 'src/Isoflow'; 7 | import { icons, colors } from './examples/initialData'; 8 | 9 | const root = ReactDOM.createRoot( 10 | document.getElementById('root') as HTMLElement 11 | ); 12 | 13 | root.render( 14 | 15 | 22 | 23 | 24 | 25 | 26 | ); 27 | -------------------------------------------------------------------------------- /src/components/ColorSelector/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MuiColorButtonProps, 3 | MuiColorInput, 4 | MuiColorInputProps 5 | } from 'mui-color-input'; 6 | import React from 'react'; 7 | import { ColorSwatch } from './ColorSwatch'; 8 | 9 | interface Props extends Omit {} 10 | 11 | const ColorButtonElement = ({ bgColor, onClick }: MuiColorButtonProps) => { 12 | return ; 13 | }; 14 | export const ColorPicker = ({ value, onChange }: Props) => { 15 | return ( 16 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/SceneLayers/Rectangles/Rectangle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useScene } from 'src/hooks/useScene'; 3 | import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea'; 4 | import { getColorVariant } from 'src/utils'; 5 | import { useColor } from 'src/hooks/useColor'; 6 | 7 | type Props = ReturnType['rectangles'][0]; 8 | 9 | export const Rectangle = ({ from, to, color: colorId }: Props) => { 10 | const color = useColor(colorId); 11 | 12 | if (!color) { 13 | return null; 14 | } 15 | 16 | return ( 17 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/ColorSelector/ColorSelector.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '@mui/material'; 3 | import { useScene } from 'src/hooks/useScene'; 4 | import { ColorSwatch } from './ColorSwatch'; 5 | 6 | interface Props { 7 | onChange: (color: string) => void; 8 | activeColor?: string; 9 | } 10 | 11 | export const ColorSelector = ({ onChange, activeColor }: Props) => { 12 | const { colors } = useScene(); 13 | 14 | return ( 15 | 16 | {colors.map((color) => { 17 | return ( 18 | { 22 | return onChange(color.id); 23 | }} 24 | isActive={activeColor === color.id} 25 | /> 26 | ); 27 | })} 28 | 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/__tests__/immer.test.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | 3 | const createItem = (x: number, y: number) => { 4 | return { 5 | x, 6 | y 7 | }; 8 | }; 9 | 10 | // Although we don't normally test third party libraries, 11 | // this is useful to explore the behaviour of immer 12 | describe('Tests immer', () => { 13 | test('Array equivalence without immer', () => { 14 | const arr = [createItem(0, 0), createItem(1, 1)]; 15 | const newArr = [createItem(0, 0), createItem(2, 2)]; 16 | 17 | expect(arr[0]).not.toBe(newArr[0]); 18 | }); 19 | 20 | test('Array equivalence with immer', () => { 21 | const arr = [createItem(0, 0), createItem(1, 1)]; 22 | const newArr = produce(arr, (draft) => { 23 | draft[1] = createItem(2, 2); 24 | }); 25 | 26 | expect(arr[0]).toBe(newArr[0]); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/DebugUtils/SizeIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Box } from '@mui/material'; 3 | import { useDiagramUtils } from 'src/hooks/useDiagramUtils'; 4 | 5 | const BORDER_WIDTH = 6; 6 | 7 | export const SizeIndicator = () => { 8 | const { getUnprojectedBounds } = useDiagramUtils(); 9 | const diagramBoundingBox = useMemo(() => { 10 | return getUnprojectedBounds(); 11 | }, [getUnprojectedBounds]); 12 | 13 | return ( 14 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/schemas/views.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { id, constrainedStrings, coords } from './common'; 3 | import { rectangleSchema } from './rectangle'; 4 | import { connectorSchema } from './connector'; 5 | import { textBoxSchema } from './textBox'; 6 | 7 | export const viewItemSchema = z.object({ 8 | id, 9 | tile: coords, 10 | labelHeight: z.number().optional() 11 | }); 12 | 13 | export const viewSchema = z.object({ 14 | id, 15 | lastUpdated: z.string().datetime().optional(), 16 | name: constrainedStrings.name, 17 | description: constrainedStrings.description.optional(), 18 | items: z.array(viewItemSchema), 19 | rectangles: z.array(rectangleSchema).optional(), 20 | connectors: z.array(connectorSchema).optional(), 21 | textBoxes: z.array(textBoxSchema).optional() 22 | }); 23 | 24 | export const viewsSchema = z.array(viewSchema); 25 | -------------------------------------------------------------------------------- /src/components/Svg/Svg.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Size } from 'src/types'; 3 | 4 | type Props = React.SVGProps & { 5 | children: React.ReactNode; 6 | style?: React.CSSProperties; 7 | viewboxSize?: Size; 8 | }; 9 | 10 | export const Svg = ({ children, style, viewboxSize, ...rest }: Props) => { 11 | const dimensionProps = useMemo(() => { 12 | if (!viewboxSize) return {}; 13 | 14 | return { 15 | viewBox: `0 0 ${viewboxSize.width} ${viewboxSize.height}`, 16 | width: `${viewboxSize.width}px`, 17 | height: `${viewboxSize.height}px` 18 | }; 19 | }, [viewboxSize]); 20 | 21 | return ( 22 | 28 | {children} 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/DebugUtils/__tests__/__snapshots__/LineItem.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`LineItem matches snapshot 1`] = ` 4 | 5 |
8 |
11 |

14 | Snapshot Title 15 |

16 |
17 |
20 |
23 |

26 | Snapshot Value 27 |

28 |
29 |
30 |
31 |
32 | `; 33 | -------------------------------------------------------------------------------- /src/components/ContextMenu/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Menu, MenuItem } from '@mui/material'; 3 | import { Coords } from 'src/types'; 4 | 5 | interface MenuItemI { 6 | label: string; 7 | onClick: () => void; 8 | } 9 | 10 | interface Props { 11 | onClose: () => void; 12 | position: Coords; 13 | anchorEl?: HTMLElement; 14 | menuItems: MenuItemI[]; 15 | } 16 | 17 | export const ContextMenu = ({ 18 | onClose, 19 | position, 20 | anchorEl, 21 | menuItems 22 | }: Props) => { 23 | return ( 24 | 33 | {menuItems.map((item) => { 34 | return {item.label}; 35 | })} 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/utils/CoordsUtils.ts: -------------------------------------------------------------------------------- 1 | import { Coords } from 'src/types'; 2 | 3 | export class CoordsUtils { 4 | static isEqual(base: Coords, operand: Coords) { 5 | return base.x === operand.x && base.y === operand.y; 6 | } 7 | 8 | static subtract(base: Coords, operand: Coords): Coords { 9 | return { x: base.x - operand.x, y: base.y - operand.y }; 10 | } 11 | 12 | static add(base: Coords, operand: Coords): Coords { 13 | return { x: base.x + operand.x, y: base.y + operand.y }; 14 | } 15 | 16 | static multiply(base: Coords, operand: number): Coords { 17 | return { x: base.x * operand, y: base.y * operand }; 18 | } 19 | 20 | static toString(coords: Coords) { 21 | return `x: ${coords.x}, y: ${coords.y}`; 22 | } 23 | 24 | static sum(coords: Coords) { 25 | return coords.x + coords.y; 26 | } 27 | 28 | static zero() { 29 | return { x: 0, y: 0 }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docs/pages/docs/contributing.mdx: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ### Branching Strategy: 4 | 5 | Branches are named using the following convention: 6 | 7 | - `feature/` for new feature implementations 8 | - `fix/` for broken code / build / bug fixes 9 | - `chore/` non-breaking & non-fixing code changes such as linting, formatting, etc. 10 | 11 | ### Commit / PR Strategy: 12 | 13 | - Commits are to be squashed prior to merge 14 | - PRs are to target a singular issue in order to keep the commit history clean and easy to follow 15 | 16 | ### Deploying to NPM 17 | 18 | CI is sensitive to any tag pushed to `main` branch. It will build and deploy the app to NPM. 19 | To deploy: 20 | 21 | 1. Bump the version using `npm version patch` or similar 22 | 2. `git push && git push --tags` 23 | 24 | ## License 25 | 26 | Isoflow is MIT licensed (see [./LICENSE](https://github.com/markmanx/isoflow/blob/main/LICENSE)). 27 | -------------------------------------------------------------------------------- /src/components/DebugUtils/LineItem.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Typography, Box } from '@mui/material'; 3 | import { Value } from './Value'; 4 | 5 | interface Props { 6 | title: string; 7 | value: string | number; 8 | } 9 | 10 | export const LineItem = ({ title, value }: Props) => { 11 | return ( 12 | { 18 | return `1px solid ${theme.palette.grey[300]}`; 19 | } 20 | }} 21 | > 22 | 27 | {title} 28 | 29 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/TransformControlsManager/TransformControlsManager.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useUiStateStore } from 'src/stores/uiStateStore'; 3 | import { RectangleTransformControls } from './RectangleTransformControls'; 4 | import { TextBoxTransformControls } from './TextBoxTransformControls'; 5 | import { NodeTransformControls } from './NodeTransformControls'; 6 | 7 | export const TransformControlsManager = () => { 8 | const itemControls = useUiStateStore((state) => { 9 | return state.itemControls; 10 | }); 11 | 12 | switch (itemControls?.type) { 13 | case 'ITEM': 14 | return ; 15 | case 'RECTANGLE': 16 | return ; 17 | case 'TEXTBOX': 18 | return ; 19 | default: 20 | return null; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /docs/pages/docs/installation.mdx: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Isoflow is published as a **React component** you can embed into your project. 4 | 5 | To install using `npm`: 6 | 7 | ```bash 8 | npm install isoflow 9 | ``` 10 | 11 | or `yarn`: 12 | 13 | ```bash 14 | yarn add isoflow 15 | ``` 16 | 17 | ### Demo 18 | 19 | The latest version of Isoflow is always synced here on [CodeSandbox](https://codesandbox.io/p/sandbox/github/markmanx/isoflow). 20 | 21 | ### Running Isoflow in development mode 22 | To run Isoflow on your local machine: 23 | 24 | 1. Clone the [Github repository](https://github.com/markmanx/isoflow). 25 | 2. `npm i` 26 | 3. `npm run start`. 27 | 28 | ### Developer documentation 29 | For detailed API documentation, examples and more, see the online [developer documentation](https://v2.isoflow.io/docs). You can also build and run the docs locally: 30 | 31 | - `npm run docs:build` 32 | - `npm run docs:start` -------------------------------------------------------------------------------- /src/components/ItemControls/IconSelectionControls/IconGrid.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Icon as IconI } from 'src/types'; 3 | import { Grid } from '@mui/material'; 4 | import { Icon } from './Icon'; 5 | 6 | interface Props { 7 | icons: IconI[]; 8 | onMouseDown?: (icon: IconI) => void; 9 | onClick?: (icon: IconI) => void; 10 | } 11 | 12 | export const IconGrid = ({ icons, onMouseDown, onClick }: Props) => { 13 | return ( 14 | 15 | {icons.map((icon) => { 16 | return ( 17 | 18 | { 21 | onClick?.(icon); 22 | }} 23 | onMouseDown={() => { 24 | onMouseDown?.(icon); 25 | }} 26 | /> 27 | 28 | ); 29 | })} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/hooks/useIconCategories.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { IconCollectionStateWithIcons } from 'src/types'; 3 | import { useUiStateStore } from 'src/stores/uiStateStore'; 4 | import { useModelStore } from 'src/stores/modelStore'; 5 | 6 | export const useIconCategories = () => { 7 | const icons = useModelStore((state) => { 8 | return state.icons; 9 | }); 10 | const iconCategoriesState = useUiStateStore((state) => { 11 | return state.iconCategoriesState; 12 | }); 13 | 14 | const iconCategories = useMemo(() => { 15 | return iconCategoriesState.map((collection) => { 16 | return { 17 | ...collection, 18 | icons: icons.filter((icon) => { 19 | return icon.collection === collection.id; 20 | }) 21 | }; 22 | }); 23 | }, [icons, iconCategoriesState]); 24 | 25 | return { 26 | iconCategories 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FossFLOW - Open Source Network Diagram Component 2 | 3 | A React component for drawing network diagrams, forked from Isoflow. 4 | 5 | ## Documentation 6 | 7 | - **📖 [ISOFLOW_ENCYCLOPEDIA.md](https://github.com/stan-smith/fossflow-lib/blob/main/ISOFLOW_ENCYCLOPEDIA.md)** - Comprehensive guide to the codebase structure and navigation 8 | - **📝 [ISOFLOW_TODO.md](https://github.com/stan-smith/fossflow-lib/blob/main/ISOFLOW_TODO.md)** - Current issues and roadmap with codebase mappings 9 | - **🤝 [CONTRIBUTORS.md](https://github.com/stan-smith/fossflow-lib/blob/main/CONTRIBUTORS.md)** - How to contribute to the project 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install fossflow 15 | ``` 16 | 17 | ## Quick Links 18 | 19 | - [FossFLOW App](https://github.com/stan-smith/FossFLOW) - The main application using this library 20 | - [NPM Package](https://www.npmjs.com/package/fossflow) 21 | - [Original Isoflow Docs](https://isoflow.io/docs) -------------------------------------------------------------------------------- /src/utils/SizeUtils.ts: -------------------------------------------------------------------------------- 1 | import { Size } from 'src/types'; 2 | 3 | export class SizeUtils { 4 | static isEqual(base: Size, operand: Size) { 5 | return base.width === operand.width && base.height === operand.height; 6 | } 7 | 8 | static subtract(base: Size, operand: Size): Size { 9 | return { 10 | width: base.width - operand.width, 11 | height: base.height - operand.height 12 | }; 13 | } 14 | 15 | static add(base: Size, operand: Size): Size { 16 | return { 17 | width: base.width + operand.width, 18 | height: base.height + operand.height 19 | }; 20 | } 21 | 22 | static multiply(base: Size, operand: number): Size { 23 | return { width: base.width * operand, height: base.height * operand }; 24 | } 25 | 26 | static toString(size: Size) { 27 | return `width: ${size.width}, height: ${size.height}`; 28 | } 29 | 30 | static zero() { 31 | return { width: 0, y: 0 }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ColorSelector/ColorSwatch.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Button } from '@mui/material'; 3 | 4 | export type Props = { 5 | hex: string; 6 | isActive?: boolean; 7 | onClick: React.MouseEventHandler | undefined; 8 | }; 9 | 10 | export const ColorSwatch = ({ hex, onClick, isActive }: Props) => { 11 | return ( 12 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/Label/ExpandButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button as MuiButton, SxProps } from '@mui/material'; 3 | import { 4 | ExpandMore as ReadMoreIcon, 5 | ExpandLess as ReadLessIcon 6 | } from '@mui/icons-material'; 7 | 8 | interface Props { 9 | isExpanded: boolean; 10 | onClick: () => void; 11 | sx?: SxProps; 12 | } 13 | 14 | export const ExpandButton = ({ isExpanded, onClick, sx }: Props) => { 15 | return ( 16 | 30 | {isExpanded ? ( 31 | 32 | ) : ( 33 | 34 | )} 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /.codesandbox/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "setupTasks": [ 3 | { 4 | "name": "Install app dependencies", 5 | "command": "npm i" 6 | }, 7 | { 8 | "name": "Install documentation dependencies", 9 | "command": "cd docs && npm i" 10 | }, 11 | { 12 | "name": "Build Docs", 13 | "command": "cd docs && npm run build" 14 | } 15 | ], 16 | "tasks": { 17 | "start": { 18 | "name": "Isoflow", 19 | "command": "npm run start", 20 | "runAtStart": true, 21 | "preview": { 22 | "port": 3000, 23 | "prLink": "direct" 24 | } 25 | }, 26 | "docs":{ 27 | "name":"Docs", 28 | "command":"npm run docs:build && npm run docs:start", 29 | "runAtStart": true, 30 | "preview": { 31 | "port": 3002, 32 | "prLink": "direct" 33 | } 34 | }, 35 | "test": { 36 | "name": "Tests", 37 | "command": "npm run test", 38 | "runAtStart": false 39 | } 40 | } 41 | } -------------------------------------------------------------------------------- /src/components/SceneLayers/Nodes/Node/IconTypes/NonIsometricIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '@mui/material'; 3 | import { Icon } from 'src/types'; 4 | import { PROJECTED_TILE_SIZE } from 'src/config'; 5 | import { getIsoProjectionCss } from 'src/utils'; 6 | 7 | interface Props { 8 | icon: Icon; 9 | } 10 | 11 | export const NonIsometricIcon = ({ icon }: Props) => { 12 | return ( 13 | 14 | 23 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/ItemControls/IconSelectionControls/Icons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Grid } from '@mui/material'; 3 | import { IconCollectionStateWithIcons, Icon } from 'src/types'; 4 | import { IconCollection } from './IconCollection'; 5 | 6 | interface Props { 7 | iconCategories: IconCollectionStateWithIcons[]; 8 | onClick?: (icon: Icon) => void; 9 | onMouseDown?: (icon: Icon) => void; 10 | } 11 | 12 | export const Icons = ({ iconCategories, onClick, onMouseDown }: Props) => { 13 | return ( 14 | 15 | {iconCategories.map((cat) => { 16 | return ( 17 | 22 | 27 | 28 | ); 29 | })} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/schemas/model.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { INITIAL_DATA } from '../config'; 3 | import { constrainedStrings } from './common'; 4 | import { modelItemsSchema } from './modelItems'; 5 | import { viewsSchema } from './views'; 6 | import { validateModel } from './validation'; 7 | import { iconsSchema } from './icons'; 8 | import { colorsSchema } from './colors'; 9 | 10 | export const modelSchema = z 11 | .object({ 12 | version: z.string().max(10).optional(), 13 | title: constrainedStrings.name, 14 | description: constrainedStrings.description.optional(), 15 | items: modelItemsSchema, 16 | views: viewsSchema, 17 | icons: iconsSchema, 18 | colors: colorsSchema 19 | }) 20 | .superRefine((model, ctx) => { 21 | const issues = validateModel({ ...INITIAL_DATA, ...model }); 22 | 23 | issues.forEach((issue) => { 24 | ctx.addIssue({ 25 | code: z.ZodIssueCode.custom, 26 | params: issue.params, 27 | message: issue.message 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/hooks/useView.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useUiStateStore } from 'src/stores/uiStateStore'; 3 | import { useSceneStore } from 'src/stores/sceneStore'; 4 | import * as reducers from 'src/stores/reducers'; 5 | import { Model } from 'src/types'; 6 | import { INITIAL_SCENE_STATE } from 'src/config'; 7 | 8 | export const useView = () => { 9 | const uiStateActions = useUiStateStore((state) => { 10 | return state.actions; 11 | }); 12 | 13 | const sceneActions = useSceneStore((state) => { 14 | return state.actions; 15 | }); 16 | 17 | const changeView = useCallback( 18 | (viewId: string, model: Model) => { 19 | const newState = reducers.view({ 20 | action: 'SYNC_SCENE', 21 | payload: undefined, 22 | ctx: { viewId, state: { model, scene: INITIAL_SCENE_STATE } } 23 | }); 24 | 25 | sceneActions.set(newState.scene); 26 | uiStateActions.setView(viewId); 27 | }, 28 | [uiStateActions, sceneActions] 29 | ); 30 | 31 | return { 32 | changeView 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/interaction/modes/TextBox.ts: -------------------------------------------------------------------------------- 1 | import { setWindowCursor } from 'src/utils'; 2 | import { ModeActions } from 'src/types'; 3 | 4 | export const TextBox: ModeActions = { 5 | entry: () => { 6 | setWindowCursor('crosshair'); 7 | }, 8 | exit: () => { 9 | setWindowCursor('default'); 10 | }, 11 | mousemove: ({ uiState, scene }) => { 12 | if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return; 13 | 14 | scene.updateTextBox(uiState.mode.id, { 15 | tile: uiState.mouse.position.tile 16 | }); 17 | }, 18 | mouseup: ({ uiState, scene, isRendererInteraction }) => { 19 | if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return; 20 | 21 | if (!isRendererInteraction) { 22 | scene.deleteTextBox(uiState.mode.id); 23 | } else { 24 | uiState.actions.setItemControls({ 25 | type: 'TEXTBOX', 26 | id: uiState.mode.id 27 | }); 28 | } 29 | 30 | uiState.actions.setMode({ 31 | type: 'CURSOR', 32 | showCursor: true, 33 | mousedownItem: null 34 | }); 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/SceneLayers/Nodes/Node/IconTypes/IsometricIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect } from 'react'; 2 | import { Box } from '@mui/material'; 3 | import { PROJECTED_TILE_SIZE } from 'src/config'; 4 | import { useResizeObserver } from 'src/hooks/useResizeObserver'; 5 | 6 | interface Props { 7 | url: string; 8 | onImageLoaded?: () => void; 9 | } 10 | 11 | export const IsometricIcon = ({ url, onImageLoaded }: Props) => { 12 | const ref = useRef(); 13 | const { size, observe, disconnect } = useResizeObserver(); 14 | 15 | useEffect(() => { 16 | if (!ref.current) return; 17 | 18 | observe(ref.current); 19 | 20 | return disconnect; 21 | }, [observe, disconnect]); 22 | 23 | return ( 24 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/ItemControls/components/ControlsContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, Divider } from '@mui/material'; 3 | 4 | interface Props { 5 | header?: React.ReactNode; 6 | children: React.ReactNode; 7 | } 8 | 9 | export const ControlsContainer = ({ header, children }: Props) => { 10 | return ( 11 | 21 | {header && ( 22 | 31 | {header} 32 | 33 | 34 | )} 35 | 41 | {children} 42 | 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /src/types/common.ts: -------------------------------------------------------------------------------- 1 | export interface Coords { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | export interface Size { 7 | width: number; 8 | height: number; 9 | } 10 | 11 | export interface Rect { 12 | from: Coords; 13 | to: Coords; 14 | } 15 | 16 | export const ProjectionOrientationEnum = { 17 | X: 'X', 18 | Y: 'Y' 19 | } as const; 20 | 21 | export type BoundingBox = [Coords, Coords, Coords, Coords]; 22 | 23 | export type SlimMouseEvent = Pick< 24 | MouseEvent, 25 | 'clientX' | 'clientY' | 'target' | 'type' | 'preventDefault' 26 | >; 27 | 28 | export const EditorModeEnum = { 29 | NON_INTERACTIVE: 'NON_INTERACTIVE', 30 | EXPLORABLE_READONLY: 'EXPLORABLE_READONLY', 31 | EDITABLE: 'EDITABLE' 32 | } as const; 33 | 34 | export const MainMenuOptionsEnum = { 35 | 'ACTION.OPEN': 'ACTION.OPEN', 36 | 'EXPORT.JSON': 'EXPORT.JSON', 37 | 'EXPORT.PNG': 'EXPORT.PNG', 38 | 'ACTION.CLEAR_CANVAS': 'ACTION.CLEAR_CANVAS', 39 | 'LINK.GITHUB': 'LINK.GITHUB', 40 | 'LINK.DISCORD': 'LINK.DISCORD', 41 | VERSION: 'VERSION' 42 | } as const; 43 | 44 | export type MainMenuOptions = (keyof typeof MainMenuOptionsEnum)[]; 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Mark Mankarious 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/hooks/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | import { Size } from 'src/types'; 3 | 4 | export const useResizeObserver = (el?: HTMLElement | null) => { 5 | const resizeObserverRef = useRef(); 6 | const [size, setSize] = useState({ width: 0, height: 0 }); 7 | 8 | const disconnect = useCallback(() => { 9 | resizeObserverRef.current?.disconnect(); 10 | }, []); 11 | 12 | const observe = useCallback( 13 | (element: HTMLElement) => { 14 | disconnect(); 15 | 16 | resizeObserverRef.current = new ResizeObserver(() => { 17 | setSize({ 18 | width: element.clientWidth, 19 | height: element.clientHeight 20 | }); 21 | }); 22 | 23 | resizeObserverRef.current.observe(element); 24 | }, 25 | [disconnect] 26 | ); 27 | 28 | useEffect(() => { 29 | return () => { 30 | disconnect(); 31 | }; 32 | }, [disconnect]); 33 | 34 | useEffect(() => { 35 | if (el) observe(el); 36 | }, [observe, el]); 37 | 38 | return { 39 | size, 40 | disconnect, 41 | observe 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "plugins": [ 7 | "react", 8 | "react-hooks", 9 | "prettier" 10 | ], 11 | "extends": [ 12 | "airbnb", 13 | "airbnb-typescript", 14 | "plugin:react-hooks/recommended", 15 | "prettier" 16 | ], 17 | "parserOptions": { 18 | "ecmaVersion": "latest", 19 | "sourceType": "module", 20 | "project": "./tsconfig.json", 21 | }, 22 | "rules": { 23 | "prettier/prettier": 2, 24 | "import/prefer-default-export": [0], 25 | "react/function-component-definition": [0], 26 | "react/jsx-props-no-spreading": [0], 27 | "consistent-return": [0], 28 | "react/no-unused-prop-types": ["warn"], 29 | "react/require-default-props": [0], 30 | "react/prop-types": [0], 31 | "no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draft"] }], 32 | "arrow-body-style": ["error", "always"] 33 | }, 34 | "ignorePatterns": [ 35 | "/dist", 36 | "/node_modules" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/components/IsoTileArea/IsoTileArea.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Coords } from 'src/types'; 3 | import { Svg } from 'src/components/Svg/Svg'; 4 | import { useIsoProjection } from 'src/hooks/useIsoProjection'; 5 | 6 | interface Props { 7 | from: Coords; 8 | to: Coords; 9 | origin?: Coords; 10 | fill?: string; 11 | cornerRadius?: number; 12 | stroke?: { 13 | width: number; 14 | color: string; 15 | }; 16 | } 17 | 18 | export const IsoTileArea = ({ 19 | from, 20 | to, 21 | fill = 'none', 22 | cornerRadius = 0, 23 | stroke 24 | }: Props) => { 25 | const { css, pxSize } = useIsoProjection({ 26 | from, 27 | to 28 | }); 29 | 30 | const strokeParams = useMemo(() => { 31 | if (!stroke) return {}; 32 | 33 | return { 34 | stroke: stroke.color, 35 | strokeWidth: stroke.width 36 | }; 37 | }, [stroke]); 38 | 39 | return ( 40 | 41 | 48 | 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /src/stores/reducers/modelItem.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { ModelItem } from 'src/types'; 3 | import { getItemByIdOrThrow } from 'src/utils'; 4 | import { State } from './types'; 5 | 6 | export const updateModelItem = ( 7 | id: string, 8 | updates: Partial, 9 | state: State 10 | ): State => { 11 | const modelItem = getItemByIdOrThrow(state.model.items, id); 12 | 13 | const newState = produce(state, (draft) => { 14 | draft.model.items[modelItem.index] = { ...modelItem.value, ...updates }; 15 | }); 16 | 17 | return newState; 18 | }; 19 | 20 | export const createModelItem = ( 21 | newModelItem: ModelItem, 22 | state: State 23 | ): State => { 24 | const newState = produce(state, (draft) => { 25 | draft.model.items.push(newModelItem); 26 | }); 27 | 28 | return updateModelItem(newModelItem.id, newModelItem, newState); 29 | }; 30 | 31 | export const deleteModelItem = (id: string, state: State): State => { 32 | const modelItem = getItemByIdOrThrow(state.model.items, id); 33 | 34 | const newState = produce(state, (draft) => { 35 | delete draft.model.items[modelItem.index]; 36 | }); 37 | 38 | return newState; 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/TransformControlsManager/RectangleTransformControls.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { useRectangle } from 'src/hooks/useRectangle'; 3 | import { AnchorPosition } from 'src/types'; 4 | import { useUiStateStore } from 'src/stores/uiStateStore'; 5 | import { TransformControls } from './TransformControls'; 6 | 7 | interface Props { 8 | id: string; 9 | } 10 | 11 | export const RectangleTransformControls = ({ id }: Props) => { 12 | const rectangle = useRectangle(id); 13 | const uiStateActions = useUiStateStore((state) => { 14 | return state.actions; 15 | }); 16 | 17 | const onAnchorMouseDown = useCallback( 18 | (key: AnchorPosition) => { 19 | if (!rectangle) return; 20 | uiStateActions.setMode({ 21 | type: 'RECTANGLE.TRANSFORM', 22 | id: rectangle.id, 23 | selectedAnchor: key, 24 | showCursor: true 25 | }); 26 | }, 27 | [rectangle?.id, uiStateActions] 28 | ); 29 | 30 | if (!rectangle) { 31 | return null; 32 | } 33 | 34 | return ( 35 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/SceneLayers/Connectors/Connectors.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import type { useScene } from 'src/hooks/useScene'; 3 | import { useUiStateStore } from 'src/stores/uiStateStore'; 4 | import { Connector } from './Connector'; 5 | 6 | interface Props { 7 | connectors: ReturnType['connectors']; 8 | } 9 | 10 | export const Connectors = ({ connectors }: Props) => { 11 | const itemControls = useUiStateStore((state) => { 12 | return state.itemControls; 13 | }); 14 | 15 | const mode = useUiStateStore((state) => { 16 | return state.mode; 17 | }); 18 | 19 | const selectedConnectorId = useMemo(() => { 20 | if (mode.type === 'CONNECTOR') { 21 | return mode.id; 22 | } 23 | if (itemControls?.type === 'CONNECTOR') { 24 | return itemControls.id; 25 | } 26 | 27 | return null; 28 | }, [mode, itemControls]); 29 | 30 | return ( 31 | <> 32 | {[...connectors].reverse().map((connector) => { 33 | return ( 34 | 39 | ); 40 | })} 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/interaction/modes/Pan.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { CoordsUtils, setWindowCursor } from 'src/utils'; 3 | import { ModeActions } from 'src/types'; 4 | 5 | export const Pan: ModeActions = { 6 | entry: () => { 7 | setWindowCursor('grab'); 8 | }, 9 | exit: () => { 10 | setWindowCursor('default'); 11 | }, 12 | mousemove: ({ uiState }) => { 13 | if (uiState.mode.type !== 'PAN') return; 14 | 15 | if (uiState.mouse.mousedown !== null) { 16 | const newScroll = produce(uiState.scroll, (draft) => { 17 | draft.position = uiState.mouse.delta?.screen 18 | ? CoordsUtils.add(draft.position, uiState.mouse.delta.screen) 19 | : draft.position; 20 | }); 21 | 22 | uiState.actions.setScroll(newScroll); 23 | } 24 | }, 25 | mousedown: ({ uiState, isRendererInteraction }) => { 26 | if (uiState.mode.type !== 'PAN' || !isRendererInteraction) return; 27 | 28 | setWindowCursor('grabbing'); 29 | }, 30 | mouseup: ({ uiState }) => { 31 | setWindowCursor('grab'); 32 | // Always revert to CURSOR mode after panning 33 | uiState.actions.setMode({ 34 | type: 'CURSOR', 35 | showCursor: true, 36 | mousedownItem: null 37 | }); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /webpack/docker.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebPackPlugin = require('html-webpack-plugin'); 3 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 4 | const webpack = require('webpack'); 5 | 6 | module.exports = { 7 | mode: 'production', 8 | entry: './src/index-docker.tsx', 9 | target: 'web', 10 | output: { 11 | path: path.resolve(__dirname, '../dist'), 12 | filename: 'main.js', 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.(ts|tsx)$/, 18 | use: 'ts-loader', 19 | exclude: /node_modules/ 20 | }, 21 | { 22 | test: /\.css$/i, 23 | use: ['style-loader', 'css-loader'] 24 | }, 25 | 26 | { 27 | test: /\.svg$/i, 28 | type: 'asset/inline' 29 | } 30 | ] 31 | }, 32 | plugins: [ 33 | new HtmlWebPackPlugin({ 34 | template: path.resolve(__dirname, '../src/index.html') 35 | }), 36 | new webpack.DefinePlugin({ 37 | PACKAGE_VERSION: JSON.stringify(require("../package.json").version), 38 | REPOSITORY_URL: JSON.stringify(require("../package.json").repository.url), 39 | }) 40 | ], 41 | resolve: { 42 | extensions: ['.tsx', '.ts', '.js'], 43 | plugins: [new TsconfigPathsPlugin()] 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/types/scene.ts: -------------------------------------------------------------------------------- 1 | import { StoreApi } from 'zustand'; 2 | import type { Coords, Rect, Size } from './common'; 3 | 4 | export const tileOriginOptions = { 5 | CENTER: 'CENTER', 6 | TOP: 'TOP', 7 | BOTTOM: 'BOTTOM', 8 | LEFT: 'LEFT', 9 | RIGHT: 'RIGHT' 10 | } as const; 11 | 12 | export type TileOrigin = keyof typeof tileOriginOptions; 13 | 14 | export const ItemReferenceTypeOptions = { 15 | ITEM: 'ITEM', 16 | CONNECTOR: 'CONNECTOR', 17 | CONNECTOR_ANCHOR: 'CONNECTOR_ANCHOR', 18 | TEXTBOX: 'TEXTBOX', 19 | RECTANGLE: 'RECTANGLE' 20 | } as const; 21 | 22 | export type ItemReferenceType = keyof typeof ItemReferenceTypeOptions; 23 | 24 | export type ItemReference = { 25 | type: ItemReferenceType; 26 | id: string; 27 | }; 28 | 29 | export type ConnectorPath = { 30 | tiles: Coords[]; 31 | rectangle: Rect; 32 | }; 33 | 34 | export interface SceneConnector { 35 | path: ConnectorPath; 36 | } 37 | 38 | export interface SceneTextBox { 39 | size: Size; 40 | } 41 | 42 | export interface Scene { 43 | connectors: { 44 | [key: string]: SceneConnector; 45 | }; 46 | textBoxes: { 47 | [key: string]: SceneTextBox; 48 | }; 49 | } 50 | 51 | export type SceneStore = Scene & { 52 | actions: { 53 | get: StoreApi['getState']; 54 | set: StoreApi['setState']; 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/DebugUtils/__tests__/DebugUtils.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { ThemeProvider } from '@mui/material/styles'; 4 | import { theme } from 'src/styles/theme'; 5 | import { ModelProvider } from 'src/stores/modelStore'; 6 | import { SceneProvider } from 'src/stores/sceneStore'; 7 | import { UiStateProvider } from 'src/stores/uiStateStore'; 8 | import { DebugUtils } from '../DebugUtils'; 9 | 10 | describe('DebugUtils', () => { 11 | const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) => { 12 | return ( 13 | 14 | 15 | 16 | {children} 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | it('renders without crashing', () => { 24 | render( 25 | 26 | 27 | 28 | ); 29 | expect(screen.getByText('Mouse')).toBeInTheDocument(); 30 | }); 31 | 32 | it('matches snapshot', () => { 33 | const { asFragment } = render( 34 | 35 | 36 | 37 | ); 38 | expect(asFragment()).toMatchSnapshot(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | import { Size, Coords } from 'src/types'; 2 | 3 | declare global { 4 | let PACKAGE_VERSION: string; 5 | let REPOSITORY_URL: string; 6 | 7 | interface Window { 8 | Isoflow: { 9 | getUnprojectedBounds: () => Size & Coords; 10 | fitToView: () => void; 11 | }; 12 | } 13 | } 14 | 15 | declare module 'react-quill' { 16 | import React from 'react'; 17 | 18 | export interface ReactQuillProps { 19 | value?: string; 20 | onChange?: (value: string, delta: any, source: any, editor: any) => void; 21 | readOnly?: boolean; 22 | theme?: string; 23 | modules?: any; 24 | formats?: string[]; 25 | style?: React.CSSProperties; 26 | className?: string; 27 | placeholder?: string; 28 | bounds?: string | HTMLElement; 29 | scrollingContainer?: string | HTMLElement; 30 | preserveWhitespace?: boolean; 31 | tabIndex?: number; 32 | onFocus?: (range: any, source: any, editor: any) => void; 33 | onBlur?: (previousRange: any, source: any, editor: any) => void; 34 | onKeyPress?: (event: React.KeyboardEvent) => void; 35 | onKeyDown?: (event: React.KeyboardEvent) => void; 36 | onKeyUp?: (event: React.KeyboardEvent) => void; 37 | } 38 | 39 | const ReactQuill: React.ForwardRefExoticComponent>; 40 | export default ReactQuill; 41 | } 42 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | defaults: &defaults 4 | working_directory: ~/repo 5 | docker: 6 | - image: cimg/node:19.7.0 7 | 8 | jobs: 9 | build: 10 | <<: *defaults 11 | steps: 12 | - checkout 13 | - run: 14 | name: Install dependencies 15 | command: npm ci 16 | - run: 17 | name: Run tests 18 | command: npm run test 19 | - run: 20 | name: Build 21 | command: npm run build 22 | - persist_to_workspace: 23 | root: ~/repo 24 | paths: 25 | - ./dist 26 | - ./LICENSE 27 | - ./package.json 28 | - ./README.md 29 | 30 | deploy: 31 | <<: *defaults 32 | steps: 33 | - attach_workspace: 34 | at: ~/repo 35 | - run: 36 | name: Publish package 37 | command: | 38 | npm set //registry.npmjs.org/:_authToken=$npm_TOKEN 39 | npm publish 40 | 41 | workflows: 42 | main: 43 | jobs: 44 | - build: 45 | filters: 46 | branches: 47 | ignore: /.*/ 48 | tags: 49 | only: /^v.*/ 50 | - deploy: 51 | requires: 52 | - build 53 | filters: 54 | branches: 55 | ignore: /.*/ 56 | tags: 57 | only: /^v.*/ 58 | -------------------------------------------------------------------------------- /src/components/ItemControls/IconSelectionControls/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import Stack from '@mui/material/Stack'; 4 | import { Button, Typography } from '@mui/material'; 5 | import { Icon as IconI } from 'src/types'; 6 | 7 | const SIZE = 50; 8 | 9 | interface Props { 10 | icon: IconI; 11 | onClick?: () => void; 12 | onMouseDown?: () => void; 13 | } 14 | 15 | export const Icon = ({ icon, onClick, onMouseDown }: Props) => { 16 | return ( 17 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/hooks/useIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useEffect } from 'react'; 2 | import { useModelStore } from 'src/stores/modelStore'; 3 | import { getItemById } from 'src/utils'; 4 | import { IsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/IsometricIcon'; 5 | import { NonIsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/NonIsometricIcon'; 6 | import { DEFAULT_ICON } from 'src/config'; 7 | 8 | export const useIcon = (id: string | undefined) => { 9 | const [hasLoaded, setHasLoaded] = React.useState(false); 10 | const icons = useModelStore((state) => { 11 | return state.icons; 12 | }); 13 | 14 | const icon = useMemo(() => { 15 | if (!id) return DEFAULT_ICON; 16 | 17 | const item = getItemById(icons, id); 18 | return item ? item.value : DEFAULT_ICON; 19 | }, [icons, id]); 20 | 21 | useEffect(() => { 22 | setHasLoaded(false); 23 | }, [icon.url]); 24 | 25 | const iconComponent = useMemo(() => { 26 | if (!icon.isIsometric) { 27 | setHasLoaded(true); 28 | return ; 29 | } 30 | 31 | return ( 32 | { 35 | setHasLoaded(true); 36 | }} 37 | /> 38 | ); 39 | }, [icon]); 40 | 41 | return { 42 | icon, 43 | iconComponent, 44 | hasLoaded 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /docs/pages/docs/quickstart.mdx: -------------------------------------------------------------------------------- 1 | # Quick Start 2 | 3 | Isoflow can be imported as an ES6 module: 4 | 5 | ```jsx 6 | import Isoflow from "isoflow"; 7 | ``` 8 | 9 | ### Basic usage 10 | 11 | ```jsx showLineNumbers 12 | import React from 'react'; 13 | import Isoflow from 'isoflow'; 14 | 15 | const App = () => { 16 | return ( 17 | 18 | ); 19 | } 20 | 21 | export default App; 22 | ``` 23 | 24 | **Note**: this will display a blank Isoflow editor, without icons (which is not very useful!). To initialise the editor with an iconset, see [Loading Isopacks](/docs/isopacks). 25 | 26 | ### Dimensions of Isoflow 27 | 28 | Isoflow takes _100%_ of `width` and `height` of the containing block so make sure the container in which you render Isoflow in has non-zero dimensions. 29 | 30 | ### Integration with NextJS 31 | 32 | Isoflow cannot be server-side rendered and has to be imported using `next/dynamic`: 33 | 34 | ```jsx showLineNumbers filename="IsoflowDynamic.jsx" 35 | import dynamic from 'next/dynamic'; 36 | 37 | export const IsoflowDynamic = dynamic(() => { 38 | return import('isoflow'); 39 | }, 40 | { 41 | ssr: false 42 | } 43 | ); 44 | ``` 45 | 46 | ```jsx showLineNumbers filename="App.jsx" 47 | import { IsoflowDynamic } from './IsoflowDynamic'; 48 | 49 | const App = () => { 50 | return ( 51 | 52 | ); 53 | } 54 | 55 | export default App; 56 | ``` -------------------------------------------------------------------------------- /src/fixtures/views.ts: -------------------------------------------------------------------------------- 1 | import { Model } from 'src/types'; 2 | 3 | export const views: Model['views'] = [ 4 | { 5 | id: 'view1', 6 | name: 'View1', 7 | description: 'View1Description', 8 | items: [ 9 | { 10 | id: 'node1', 11 | tile: { 12 | x: 0, 13 | y: 0 14 | } 15 | }, 16 | { 17 | id: 'node2', 18 | tile: { 19 | x: 0, 20 | y: 4 21 | } 22 | }, 23 | { 24 | id: 'node3', 25 | tile: { 26 | x: 0, 27 | y: -4 28 | } 29 | } 30 | ], 31 | rectangles: [ 32 | { 33 | id: 'rectangle1', 34 | color: 'color1', 35 | from: { x: 0, y: 0 }, 36 | to: { x: 2, y: 2 } 37 | }, 38 | { 39 | id: 'rectangle2', 40 | from: { x: 0, y: 0 }, 41 | to: { x: 2, y: 2 } 42 | } 43 | ], 44 | connectors: [ 45 | { 46 | id: 'connector1', 47 | color: 'color1', 48 | anchors: [ 49 | { id: 'anch1-1', ref: { item: 'node1' } }, 50 | { id: 'anch1-2', ref: { item: 'node2' } } 51 | ] 52 | }, 53 | { 54 | id: 'connector2', 55 | anchors: [ 56 | { id: 'anch2-1', ref: { item: 'node2' } }, 57 | { id: 'anch2-2', ref: { item: 'node3' } } 58 | ] 59 | } 60 | ] 61 | } 62 | ]; 63 | -------------------------------------------------------------------------------- /src/schemas/__tests__/connector.test.ts: -------------------------------------------------------------------------------- 1 | import { anchorSchema, connectorSchema } from '../connector'; 2 | 3 | describe('anchorSchema', () => { 4 | it('validates a correct anchor', () => { 5 | const valid = { id: 'a1', ref: { item: 'item1' } }; 6 | expect(anchorSchema.safeParse(valid).success).toBe(true); 7 | }); 8 | it('fails if id is missing', () => { 9 | const invalid = { ref: { item: 'item1' } }; 10 | const result = anchorSchema.safeParse(invalid); 11 | expect(result.success).toBe(false); 12 | if (!result.success) { 13 | expect( 14 | result.error.issues.some((issue: any) => { 15 | return issue.path.includes('id'); 16 | }) 17 | ).toBe(true); 18 | } 19 | }); 20 | }); 21 | 22 | describe('connectorSchema', () => { 23 | it('validates a correct connector', () => { 24 | const valid = { id: 'c1', anchors: [{ id: 'a1', ref: { item: 'item1' } }] }; 25 | expect(connectorSchema.safeParse(valid).success).toBe(true); 26 | }); 27 | it('fails if anchors is missing', () => { 28 | const invalid = { id: 'c1' }; 29 | const result = connectorSchema.safeParse(invalid); 30 | expect(result.success).toBe(false); 31 | if (!result.success) { 32 | expect( 33 | result.error.issues.some((issue: any) => { 34 | return issue.path.includes('anchors'); 35 | }) 36 | ).toBe(true); 37 | } 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/DebugUtils/__tests__/SizeIndicator.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { ThemeProvider } from '@mui/material/styles'; 4 | import { theme } from 'src/styles/theme'; 5 | import { ModelProvider } from 'src/stores/modelStore'; 6 | import { SceneProvider } from 'src/stores/sceneStore'; 7 | import { UiStateProvider } from 'src/stores/uiStateStore'; 8 | import { SizeIndicator } from '../SizeIndicator'; 9 | 10 | describe('SizeIndicator', () => { 11 | const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) => { 12 | return ( 13 | 14 | 15 | 16 | {children} 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | it('renders without crashing', () => { 24 | const { container } = render( 25 | 26 | 27 | 28 | ); 29 | const box = container.querySelector('div'); 30 | expect(box).toBeInTheDocument(); 31 | expect(box).toHaveStyle('border: 6px solid red'); 32 | }); 33 | 34 | it('matches snapshot', () => { 35 | const { asFragment } = render( 36 | 37 | 38 | 39 | ); 40 | expect(asFragment()).toMatchSnapshot(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/components/SceneLayers/TextBoxes/TextBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Box, Typography } from '@mui/material'; 3 | import { toPx, CoordsUtils } from 'src/utils'; 4 | import { useIsoProjection } from 'src/hooks/useIsoProjection'; 5 | import { useTextBoxProps } from 'src/hooks/useTextBoxProps'; 6 | import { useScene } from 'src/hooks/useScene'; 7 | 8 | interface Props { 9 | textBox: ReturnType['textBoxes'][0]; 10 | } 11 | 12 | export const TextBox = ({ textBox }: Props) => { 13 | const { paddingX, fontProps } = useTextBoxProps(textBox); 14 | 15 | const to = useMemo(() => { 16 | return CoordsUtils.add(textBox.tile, { 17 | x: textBox.size.width, 18 | y: 0 19 | }); 20 | }, [textBox.tile, textBox.size.width]); 21 | 22 | const { css } = useIsoProjection({ 23 | from: textBox.tile, 24 | to, 25 | orientation: textBox.orientation 26 | }); 27 | 28 | return ( 29 | 30 | 42 | 47 | {textBox.content} 48 | 49 | 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/components/SceneLayers/ConnectorLabels/ConnectorLabel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Box, Typography } from '@mui/material'; 3 | import { useScene } from 'src/hooks/useScene'; 4 | import { connectorPathTileToGlobal, getTilePosition } from 'src/utils'; 5 | import { PROJECTED_TILE_SIZE } from 'src/config'; 6 | import { Label } from 'src/components/Label/Label'; 7 | 8 | interface Props { 9 | connector: ReturnType['connectors'][0]; 10 | } 11 | 12 | export const ConnectorLabel = ({ connector }: Props) => { 13 | const labelPosition = useMemo(() => { 14 | const tileIndex = Math.floor(connector.path.tiles.length / 2); 15 | const tile = connector.path.tiles[tileIndex]; 16 | 17 | return getTilePosition({ 18 | tile: connectorPathTileToGlobal(tile, connector.path.rectangle.from) 19 | }); 20 | }, [connector.path]); 21 | 22 | return ( 23 | 31 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/interaction/modes/PlaceIcon.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { ModeActions } from 'src/types'; 3 | import { generateId, getItemAtTile } from 'src/utils'; 4 | import { VIEW_ITEM_DEFAULTS } from 'src/config'; 5 | 6 | export const PlaceIcon: ModeActions = { 7 | mousemove: () => {}, 8 | mousedown: ({ uiState, scene, isRendererInteraction }) => { 9 | if (uiState.mode.type !== 'PLACE_ICON' || !isRendererInteraction) return; 10 | 11 | if (!uiState.mode.id) { 12 | const itemAtTile = getItemAtTile({ 13 | tile: uiState.mouse.position.tile, 14 | scene 15 | }); 16 | 17 | uiState.actions.setMode({ 18 | type: 'CURSOR', 19 | mousedownItem: itemAtTile, 20 | showCursor: true 21 | }); 22 | 23 | uiState.actions.setItemControls(null); 24 | } 25 | }, 26 | mouseup: ({ uiState, scene }) => { 27 | if (uiState.mode.type !== 'PLACE_ICON') return; 28 | 29 | if (uiState.mode.id !== null) { 30 | const modelItemId = generateId(); 31 | 32 | scene.placeIcon({ 33 | modelItem: { 34 | id: modelItemId, 35 | name: 'Untitled', 36 | icon: uiState.mode.id 37 | }, 38 | viewItem: { 39 | ...VIEW_ITEM_DEFAULTS, 40 | id: modelItemId, 41 | tile: uiState.mouse.position.tile 42 | } 43 | }); 44 | } 45 | 46 | uiState.actions.setMode( 47 | produce(uiState.mode, (draft) => { 48 | draft.id = null; 49 | }) 50 | ); 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /docs/pages/docs/api/index.mdx: -------------------------------------------------------------------------------- 1 | # Props 2 | 3 | | Name | Type | Description | Default | 4 | | --- | --- | --- | --- | 5 | | `initialData` | [`object`](/docs/api/initialData) | The initial data that Isoflow will render. If `undefined`, isoflow loads a blank scene. | `undefined` | 6 | | `width` | `number` \| `string` | Width of the Isoflow renderer as a CSS value. | `100%` | 7 | | `height` | `number` \| `string` | Height of the Isoflow renderer as a CSS value. | `100%` | 8 | | `onModelUpdated` | `function` | A callback that is triggered whenever an item is added, updated or removed from the Model. The callback is called with the updated Model as the first argument. | `undefined` | 9 | | `enableDebugTools` | `boolean` | Enables extra tools for debugging purposes. | `false` | 10 | | `editorMode` | `"EXPLORABLE_READONLY"` \| `"NON_INTERACTIVE"` \| `"EDITABLE"` | Enables / disables editor features. | `"EDITABLE"` | 11 | | `mainMenuOptions` | `("ACTION.OPEN" \| "EXPORT.JSON" \| "EXPORT.PNG" \| "ACTION.CLEAR_CANVAS" \| "LINK.GITHUB" \| "LINK.DISCORD" \| "VERSION")[]` | Shows / hides options in the main menu. If `[]` is passed, the menu is hidden. | All enabled | 12 | | `renderer` | [`RendererProps`](#rendererprops) | Configuration for the renderer component. | `undefined` | 13 | 14 | ## RendererProps 15 | 16 | | Name | Type | Description | Default | 17 | | --- | --- | --- | --- | 18 | | `showGrid` | `boolean` | Controls whether the grid is visible. | `undefined` | 19 | | `backgroundColor` | `string` | Sets the background color of the renderer. | `undefined` | -------------------------------------------------------------------------------- /src/schemas/__tests__/colors.test.ts: -------------------------------------------------------------------------------- 1 | import { colorSchema, colorsSchema } from '../colors'; 2 | 3 | describe('colorSchema', () => { 4 | it('validates a correct color', () => { 5 | const valid = { id: 'color1', value: '#123456' }; 6 | expect(colorSchema.safeParse(valid).success).toBe(true); 7 | }); 8 | it('fails if value is too long', () => { 9 | const invalid = { id: 'color1', value: '#1234567A' }; 10 | const result = colorSchema.safeParse(invalid); 11 | expect(result.success).toBe(false); 12 | if (!result.success) { 13 | expect( 14 | result.error.issues.some((issue: any) => { 15 | return issue.path.includes('value'); 16 | }) 17 | ).toBe(true); 18 | } 19 | }); 20 | }); 21 | 22 | describe('colorsSchema', () => { 23 | it('validates an array of colors', () => { 24 | const valid = [ 25 | { id: 'color1', value: '#000000' }, 26 | { id: 'color2', value: '#ffffff' } 27 | ]; 28 | expect(colorsSchema.safeParse(valid).success).toBe(true); 29 | }); 30 | it('fails if any color is invalid', () => { 31 | const invalid = [ 32 | { id: 'color1', value: '#000000' }, 33 | { id: 'color2', value: '#1234567A' } 34 | ]; 35 | const result = colorsSchema.safeParse(invalid); 36 | expect(result.success).toBe(false); 37 | if (!result.success) { 38 | expect( 39 | result.error.issues.some((issue: any) => { 40 | return issue.path.includes('value'); 41 | }) 42 | ).toBe(true); 43 | } 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/schemas/__tests__/icons.test.ts: -------------------------------------------------------------------------------- 1 | import { iconSchema, iconsSchema } from '../icons'; 2 | 3 | describe('iconSchema', () => { 4 | it('validates a correct icon', () => { 5 | const valid = { id: 'icon1', name: 'Icon', url: 'http://test.com' }; 6 | expect(iconSchema.safeParse(valid).success).toBe(true); 7 | }); 8 | it('fails if required fields are missing', () => { 9 | const invalid = { name: 'Icon' }; 10 | const result = iconSchema.safeParse(invalid); 11 | expect(result.success).toBe(false); 12 | if (!result.success) { 13 | expect( 14 | result.error.issues.some((issue: any) => { 15 | return issue.path.includes('id'); 16 | }) 17 | ).toBe(true); 18 | } 19 | }); 20 | }); 21 | 22 | describe('iconsSchema', () => { 23 | it('validates an array of icons', () => { 24 | const valid = [ 25 | { id: 'icon1', name: 'Icon', url: 'http://test.com' }, 26 | { id: 'icon2', name: 'Icon2', url: 'http://test2.com' } 27 | ]; 28 | expect(iconsSchema.safeParse(valid).success).toBe(true); 29 | }); 30 | it('fails if any icon is invalid', () => { 31 | const invalid = [ 32 | { id: 'icon1', name: 'Icon', url: 'http://test.com' }, 33 | { name: 'MissingId' } 34 | ]; 35 | const result = iconsSchema.safeParse(invalid); 36 | expect(result.success).toBe(false); 37 | if (!result.success) { 38 | expect( 39 | result.error.issues.some((issue: any) => { 40 | return issue.path.includes('id'); 41 | }) 42 | ).toBe(true); 43 | } 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/stores/reducers/layerOrdering.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { ItemReference, LayerOrderingAction, View } from 'src/types'; 3 | import { getItemByIdOrThrow } from 'src/utils'; 4 | import { State, ViewReducerContext } from './types'; 5 | 6 | export const changeLayerOrder = ( 7 | { action, item }: { action: LayerOrderingAction; item: ItemReference }, 8 | { viewId, state }: ViewReducerContext 9 | ): State => { 10 | const newState = produce(state, (draft) => { 11 | const view = getItemByIdOrThrow(draft.model.views, viewId); 12 | let arr: View['rectangles']; 13 | 14 | switch (item.type) { 15 | case 'RECTANGLE': 16 | arr = view.value.rectangles ?? []; 17 | break; 18 | default: 19 | throw new Error('Invalid item type'); 20 | } 21 | 22 | const target = getItemByIdOrThrow(arr, item.id); 23 | 24 | if (action === 'SEND_BACKWARD' && target.index < arr.length - 1) { 25 | arr.splice(target.index, 1); 26 | arr.splice(target.index + 1, 0, target.value); 27 | } else if (action === 'SEND_TO_BACK' && target.index !== arr.length - 1) { 28 | arr.splice(target.index, 1); 29 | arr.splice(arr.length, 0, target.value); 30 | } else if (action === 'BRING_FORWARD' && target.index > 0) { 31 | arr.splice(target.index, 1); 32 | arr.splice(target.index - 1, 0, target.value); 33 | } else if (action === 'BRING_TO_FRONT' && target.index !== 0) { 34 | arr.splice(target.index, 1); 35 | arr.splice(0, 0, target.value); 36 | } 37 | }); 38 | 39 | return newState; 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/ItemControls/ItemControlsManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Box } from '@mui/material'; 3 | import { useUiStateStore } from 'src/stores/uiStateStore'; 4 | import { IconSelectionControls } from 'src/components/ItemControls/IconSelectionControls/IconSelectionControls'; 5 | import { NodeControls } from './NodeControls/NodeControls'; 6 | import { ConnectorControls } from './ConnectorControls/ConnectorControls'; 7 | import { TextBoxControls } from './TextBoxControls/TextBoxControls'; 8 | import { RectangleControls } from './RectangleControls/RectangleControls'; 9 | 10 | export const ItemControlsManager = () => { 11 | const itemControls = useUiStateStore((state) => { 12 | return state.itemControls; 13 | }); 14 | 15 | const Controls = useMemo(() => { 16 | switch (itemControls?.type) { 17 | case 'ITEM': 18 | return ; 19 | case 'CONNECTOR': 20 | return ; 21 | case 'TEXTBOX': 22 | return ; 23 | case 'RECTANGLE': 24 | return ; 25 | case 'ADD_ITEM': 26 | return ; 27 | default: 28 | return null; 29 | } 30 | }, [itemControls]); 31 | 32 | return ( 33 | 38 | {Controls} 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/examples/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from 'react'; 2 | import { Box, Select, MenuItem, useTheme } from '@mui/material'; 3 | import { BasicEditor } from './BasicEditor/BasicEditor'; 4 | import { DebugTools } from './DebugTools/DebugTools'; 5 | import { ReadonlyMode } from './ReadonlyMode/ReadonlyMode'; 6 | 7 | const examples = [ 8 | { name: 'Basic editor', component: BasicEditor }, 9 | { name: 'Debug tools', component: DebugTools }, 10 | { name: 'Read-only mode', component: ReadonlyMode } 11 | ]; 12 | 13 | export const Examples = () => { 14 | const theme = useTheme(); 15 | const [currentExample, setCurrentExample] = useState(0); 16 | 17 | const Example = useMemo(() => { 18 | return examples[currentExample].component; 19 | }, [currentExample]); 20 | 21 | return ( 22 | 23 | {Example && } 24 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/interaction/modes/Rectangle/DrawRectangle.ts: -------------------------------------------------------------------------------- 1 | import { ModeActions } from 'src/types'; 2 | import { produce } from 'immer'; 3 | import { generateId, hasMovedTile, setWindowCursor } from 'src/utils'; 4 | 5 | export const DrawRectangle: ModeActions = { 6 | entry: () => { 7 | setWindowCursor('crosshair'); 8 | }, 9 | exit: () => { 10 | setWindowCursor('default'); 11 | }, 12 | mousemove: ({ uiState, scene }) => { 13 | if ( 14 | uiState.mode.type !== 'RECTANGLE.DRAW' || 15 | !hasMovedTile(uiState.mouse) || 16 | !uiState.mode.id || 17 | !uiState.mouse.mousedown 18 | ) 19 | return; 20 | 21 | scene.updateRectangle(uiState.mode.id, { 22 | to: uiState.mouse.position.tile 23 | }); 24 | }, 25 | mousedown: ({ uiState, scene, isRendererInteraction }) => { 26 | if (uiState.mode.type !== 'RECTANGLE.DRAW' || !isRendererInteraction) 27 | return; 28 | 29 | const newRectangleId = generateId(); 30 | 31 | scene.createRectangle({ 32 | id: newRectangleId, 33 | color: scene.colors[0].id, 34 | from: uiState.mouse.position.tile, 35 | to: uiState.mouse.position.tile 36 | }); 37 | 38 | const newMode = produce(uiState.mode, (draft) => { 39 | draft.id = newRectangleId; 40 | }); 41 | 42 | uiState.actions.setMode(newMode); 43 | }, 44 | mouseup: ({ uiState }) => { 45 | if (uiState.mode.type !== 'RECTANGLE.DRAW' || !uiState.mode.id) return; 46 | 47 | uiState.actions.setMode({ 48 | type: 'CURSOR', 49 | showCursor: true, 50 | mousedownItem: null 51 | }); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/schemas/__tests__/modelItems.test.ts: -------------------------------------------------------------------------------- 1 | import { modelItemSchema, modelItemsSchema } from '../modelItems'; 2 | 3 | describe('modelItemSchema', () => { 4 | it('validates a correct model item', () => { 5 | const valid = { 6 | id: 'item1', 7 | name: 'Test', 8 | icon: 'icon1', 9 | description: 'desc' 10 | }; 11 | expect(modelItemSchema.safeParse(valid).success).toBe(true); 12 | }); 13 | it('fails if required fields are missing', () => { 14 | const invalid = { name: 'Test' }; 15 | const result = modelItemSchema.safeParse(invalid); 16 | expect(result.success).toBe(false); 17 | if (!result.success) { 18 | expect( 19 | result.error.issues.some((issue: any) => { 20 | return issue.path.includes('id'); 21 | }) 22 | ).toBe(true); 23 | } 24 | }); 25 | }); 26 | 27 | describe('modelItemsSchema', () => { 28 | it('validates an array of model items', () => { 29 | const valid = [ 30 | { id: 'item1', name: 'Test1' }, 31 | { id: 'item2', name: 'Test2', icon: 'icon2' } 32 | ]; 33 | expect(modelItemsSchema.safeParse(valid).success).toBe(true); 34 | }); 35 | it('fails if any item is invalid', () => { 36 | const invalid = [{ id: 'item1', name: 'Test1' }, { name: 'MissingId' }]; 37 | const result = modelItemsSchema.safeParse(invalid); 38 | expect(result.success).toBe(false); 39 | if (!result.success) { 40 | expect( 41 | result.error.issues.some((issue: any) => { 42 | return issue.path.includes('id'); 43 | }) 44 | ).toBe(true); 45 | } 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/SceneLayer/SceneLayer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState } from 'react'; 2 | import gsap from 'gsap'; 3 | import { Box, SxProps } from '@mui/material'; 4 | import { useUiStateStore } from 'src/stores/uiStateStore'; 5 | 6 | interface Props { 7 | children?: React.ReactNode; 8 | order?: number; 9 | sx?: SxProps; 10 | disableAnimation?: boolean; 11 | } 12 | 13 | export const SceneLayer = ({ 14 | children, 15 | order = 0, 16 | sx, 17 | disableAnimation 18 | }: Props) => { 19 | const [isFirstRender, setIsFirstRender] = useState(true); 20 | const elementRef = useRef(null); 21 | 22 | const scroll = useUiStateStore((state) => { 23 | return state.scroll; 24 | }); 25 | const zoom = useUiStateStore((state) => { 26 | return state.zoom; 27 | }); 28 | 29 | useEffect(() => { 30 | if (!elementRef.current) return; 31 | 32 | gsap.to(elementRef.current, { 33 | duration: disableAnimation || isFirstRender ? 0 : 0.25, 34 | translateX: scroll.position.x, 35 | translateY: scroll.position.y, 36 | scale: zoom 37 | }); 38 | 39 | if (isFirstRender) { 40 | setIsFirstRender(false); 41 | } 42 | }, [zoom, scroll, disableAnimation, isFirstRender]); 43 | 44 | return ( 45 | 58 | {children} 59 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /src/hooks/useDiagramUtils.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useUiStateStore } from 'src/stores/uiStateStore'; 3 | import { Size, Coords } from 'src/types'; 4 | import { 5 | getUnprojectedBounds as getUnprojectedBoundsUtil, 6 | getFitToViewParams as getFitToViewParamsUtil, 7 | CoordsUtils 8 | } from 'src/utils'; 9 | import { useScene } from 'src/hooks/useScene'; 10 | import { useResizeObserver } from './useResizeObserver'; 11 | 12 | export const useDiagramUtils = () => { 13 | const scene = useScene(); 14 | const rendererEl = useUiStateStore((state) => { 15 | return state.rendererEl; 16 | }); 17 | const { size: rendererSize } = useResizeObserver(rendererEl); 18 | const uiStateActions = useUiStateStore((state) => { 19 | return state.actions; 20 | }); 21 | 22 | const getUnprojectedBounds = useCallback((): Size & Coords => { 23 | return getUnprojectedBoundsUtil(scene.currentView); 24 | }, [scene.currentView]); 25 | 26 | const getFitToViewParams = useCallback( 27 | (viewportSize: Size) => { 28 | return getFitToViewParamsUtil(scene.currentView, viewportSize); 29 | }, 30 | [scene.currentView] 31 | ); 32 | 33 | const fitToView = useCallback(async () => { 34 | const { zoom, scroll } = getFitToViewParams(rendererSize); 35 | 36 | uiStateActions.setScroll({ 37 | position: scroll, 38 | offset: CoordsUtils.zero() 39 | }); 40 | uiStateActions.setZoom(zoom); 41 | }, [uiStateActions, getFitToViewParams, rendererSize]); 42 | 43 | return { 44 | getUnprojectedBounds, 45 | fitToView, 46 | getFitToViewParams 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/stores/reducers/__tests__/modelItem.test.ts: -------------------------------------------------------------------------------- 1 | import { model as modelFixture } from 'src/fixtures/model'; 2 | import { ModelItem } from 'src/types'; 3 | import { getItemByIdOrThrow } from 'src/utils'; 4 | import { 5 | createModelItem, 6 | updateModelItem, 7 | deleteModelItem 8 | } from '../modelItem'; 9 | 10 | const scene = { 11 | connectors: {}, 12 | textBoxes: {} 13 | }; 14 | 15 | describe('Model item reducers works correctly', () => { 16 | test('Item is added to model correctly', () => { 17 | const newItem: ModelItem = { 18 | id: 'newItem', 19 | name: 'newItem' 20 | }; 21 | 22 | const newState = createModelItem(newItem, { 23 | model: modelFixture, 24 | scene 25 | }); 26 | 27 | expect(newState.model.items[newState.model.items.length - 1]).toStrictEqual( 28 | newItem 29 | ); 30 | }); 31 | 32 | test('Item is updated correctly', () => { 33 | const nodeId = 'node1'; 34 | const updates: Partial = { 35 | name: 'test' 36 | }; 37 | 38 | const newState = updateModelItem(nodeId, updates, { 39 | model: modelFixture, 40 | scene 41 | }); 42 | 43 | const updatedItem = getItemByIdOrThrow(newState.model.items, nodeId); 44 | 45 | expect(updatedItem.value.name).toBe(updates.name); 46 | }); 47 | 48 | test('Item is deleted correctly', () => { 49 | const nodeId = 'node1'; 50 | 51 | const newState = deleteModelItem(nodeId, { 52 | model: modelFixture, 53 | scene 54 | }); 55 | 56 | const deletedItem = () => { 57 | getItemByIdOrThrow(newState.model.items, nodeId); 58 | }; 59 | 60 | expect(deletedItem).toThrow(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/components/MarkdownEditor/MarkdownEditor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import ReactQuill from 'react-quill'; 3 | import { Box } from '@mui/material'; 4 | 5 | interface Props { 6 | value?: string; 7 | onChange?: (value: string) => void; 8 | readOnly?: boolean; 9 | height?: number; 10 | styles?: React.CSSProperties; 11 | } 12 | 13 | const tools = ['bold', 'italic', 'underline', 'strike', 'link']; 14 | 15 | export const MarkdownEditor = ({ 16 | value, 17 | onChange, 18 | readOnly, 19 | height = 120, 20 | styles 21 | }: Props) => { 22 | const modules = useMemo(() => { 23 | if (!readOnly) 24 | return { 25 | toolbar: tools 26 | }; 27 | 28 | return { toolbar: false }; 29 | }, [readOnly]); 30 | 31 | return ( 32 | 56 | 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/types/model.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod'; 2 | import { 3 | iconSchema, 4 | modelSchema, 5 | modelItemSchema, 6 | modelItemsSchema, 7 | viewsSchema, 8 | viewSchema, 9 | viewItemSchema, 10 | connectorSchema, 11 | iconsSchema, 12 | colorsSchema, 13 | anchorSchema, 14 | textBoxSchema, 15 | rectangleSchema, 16 | connectorStyleOptions 17 | } from 'src/schemas'; 18 | import { StoreApi } from 'zustand'; 19 | 20 | export { connectorStyleOptions } from 'src/schemas'; 21 | export type Model = z.infer; 22 | export type ModelItems = z.infer; 23 | export type Icon = z.infer; 24 | export type Icons = z.infer; 25 | export type Colors = z.infer; 26 | export type ModelItem = z.infer; 27 | export type Views = z.infer; 28 | export type View = z.infer; 29 | export type ViewItem = z.infer; 30 | export type ConnectorStyle = keyof typeof connectorStyleOptions; 31 | export type ConnectorAnchor = z.infer; 32 | export type Connector = z.infer; 33 | export type TextBox = z.infer; 34 | export type Rectangle = z.infer; 35 | 36 | export type ModelStore = Model & { 37 | actions: { 38 | get: StoreApi['getState']; 39 | set: StoreApi['setState']; 40 | }; 41 | }; 42 | 43 | export type { 44 | ModelStoreWithHistory, 45 | HistoryState as ModelHistoryState 46 | } from 'src/stores/modelStore'; 47 | 48 | export type { 49 | SceneStoreWithHistory, 50 | SceneHistoryState 51 | } from 'src/stores/sceneStore'; 52 | -------------------------------------------------------------------------------- /webpack/dev.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebPackPlugin = require('html-webpack-plugin'); 3 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 4 | const webpack = require('webpack'); 5 | 6 | module.exports = { 7 | mode: 'development', 8 | entry: './src/index.tsx', 9 | devtool: 'eval-cheap-source-map', 10 | target: 'web', 11 | output: { 12 | filename: 'main.js', 13 | path: path.resolve(__dirname, 'build') 14 | }, 15 | devServer: { 16 | static: { 17 | directory: path.join(__dirname, 'build') 18 | }, 19 | allowedHosts: [ 20 | '.csb.app', // So Codesandbox.io can run the dev server 21 | '.ngrok-free.app' 22 | ], 23 | port: 3000 24 | }, 25 | module: { 26 | rules: [ 27 | { 28 | test: /\.(ts|tsx)$/, 29 | use: { 30 | loader: 'ts-loader', 31 | options: { 32 | configFile: 'tsconfig.dev.json' 33 | } 34 | }, 35 | exclude: /node_modules/ 36 | }, 37 | { 38 | test: /\.css$/i, 39 | use: ['style-loader', 'css-loader'] 40 | }, 41 | 42 | { 43 | test: /\.svg$/i, 44 | type: 'asset/inline' 45 | } 46 | ] 47 | }, 48 | resolve: { 49 | extensions: ['.tsx', '.ts', '.js'], 50 | plugins: [new TsconfigPathsPlugin()] 51 | }, 52 | plugins: [ 53 | new HtmlWebPackPlugin({ 54 | template: path.resolve(__dirname, '../src/index.html') 55 | }), 56 | new webpack.DefinePlugin({ 57 | PACKAGE_VERSION: JSON.stringify(require("../package.json").version), 58 | REPOSITORY_URL: JSON.stringify(require("../package.json").repository.url), 59 | }) 60 | ] 61 | }; 62 | -------------------------------------------------------------------------------- /src/stores/reducers/rectangle.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { Rectangle } from 'src/types'; 3 | import { getItemByIdOrThrow } from 'src/utils'; 4 | import { State, ViewReducerContext } from './types'; 5 | 6 | export const updateRectangle = ( 7 | { id, ...updates }: { id: string } & Partial, 8 | { viewId, state }: ViewReducerContext 9 | ): State => { 10 | const view = getItemByIdOrThrow(state.model.views, viewId); 11 | 12 | const newState = produce(state, (draft) => { 13 | const { rectangles } = draft.model.views[view.index]; 14 | 15 | if (!rectangles) return; 16 | 17 | const rectangle = getItemByIdOrThrow(rectangles, id); 18 | const newRectangle = { ...rectangle.value, ...updates }; 19 | rectangles[rectangle.index] = newRectangle; 20 | }); 21 | 22 | return newState; 23 | }; 24 | 25 | export const createRectangle = ( 26 | newRectangle: Rectangle, 27 | { viewId, state }: ViewReducerContext 28 | ): State => { 29 | const view = getItemByIdOrThrow(state.model.views, viewId); 30 | 31 | const newState = produce(state, (draft) => { 32 | const { rectangles } = draft.model.views[view.index]; 33 | 34 | if (!rectangles) { 35 | draft.model.views[view.index].rectangles = [newRectangle]; 36 | } else { 37 | draft.model.views[view.index].rectangles?.unshift(newRectangle); 38 | } 39 | }); 40 | 41 | return updateRectangle(newRectangle, { 42 | viewId, 43 | state: newState 44 | }); 45 | }; 46 | 47 | export const deleteRectangle = ( 48 | id: string, 49 | { viewId, state }: ViewReducerContext 50 | ): State => { 51 | const view = getItemByIdOrThrow(state.model.views, viewId); 52 | const rectangle = getItemByIdOrThrow(view.value.rectangles ?? [], id); 53 | 54 | const newState = produce(state, (draft) => { 55 | draft.model.views[view.index].rectangles?.splice(rectangle.index, 1); 56 | }); 57 | 58 | return newState; 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Button, Box, useTheme } from '@mui/material'; 3 | import Tooltip, { TooltipProps } from '@mui/material/Tooltip'; 4 | 5 | interface Props { 6 | name: string; 7 | Icon: React.ReactNode; 8 | isActive?: boolean; 9 | onClick: (e: React.MouseEvent) => void; 10 | tooltipPosition?: TooltipProps['placement']; 11 | disabled?: boolean; 12 | } 13 | 14 | export const IconButton = ({ 15 | name, 16 | Icon, 17 | onClick, 18 | isActive = false, 19 | disabled = false, 20 | tooltipPosition = 'bottom' 21 | }: Props) => { 22 | const theme = useTheme(); 23 | const iconColor = useMemo(() => { 24 | if (isActive) { 25 | return 'grey.200'; 26 | } 27 | 28 | if (disabled) { 29 | return 'grey.800'; 30 | } 31 | 32 | return 'grey.500'; 33 | }, [disabled, isActive]); 34 | 35 | return ( 36 | 44 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/hooks/useIsoProjection.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { Coords, Size, ProjectionOrientationEnum } from 'src/types'; 3 | import { 4 | getBoundingBox, 5 | getIsoProjectionCss, 6 | getTilePosition 7 | } from 'src/utils'; 8 | import { UNPROJECTED_TILE_SIZE } from 'src/config'; 9 | 10 | interface Props { 11 | from: Coords; 12 | to: Coords; 13 | originOverride?: Coords; 14 | orientation?: keyof typeof ProjectionOrientationEnum; 15 | } 16 | 17 | export const useIsoProjection = ({ 18 | from, 19 | to, 20 | originOverride, 21 | orientation 22 | }: Props): { 23 | css: React.CSSProperties; 24 | position: Coords; 25 | gridSize: Size; 26 | pxSize: Size; 27 | } => { 28 | const gridSize = useMemo(() => { 29 | return { 30 | width: Math.abs(from.x - to.x) + 1, 31 | height: Math.abs(from.y - to.y) + 1 32 | }; 33 | }, [from, to]); 34 | 35 | const origin = useMemo(() => { 36 | if (originOverride) return originOverride; 37 | 38 | const boundingBox = getBoundingBox([from, to]); 39 | 40 | return boundingBox[3]; 41 | }, [from, to, originOverride]); 42 | 43 | const position = useMemo(() => { 44 | const pos = getTilePosition({ 45 | tile: origin, 46 | origin: orientation === 'Y' ? 'TOP' : 'LEFT' 47 | }); 48 | 49 | return pos; 50 | }, [origin, orientation]); 51 | 52 | const pxSize = useMemo(() => { 53 | return { 54 | width: gridSize.width * UNPROJECTED_TILE_SIZE, 55 | height: gridSize.height * UNPROJECTED_TILE_SIZE 56 | }; 57 | }, [gridSize]); 58 | 59 | return { 60 | css: { 61 | position: 'absolute', 62 | left: position.x, 63 | top: position.y, 64 | width: `${pxSize.width}px`, 65 | height: `${pxSize.height}px`, 66 | transform: getIsoProjectionCss(orientation), 67 | transformOrigin: 'top left' 68 | }, 69 | position, 70 | gridSize, 71 | pxSize 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/ItemControls/IconSelectionControls/IconCollection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Divider, Stack, Typography, Button } from '@mui/material'; 3 | import { 4 | ExpandMore as ChevronDownIcon, 5 | ExpandLess as ChevronUpIcon 6 | } from '@mui/icons-material'; 7 | import { Icon as IconI } from 'src/types'; 8 | import { Section } from 'src/components/ItemControls/components/Section'; 9 | import { IconGrid } from './IconGrid'; 10 | 11 | interface Props { 12 | id?: string; 13 | icons: IconI[]; 14 | onClick?: (icon: IconI) => void; 15 | onMouseDown?: (icon: IconI) => void; 16 | isExpanded: boolean; 17 | } 18 | 19 | export const IconCollection = ({ 20 | id, 21 | icons, 22 | onClick, 23 | onMouseDown, 24 | isExpanded: _isExpanded 25 | }: Props) => { 26 | const [isExpanded, setIsExpanded] = useState(_isExpanded); 27 | 28 | return ( 29 |
30 | 59 | 60 | 61 | {isExpanded && ( 62 | 63 | )} 64 |
65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/TransformControlsManager/TransformAnchor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Coords } from 'src/types'; 3 | import { useTheme, Box } from '@mui/material'; 4 | import { getIsoProjectionCss } from 'src/utils'; 5 | import { Svg } from 'src/components/Svg/Svg'; 6 | import { TRANSFORM_ANCHOR_SIZE, TRANSFORM_CONTROLS_COLOR } from 'src/config'; 7 | 8 | interface Props { 9 | position: Coords; 10 | onMouseDown: () => void; 11 | } 12 | 13 | const strokeWidth = 2; 14 | 15 | export const TransformAnchor = ({ position, onMouseDown }: Props) => { 16 | const [isHovered, setIsHovered] = useState(false); 17 | const theme = useTheme(); 18 | 19 | return ( 20 | { 22 | setIsHovered(true); 23 | }} 24 | onMouseOut={() => { 25 | setIsHovered(false); 26 | }} 27 | onMouseDown={onMouseDown} 28 | sx={{ 29 | position: 'absolute', 30 | transform: getIsoProjectionCss(), 31 | width: TRANSFORM_ANCHOR_SIZE, 32 | height: TRANSFORM_ANCHOR_SIZE 33 | }} 34 | style={{ 35 | left: position.x - TRANSFORM_ANCHOR_SIZE / 2, 36 | top: position.y - TRANSFORM_ANCHOR_SIZE / 2 37 | }} 38 | > 39 | 45 | 46 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /webpack/prod.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 3 | const webpack = require('webpack'); 4 | 5 | module.exports = { 6 | mode: 'production', 7 | target: 'web', 8 | entry: { 9 | 'index': './src/index.ts', 10 | 'standaloneExports': './src/standaloneExports.ts', 11 | }, 12 | output: { 13 | path: path.resolve(__dirname, '../dist'), 14 | filename: '[name].js', 15 | libraryTarget: 'commonjs2', 16 | clean: true 17 | }, 18 | externals: { 19 | react: { 20 | commonjs: 'react', 21 | commonjs2: 'react', 22 | amd: 'React', 23 | root: 'React' 24 | }, 25 | 'react-dom': { 26 | commonjs: 'react-dom', 27 | commonjs2: 'react-dom', 28 | amd: 'ReactDOM', 29 | root: 'ReactDOM' 30 | }, 31 | '@mui/material': '@mui/material', 32 | '@mui/icons-material': '@mui/icons-material', 33 | '@emotion/react': '@emotion/react', 34 | '@emotion/styled': '@emotion/styled' 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.(ts|tsx)$/, 40 | use: { 41 | loader: 'ts-loader', 42 | options: { 43 | transpileOnly: true, 44 | compilerOptions: { 45 | declaration: false, 46 | emitDeclarationOnly: false 47 | } 48 | } 49 | }, 50 | exclude: /node_modules/ 51 | }, 52 | { 53 | test: /\.css$/i, 54 | use: ['style-loader', 'css-loader'] 55 | }, 56 | { 57 | test: /\.svg$/, 58 | type: 'asset/inline' 59 | } 60 | ] 61 | }, 62 | plugins: [ 63 | new webpack.DefinePlugin({ 64 | PACKAGE_VERSION: JSON.stringify(require("../package.json").version), 65 | REPOSITORY_URL: JSON.stringify(require("../package.json").repository.url), 66 | }) 67 | ], 68 | resolve: { 69 | extensions: ['.tsx', '.ts', '.js'], 70 | plugins: [new TsconfigPathsPlugin()] 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /src/utils/model.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { Model, ModelStore } from 'src/types'; 3 | import { validateModel } from 'src/schemas/validation'; 4 | import { getItemByIdOrThrow } from './common'; 5 | 6 | export const fixModel = (model: Model): Model => { 7 | const issues = validateModel(model); 8 | 9 | return issues.reduce((acc, issue) => { 10 | if (issue.type === 'INVALID_MODEL_TO_ICON_REF') { 11 | return produce(acc, (draft) => { 12 | const { index: itemIndex } = getItemByIdOrThrow( 13 | draft.items, 14 | issue.params.modelItem 15 | ); 16 | 17 | draft.items[itemIndex].icon = undefined; 18 | }); 19 | } 20 | 21 | if (issue.type === 'CONNECTOR_TOO_FEW_ANCHORS') { 22 | return produce(acc, (draft) => { 23 | const view = getItemByIdOrThrow(draft.views, issue.params.view); 24 | 25 | const connector = getItemByIdOrThrow( 26 | view.value.connectors ?? [], 27 | issue.params.connector 28 | ); 29 | 30 | draft.views[view.index].connectors?.splice(connector.index, 1); 31 | }); 32 | } 33 | 34 | if (issue.type === 'INVALID_ANCHOR_TO_ANCHOR_REF') { 35 | return produce(acc, (draft) => { 36 | const view = getItemByIdOrThrow(draft.views, issue.params.view); 37 | 38 | const connector = getItemByIdOrThrow( 39 | view.value.connectors ?? [], 40 | issue.params.connector 41 | ); 42 | 43 | const anchor = getItemByIdOrThrow( 44 | connector.value.anchors, 45 | issue.params.srcAnchor 46 | ); 47 | 48 | connector.value.anchors.splice(anchor.index, 1); 49 | }); 50 | } 51 | 52 | return acc; 53 | }, model); 54 | }; 55 | 56 | export const modelFromModelStore = (modelStore: ModelStore): Model => { 57 | return { 58 | version: modelStore.version, 59 | title: modelStore.title, 60 | description: modelStore.description, 61 | colors: modelStore.colors, 62 | icons: modelStore.icons, 63 | items: modelStore.items, 64 | views: modelStore.views 65 | }; 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/DebugUtils/DebugUtils.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box } from '@mui/material'; 3 | import { useUiStateStore } from 'src/stores/uiStateStore'; 4 | import { useResizeObserver } from 'src/hooks/useResizeObserver'; 5 | import { useScene } from 'src/hooks/useScene'; 6 | import { LineItem } from './LineItem'; 7 | 8 | export const DebugUtils = () => { 9 | const uiState = useUiStateStore( 10 | ({ scroll, mouse, zoom, mode, rendererEl }) => { 11 | return { scroll, mouse, zoom, mode, rendererEl }; 12 | } 13 | ); 14 | const scene = useScene(); 15 | const { size: rendererSize } = useResizeObserver(uiState.rendererEl); 16 | 17 | return ( 18 | 27 | 31 | 39 | 47 | 51 | 52 | 56 | 60 | 61 | 65 | 66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/Grid/Grid.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { Box } from '@mui/material'; 3 | import gsap from 'gsap'; 4 | import { Size } from 'src/types'; 5 | import gridTileSvg from 'src/assets/grid-tile-bg.svg'; 6 | import { useUiStateStore } from 'src/stores/uiStateStore'; 7 | import { PROJECTED_TILE_SIZE } from 'src/config'; 8 | import { SizeUtils } from 'src/utils/SizeUtils'; 9 | import { useResizeObserver } from 'src/hooks/useResizeObserver'; 10 | 11 | export const Grid = () => { 12 | const elementRef = useRef(null); 13 | const { size } = useResizeObserver(elementRef.current); 14 | const [isFirstRender, setIsFirstRender] = useState(true); 15 | const scroll = useUiStateStore((state) => { 16 | return state.scroll; 17 | }); 18 | const zoom = useUiStateStore((state) => { 19 | return state.zoom; 20 | }); 21 | 22 | useEffect(() => { 23 | if (!elementRef.current) return; 24 | 25 | const tileSize = SizeUtils.multiply(PROJECTED_TILE_SIZE, zoom); 26 | const elSize = elementRef.current.getBoundingClientRect(); 27 | const backgroundPosition: Size = { 28 | width: elSize.width / 2 + scroll.position.x + tileSize.width / 2, 29 | height: elSize.height / 2 + scroll.position.y 30 | }; 31 | 32 | gsap.to(elementRef.current, { 33 | duration: isFirstRender ? 0 : 0.25, 34 | backgroundSize: `${tileSize.width}px ${tileSize.height * 2}px`, 35 | backgroundPosition: `${backgroundPosition.width}px ${backgroundPosition.height}px` 36 | }); 37 | 38 | if (isFirstRender) { 39 | setIsFirstRender(false); 40 | } 41 | }, [scroll, zoom, isFirstRender, size]); 42 | 43 | return ( 44 | 55 | 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/Label/Label.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { Box, SxProps } from '@mui/material'; 3 | 4 | const CONNECTOR_DOT_SIZE = 3; 5 | 6 | export interface Props { 7 | labelHeight?: number; 8 | maxWidth: number; 9 | maxHeight?: number; 10 | expandDirection?: 'CENTER' | 'BOTTOM'; 11 | children: React.ReactNode; 12 | sx?: SxProps; 13 | } 14 | 15 | export const Label = ({ 16 | children, 17 | maxWidth, 18 | maxHeight, 19 | expandDirection = 'CENTER', 20 | labelHeight = 0, 21 | sx 22 | }: Props) => { 23 | const contentRef = useRef(); 24 | 25 | return ( 26 | 32 | {labelHeight > 0 && ( 33 | 43 | 53 | 54 | )} 55 | 56 | 79 | {children} 80 | 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/ItemControls/RectangleControls/RectangleControls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, IconButton as MUIIconButton } from '@mui/material'; 3 | import { useRectangle } from 'src/hooks/useRectangle'; 4 | import { ColorSelector } from 'src/components/ColorSelector/ColorSelector'; 5 | import { useUiStateStore } from 'src/stores/uiStateStore'; 6 | import { useScene } from 'src/hooks/useScene'; 7 | import { Close as CloseIcon } from '@mui/icons-material'; 8 | import { ControlsContainer } from '../components/ControlsContainer'; 9 | import { Section } from '../components/Section'; 10 | import { DeleteButton } from '../components/DeleteButton'; 11 | 12 | interface Props { 13 | id: string; 14 | } 15 | 16 | export const RectangleControls = ({ id }: Props) => { 17 | const uiStateActions = useUiStateStore((state) => { 18 | return state.actions; 19 | }); 20 | const rectangle = useRectangle(id); 21 | const { updateRectangle, deleteRectangle } = useScene(); 22 | 23 | // If rectangle doesn't exist, return null 24 | if (!rectangle) { 25 | return null; 26 | } 27 | 28 | return ( 29 | 30 | 31 | {/* Close button */} 32 | { 35 | return uiStateActions.setItemControls(null); 36 | }} 37 | sx={{ 38 | position: 'absolute', 39 | top: 8, 40 | right: 8, 41 | zIndex: 2 42 | }} 43 | size="small" 44 | > 45 | 46 | 47 |
48 | { 50 | updateRectangle(rectangle.id, { color }); 51 | }} 52 | activeColor={rectangle.color} 53 | /> 54 |
55 |
56 | 57 | { 59 | uiStateActions.setItemControls(null); 60 | deleteRectangle(rectangle.id); 61 | }} 62 | /> 63 | 64 |
65 |
66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/ItemControls/NodeControls/NodeSettings/NodeSettings.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Slider, Box, TextField } from '@mui/material'; 3 | import { ModelItem, ViewItem } from 'src/types'; 4 | import { MarkdownEditor } from 'src/components/MarkdownEditor/MarkdownEditor'; 5 | import { useModelItem } from 'src/hooks/useModelItem'; 6 | import { DeleteButton } from '../../components/DeleteButton'; 7 | import { Section } from '../../components/Section'; 8 | 9 | export type NodeUpdates = { 10 | model: Partial; 11 | view: Partial; 12 | }; 13 | 14 | interface Props { 15 | node: ViewItem; 16 | onModelItemUpdated: (updates: Partial) => void; 17 | onViewItemUpdated: (updates: Partial) => void; 18 | onDeleted: () => void; 19 | } 20 | 21 | export const NodeSettings = ({ 22 | node, 23 | onModelItemUpdated, 24 | onViewItemUpdated, 25 | onDeleted 26 | }: Props) => { 27 | const modelItem = useModelItem(node.id); 28 | 29 | if (!modelItem) { 30 | return null; 31 | } 32 | 33 | return ( 34 | <> 35 |
36 | { 39 | const text = e.target.value as string; 40 | if (modelItem.name !== text) onModelItemUpdated({ name: text }); 41 | }} 42 | /> 43 |
44 |
45 | { 48 | if (modelItem.description !== text) 49 | onModelItemUpdated({ description: text }); 50 | }} 51 | /> 52 |
53 | {modelItem.name && ( 54 |
55 | { 62 | const labelHeight = newHeight as number; 63 | onViewItemUpdated({ labelHeight }); 64 | }} 65 | /> 66 |
67 | )} 68 |
69 | 70 | 71 | 72 |
73 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /src/schemas/__tests__/views.test.ts: -------------------------------------------------------------------------------- 1 | import { viewItemSchema, viewSchema, viewsSchema } from '../views'; 2 | 3 | describe('viewItemSchema', () => { 4 | it('validates a correct view item', () => { 5 | const valid = { id: 'item1', tile: { x: 1, y: 2 } }; 6 | expect(viewItemSchema.safeParse(valid).success).toBe(true); 7 | }); 8 | it('fails if required fields are missing', () => { 9 | const invalid = { tile: { x: 1, y: 2 } }; 10 | const result = viewItemSchema.safeParse(invalid); 11 | expect(result.success).toBe(false); 12 | if (!result.success) { 13 | expect( 14 | result.error.issues.some((issue: any) => { 15 | return issue.path.includes('id'); 16 | }) 17 | ).toBe(true); 18 | } 19 | }); 20 | }); 21 | 22 | describe('viewSchema', () => { 23 | it('validates a correct view', () => { 24 | const valid = { 25 | id: 'view1', 26 | name: 'View', 27 | items: [{ id: 'item1', tile: { x: 0, y: 0 } }] 28 | }; 29 | expect(viewSchema.safeParse(valid).success).toBe(true); 30 | }); 31 | it('fails if items is missing', () => { 32 | const invalid = { id: 'view1', name: 'View' }; 33 | const result = viewSchema.safeParse(invalid); 34 | expect(result.success).toBe(false); 35 | if (!result.success) { 36 | expect( 37 | result.error.issues.some((issue: any) => { 38 | return issue.path.includes('items'); 39 | }) 40 | ).toBe(true); 41 | } 42 | }); 43 | }); 44 | 45 | describe('viewsSchema', () => { 46 | it('validates an array of views', () => { 47 | const valid = [ 48 | { 49 | id: 'view1', 50 | name: 'View', 51 | items: [{ id: 'item1', tile: { x: 0, y: 0 } }] 52 | } 53 | ]; 54 | expect(viewsSchema.safeParse(valid).success).toBe(true); 55 | }); 56 | it('fails if any view is invalid', () => { 57 | const invalid = [ 58 | { 59 | id: 'view1', 60 | name: 'View', 61 | items: [{ id: 'item1', tile: { x: 0, y: 0 } }] 62 | }, 63 | { id: 'view2', name: 'View2' } 64 | ]; 65 | const result = viewsSchema.safeParse(invalid); 66 | expect(result.success).toBe(false); 67 | if (!result.success) { 68 | expect( 69 | result.error.issues.some((issue: any) => { 70 | return issue.path.includes('items'); 71 | }) 72 | ).toBe(true); 73 | } 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/components/TransformControlsManager/TransformControls.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Coords, AnchorPosition } from 'src/types'; 3 | import { Svg } from 'src/components/Svg/Svg'; 4 | import { TRANSFORM_CONTROLS_COLOR } from 'src/config'; 5 | import { useIsoProjection } from 'src/hooks/useIsoProjection'; 6 | import { 7 | getBoundingBox, 8 | outermostCornerPositions, 9 | getTilePosition, 10 | convertBoundsToNamedAnchors 11 | } from 'src/utils'; 12 | import { TransformAnchor } from './TransformAnchor'; 13 | 14 | interface Props { 15 | from: Coords; 16 | to: Coords; 17 | onAnchorMouseDown?: (anchorPosition: AnchorPosition) => void; 18 | } 19 | 20 | const strokeWidth = 2; 21 | 22 | export const TransformControls = ({ from, to, onAnchorMouseDown }: Props) => { 23 | const { css, pxSize } = useIsoProjection({ 24 | from, 25 | to 26 | }); 27 | 28 | const anchors = useMemo(() => { 29 | if (!onAnchorMouseDown) return []; 30 | 31 | const corners = getBoundingBox([from, to]); 32 | const namedCorners = convertBoundsToNamedAnchors(corners); 33 | const cornerPositions = Object.entries(namedCorners).map( 34 | ([key, value], i) => { 35 | const position = getTilePosition({ 36 | tile: value, 37 | origin: outermostCornerPositions[i] 38 | }); 39 | 40 | return { 41 | position, 42 | onMouseDown: () => { 43 | onAnchorMouseDown(key as AnchorPosition); 44 | } 45 | }; 46 | } 47 | ); 48 | 49 | return cornerPositions; 50 | }, [onAnchorMouseDown, from, to]); 51 | 52 | return ( 53 | <> 54 | 60 | 61 | 70 | 71 | 72 | 73 | {anchors.map(({ position, onMouseDown }) => { 74 | return ( 75 | 76 | ); 77 | })} 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /src/interaction/modes/Lasso.ts: -------------------------------------------------------------------------------- 1 | // import { CoordsUtils, isWithinBounds } from 'src/utils'; 2 | // import { ModeActions } from 'src/types'; 3 | 4 | // export const Lasso: ModeActions = { 5 | // type: 'LASSO', 6 | // mousemove: ({ uiState, Model }) => { 7 | // if (uiState.mode.type !== 'LASSO') return; 8 | 9 | // if (uiState.mouse.mousedown === null) return; 10 | // // User is in mousedown mode 11 | 12 | // if ( 13 | // uiState.mouse.delta === null || 14 | // CoordsUtils.isEqual(uiState.mouse.delta.tile, CoordsUtils.zero()) 15 | // ) 16 | // return; 17 | // // User has moved tile since they moused down 18 | 19 | // if (!uiState.mode.isDragging) { 20 | // const { mousedown } = uiState.mouse; 21 | // const items = Model.nodes.filter((node) => { 22 | // return CoordsUtils.isEqual(node.tile, mousedown.tile); 23 | // }); 24 | 25 | // // User is creating a selection 26 | // uiState.mode.selection = { 27 | // startTile: uiState.mouse.mousedown.tile, 28 | // endTile: uiState.mouse.position.tile, 29 | // items 30 | // }; 31 | 32 | // return; 33 | // } 34 | 35 | // if (uiState.mode.isDragging) { 36 | // // User is dragging an existing selection 37 | // uiState.mode.selection.startTile = CoordsUtils.add( 38 | // uiState.mode.selection.startTile, 39 | // uiState.mouse.delta.tile 40 | // ); 41 | // uiState.mode.selection.endTile = CoordsUtils.add( 42 | // uiState.mode.selection.endTile, 43 | // uiState.mouse.delta.tile 44 | // ); 45 | // } 46 | // }, 47 | // mousedown: (draft) => { 48 | // if (draft.mode.type !== 'LASSO') return; 49 | 50 | // if (draft.mode.selection) { 51 | // const isWithinSelection = isWithinBounds(draft.mouse.position.tile, [ 52 | // draft.mode.selection.startTile, 53 | // draft.mode.selection.endTile 54 | // ]); 55 | 56 | // if (!isWithinSelection) { 57 | // draft.mode = { 58 | // type: 'CURSOR', 59 | // showCursor: true, 60 | // mousedown: null 61 | // }; 62 | 63 | // return; 64 | // } 65 | 66 | // if (isWithinSelection) { 67 | // draft.mode.isDragging = true; 68 | 69 | // return; 70 | // } 71 | // } 72 | 73 | // draft.mode = { 74 | // type: 'CURSOR', 75 | // showCursor: true, 76 | // mousedown: null 77 | // }; 78 | // } 79 | // }; 80 | -------------------------------------------------------------------------------- /src/interaction/modes/Rectangle/TransformRectangle.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getItemByIdOrThrow, 3 | getBoundingBox, 4 | convertBoundsToNamedAnchors, 5 | hasMovedTile 6 | } from 'src/utils'; 7 | import { ModeActions } from 'src/types'; 8 | 9 | export const TransformRectangle: ModeActions = { 10 | entry: () => {}, 11 | exit: () => {}, 12 | mousemove: ({ uiState, scene }) => { 13 | if ( 14 | uiState.mode.type !== 'RECTANGLE.TRANSFORM' || 15 | !hasMovedTile(uiState.mouse) 16 | ) 17 | return; 18 | 19 | if (uiState.mode.selectedAnchor) { 20 | // User is dragging an anchor 21 | const rectangle = getItemByIdOrThrow( 22 | scene.rectangles, 23 | uiState.mode.id 24 | ).value; 25 | const rectangleBounds = getBoundingBox([rectangle.to, rectangle.from]); 26 | const namedBounds = convertBoundsToNamedAnchors(rectangleBounds); 27 | 28 | if ( 29 | uiState.mode.selectedAnchor === 'BOTTOM_LEFT' || 30 | uiState.mode.selectedAnchor === 'TOP_RIGHT' 31 | ) { 32 | const nextBounds = getBoundingBox([ 33 | uiState.mode.selectedAnchor === 'BOTTOM_LEFT' 34 | ? namedBounds.TOP_RIGHT 35 | : namedBounds.BOTTOM_LEFT, 36 | uiState.mouse.position.tile 37 | ]); 38 | const nextNamedBounds = convertBoundsToNamedAnchors(nextBounds); 39 | 40 | scene.updateRectangle(uiState.mode.id, { 41 | from: nextNamedBounds.TOP_RIGHT, 42 | to: nextNamedBounds.BOTTOM_LEFT 43 | }); 44 | } else if ( 45 | uiState.mode.selectedAnchor === 'BOTTOM_RIGHT' || 46 | uiState.mode.selectedAnchor === 'TOP_LEFT' 47 | ) { 48 | const nextBounds = getBoundingBox([ 49 | uiState.mode.selectedAnchor === 'BOTTOM_RIGHT' 50 | ? namedBounds.TOP_LEFT 51 | : namedBounds.BOTTOM_RIGHT, 52 | uiState.mouse.position.tile 53 | ]); 54 | const nextNamedBounds = convertBoundsToNamedAnchors(nextBounds); 55 | 56 | scene.updateRectangle(uiState.mode.id, { 57 | from: nextNamedBounds.TOP_LEFT, 58 | to: nextNamedBounds.BOTTOM_RIGHT 59 | }); 60 | } 61 | } 62 | }, 63 | mousedown: () => { 64 | // MOUSE_DOWN is triggered by the anchor iteself (see `TransformAnchor.tsx`) 65 | }, 66 | mouseup: ({ uiState }) => { 67 | if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return; 68 | 69 | uiState.actions.setMode({ 70 | type: 'CURSOR', 71 | mousedownItem: null, 72 | showCursor: true 73 | }); 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /src/components/ZoomControls/ZoomControls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Add as ZoomInIcon, 4 | Remove as ZoomOutIcon, 5 | CropFreeOutlined as FitToScreenIcon, 6 | Help as HelpIcon 7 | } from '@mui/icons-material'; 8 | import { Stack, Box, Typography, Divider } from '@mui/material'; 9 | import { toPx } from 'src/utils'; 10 | import { UiElement } from 'src/components/UiElement/UiElement'; 11 | import { IconButton } from 'src/components/IconButton/IconButton'; 12 | import { MAX_ZOOM, MIN_ZOOM } from 'src/config'; 13 | import { useUiStateStore } from 'src/stores/uiStateStore'; 14 | import { useDiagramUtils } from 'src/hooks/useDiagramUtils'; 15 | import { DialogTypeEnum } from 'src/types/ui'; 16 | 17 | export const ZoomControls = () => { 18 | const uiStateStoreActions = useUiStateStore((state) => { 19 | return state.actions; 20 | }); 21 | const zoom = useUiStateStore((state) => { 22 | return state.zoom; 23 | }); 24 | const { fitToView } = useDiagramUtils(); 25 | 26 | return ( 27 | 28 | 29 | 30 | } 33 | onClick={uiStateStoreActions.decrementZoom} 34 | disabled={zoom >= MAX_ZOOM} 35 | /> 36 | 37 | 45 | 46 | {Math.ceil(zoom * 100)}% 47 | 48 | 49 | 50 | } 53 | onClick={uiStateStoreActions.incrementZoom} 54 | disabled={zoom <= MIN_ZOOM} 55 | /> 56 | 57 | 58 | 59 | } 62 | onClick={fitToView} 63 | /> 64 | 65 | 66 | } 69 | onClick={() => { 70 | return uiStateStoreActions.setDialog(DialogTypeEnum.HELP); 71 | }} 72 | /> 73 | 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /docs/pages/docs/isopacks.mdx: -------------------------------------------------------------------------------- 1 | # Isopacks 2 | 3 | **Isopacks** are add-on modules for Isoflow that contain icons and other assets. You can easily build your own from scratch or create and load your own. 4 | 5 | ### Cloud & Network Isopacks 6 | 7 | These are available as a separately maintained project on [Github](https://github.com/markmanx/isopacks). 8 | Below is a sample of icons available in the **Isoflow** Isopack: 9 | 10 |
11 | server 12 | storage 13 | switch 14 |
15 | 16 | In addition, Isopacks for **AWS**, **Azure**, **GCP**, and **Kubernetes** are included. 17 | You can choose which Isopacks to import into your app and which to leave out. 18 | 19 | ### Loading Isopacks into Isoflow 20 | 21 | 1. Install the `npm` package: 22 | 23 | ```bash 24 | npm i @isoflow/isopacks 25 | ``` 26 | 27 | 2. Import your selected Isopacks: 28 | 29 | ```jsx showLineNumbers 30 | import Isoflow from 'isoflow'; 31 | import { flattenCollections } from '@isoflow/isopacks/dist/utils'; 32 | import isoflowIsopack from '@isoflow/isopacks/dist/isoflow'; 33 | import awsIsopack from '@isoflow/isopacks/dist/aws'; 34 | import gcpIsopack from '@isoflow/isopacks/dist/gcp'; 35 | import azureIsopack from '@isoflow/isopacks/dist/azure'; 36 | import kubernetesIsopack from '@isoflow/isopacks/dist/kubernetes'; 37 | 38 | const icons = flattenCollections([ 39 | isoflowIsopack, 40 | awsIsopack, 41 | azureIsopack, 42 | gcpIsopack, 43 | kubernetesIsopack 44 | ]); 45 | 46 | const App = () => { 47 | return ( 48 | 55 | ); 56 | } 57 | 58 | export default App; 59 | ``` 60 | 61 | ## Usage without Isoflow 62 | 63 | Isopacks can also be used without Isoflow or React (for example, you can simply drag and drop the images into slides or documents, or import into your vanilla Javascript / Typescript project). 64 | See the [Isopacks Github project](https://github.com/markmanx/isopacks) for more information. 65 | 66 | ## Self-hosting vs importing icons 67 | 68 | While you can import the icon images directly into your JS or TS application, it is recommended that you host the icon images yourself so that they can be lazy-loaded (referencing them via URL from a service like S3 or a CDN). 69 | -------------------------------------------------------------------------------- /src/stores/reducers/textBox.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { TextBox } from 'src/types'; 3 | import { getItemByIdOrThrow, getTextBoxDimensions } from 'src/utils'; 4 | import { State, ViewReducerContext } from './types'; 5 | 6 | export const syncTextBox = ( 7 | id: string, 8 | { viewId, state }: ViewReducerContext 9 | ): State => { 10 | const newState = produce(state, (draft) => { 11 | const view = getItemByIdOrThrow(draft.model.views, viewId); 12 | const textBox = getItemByIdOrThrow(view.value.textBoxes ?? [], id); 13 | 14 | const textBoxSize = getTextBoxDimensions(textBox.value); 15 | 16 | draft.scene.textBoxes[textBox.value.id] = { size: textBoxSize }; 17 | }); 18 | 19 | return newState; 20 | }; 21 | 22 | export const updateTextBox = ( 23 | { id, ...updates }: { id: string } & Partial, 24 | { viewId, state }: ViewReducerContext 25 | ): State => { 26 | const view = getItemByIdOrThrow(state.model.views, viewId); 27 | 28 | const newState = produce(state, (draft) => { 29 | const { textBoxes } = draft.model.views[view.index]; 30 | 31 | if (!textBoxes) return; 32 | 33 | const textBox = getItemByIdOrThrow(textBoxes, id); 34 | const newTextBox = { ...textBox.value, ...updates }; 35 | textBoxes[textBox.index] = newTextBox; 36 | 37 | if (updates.content !== undefined || updates.fontSize !== undefined) { 38 | const stateAfterSync = syncTextBox(newTextBox.id, { 39 | viewId, 40 | state: draft 41 | }); 42 | 43 | draft.model = stateAfterSync.model; 44 | draft.scene = stateAfterSync.scene; 45 | } 46 | }); 47 | 48 | return newState; 49 | }; 50 | 51 | export const createTextBox = ( 52 | newTextBox: TextBox, 53 | { viewId, state }: ViewReducerContext 54 | ): State => { 55 | const view = getItemByIdOrThrow(state.model.views, viewId); 56 | 57 | const newState = produce(state, (draft) => { 58 | const { textBoxes } = draft.model.views[view.index]; 59 | 60 | if (!textBoxes) { 61 | draft.model.views[view.index].textBoxes = [newTextBox]; 62 | } else { 63 | draft.model.views[view.index].textBoxes?.unshift(newTextBox); 64 | } 65 | }); 66 | 67 | return updateTextBox(newTextBox, { viewId, state: newState }); 68 | }; 69 | 70 | export const deleteTextBox = ( 71 | id: string, 72 | { viewId, state }: ViewReducerContext 73 | ): State => { 74 | const view = getItemByIdOrThrow(state.model.views, viewId); 75 | const textBox = getItemByIdOrThrow(view.value.textBoxes ?? [], id); 76 | 77 | const newState = produce(state, (draft) => { 78 | draft.model.views[view.index].textBoxes?.splice(textBox.index, 1); 79 | }); 80 | 81 | return newState; 82 | }; 83 | -------------------------------------------------------------------------------- /src/components/Label/ExpandableLabel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, useMemo } from 'react'; 2 | import { Box } from '@mui/material'; 3 | import { useResizeObserver } from 'src/hooks/useResizeObserver'; 4 | import { Gradient } from 'src/components/Gradient/Gradient'; 5 | import { ExpandButton } from './ExpandButton'; 6 | import { Label, Props as LabelProps } from './Label'; 7 | 8 | type Props = Omit & { 9 | onToggleExpand?: (isExpanded: boolean) => void; 10 | }; 11 | 12 | const STANDARD_LABEL_HEIGHT = 80; 13 | 14 | export const ExpandableLabel = ({ 15 | children, 16 | onToggleExpand, 17 | ...rest 18 | }: Props) => { 19 | const [isExpanded, setIsExpanded] = useState(false); 20 | const contentRef = useRef(); 21 | const { observe, size: contentSize } = useResizeObserver(); 22 | 23 | useEffect(() => { 24 | if (!contentRef.current) return; 25 | 26 | observe(contentRef.current); 27 | }, [observe]); 28 | 29 | const containerMaxHeight = useMemo(() => { 30 | return isExpanded ? undefined : STANDARD_LABEL_HEIGHT; 31 | }, [isExpanded]); 32 | 33 | const isContentTruncated = useMemo(() => { 34 | return !isExpanded && contentSize.height >= STANDARD_LABEL_HEIGHT - 10; 35 | }, [isExpanded, contentSize.height]); 36 | 37 | useEffect(() => { 38 | contentRef.current?.scrollTo({ top: 0 }); 39 | }, [isExpanded]); 40 | 41 | return ( 42 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/Lasso/Lasso.tsx: -------------------------------------------------------------------------------- 1 | // import { useRef, useCallback } from 'react'; 2 | // import { Rectangle, Shape } from 'paper'; 3 | // import gsap from 'gsap'; 4 | // import { Coords } from 'src/types'; 5 | // import { UNPROJECTED_TILE_SIZE, PIXEL_UNIT } from 'src/renderer/utils/constants'; 6 | // import { 7 | // getBoundingBox, 8 | // sortByPosition, 9 | // getTileBounds 10 | // } from 'src/renderer/utils/gridHelpers'; 11 | // import { applyProjectionMatrix } from 'src/renderer/utils/projection'; 12 | 13 | // export const useLasso = () => { 14 | // const containerRef = useRef(new Rectangle()); 15 | // const shapeRef = useRef(); 16 | 17 | // const setSelection = useCallback((startTile: Coords, endTile: Coords) => { 18 | // if (!shapeRef.current) return; 19 | 20 | // const boundingBox = getBoundingBox([startTile, endTile]); 21 | 22 | // // TODO: Enforce at least one node being passed to this getBoundingBox() to prevent null returns 23 | // if (!boundingBox) return; 24 | 25 | // const lassoStartTile = boundingBox[3]; 26 | // const lassoScreenPosition = getTileBounds(lassoStartTile).left; 27 | // const sorted = sortByPosition(boundingBox); 28 | // const position = { x: sorted.lowX, y: sorted.highY }; 29 | // const size = { 30 | // x: sorted.highX - sorted.lowX, 31 | // y: sorted.highY - sorted.lowY 32 | // }; 33 | 34 | // shapeRef.current.set({ 35 | // position, 36 | // size: [ 37 | // (size.x + 1) * (UNPROJECTED_TILE_SIZE - PIXEL_UNIT * 3), 38 | // (size.y + 1) * (UNPROJECTED_TILE_SIZE - PIXEL_UNIT * 3) 39 | // ] 40 | // }); 41 | 42 | // containerRef.current.set({ 43 | // pivot: shapeRef.current.bounds.bottomLeft, 44 | // position: lassoScreenPosition 45 | // }); 46 | // }, []); 47 | 48 | // const init = useCallback(() => { 49 | // containerRef.current.removeChildren(); 50 | // containerRef.current.set({ pivot: [0, 0] }); 51 | 52 | // shapeRef.current = new Shape.Rectangle({ 53 | // strokeCap: 'round', 54 | // fillColor: 'lightBlue', 55 | // size: [UNPROJECTED_TILE_SIZE, UNPROJECTED_TILE_SIZE], 56 | // opacity: 0.5, 57 | // radius: PIXEL_UNIT * 8, 58 | // strokeWidth: PIXEL_UNIT * 3, 59 | // strokeColor: 'blue', 60 | // dashArray: [5, 10], 61 | // pivot: [0, 0] 62 | // }); 63 | 64 | // gsap 65 | // .fromTo( 66 | // shapeRef.current, 67 | // { dashOffset: 0 }, 68 | // { dashOffset: PIXEL_UNIT * 10, ease: 'none', duration: 0.25 } 69 | // ) 70 | // .repeat(-1); 71 | 72 | // containerRef.current.addChild(shapeRef.current); 73 | // applyProjectionMatrix(containerRef.current); 74 | 75 | // return containerRef.current; 76 | // }, []); 77 | 78 | // return { 79 | // init, 80 | // containerRef, 81 | // setSelection 82 | // }; 83 | // }; 84 | -------------------------------------------------------------------------------- /src/components/SceneLayers/Nodes/Node/Node.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { Box, Typography, Stack } from '@mui/material'; 3 | import { 4 | PROJECTED_TILE_SIZE, 5 | DEFAULT_LABEL_HEIGHT, 6 | MARKDOWN_EMPTY_VALUE 7 | } from 'src/config'; 8 | import { getTilePosition } from 'src/utils'; 9 | import { useIcon } from 'src/hooks/useIcon'; 10 | import { ViewItem } from 'src/types'; 11 | import { useModelItem } from 'src/hooks/useModelItem'; 12 | import { ExpandableLabel } from 'src/components/Label/ExpandableLabel'; 13 | import { MarkdownEditor } from 'src/components/MarkdownEditor/MarkdownEditor'; 14 | 15 | interface Props { 16 | node: ViewItem; 17 | order: number; 18 | } 19 | 20 | export const Node = ({ node, order }: Props) => { 21 | const modelItem = useModelItem(node.id); 22 | const { iconComponent } = useIcon(modelItem?.icon); 23 | 24 | const position = useMemo(() => { 25 | return getTilePosition({ 26 | tile: node.tile, 27 | origin: 'BOTTOM' 28 | }); 29 | }, [node.tile]); 30 | 31 | const description = useMemo(() => { 32 | if ( 33 | !modelItem || 34 | modelItem.description === undefined || 35 | modelItem.description === MARKDOWN_EMPTY_VALUE 36 | ) 37 | return null; 38 | 39 | return modelItem.description; 40 | }, [modelItem?.description]); 41 | 42 | // If modelItem doesn't exist, don't render the node 43 | if (!modelItem) { 44 | return null; 45 | } 46 | 47 | return ( 48 | 54 | 61 | {(modelItem?.name || description) && ( 62 | 66 | 71 | 72 | {modelItem.name && ( 73 | {modelItem.name} 74 | )} 75 | {modelItem.description && 76 | modelItem.description !== MARKDOWN_EMPTY_VALUE && ( 77 | 78 | )} 79 | 80 | 81 | 82 | )} 83 | {iconComponent && ( 84 | 90 | {iconComponent} 91 | 92 | )} 93 | 94 | 95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /src/stores/reducers/types.ts: -------------------------------------------------------------------------------- 1 | import { Model, Scene } from 'src/types'; 2 | import type * as viewReducers from './view'; 3 | import type * as viewItemReducers from './viewItem'; 4 | import type * as connectorReducers from './connector'; 5 | import type * as textBoxReducers from './textBox'; 6 | import type * as rectangleReducers from './rectangle'; 7 | import type * as layerOrderingReducers from './layerOrdering'; 8 | 9 | export interface State { 10 | model: Model; 11 | scene: Scene; 12 | } 13 | 14 | export interface ViewReducerContext { 15 | viewId: string; 16 | state: State; 17 | } 18 | 19 | type ViewReducerAction = 20 | | { 21 | action: 'SYNC_SCENE'; 22 | payload: undefined; 23 | } 24 | | { 25 | action: 'CREATE_VIEW'; 26 | payload: Parameters[0]; 27 | } 28 | | { 29 | action: 'UPDATE_VIEW'; 30 | payload: Parameters[0]; 31 | } 32 | | { 33 | action: 'DELETE_VIEW'; 34 | payload: undefined; 35 | } 36 | | { 37 | action: 'CREATE_VIEWITEM'; 38 | payload: Parameters[0]; 39 | } 40 | | { 41 | action: 'UPDATE_VIEWITEM'; 42 | payload: Parameters[0]; 43 | } 44 | | { 45 | action: 'DELETE_VIEWITEM'; 46 | payload: Parameters[0]; 47 | } 48 | | { 49 | action: 'CREATE_CONNECTOR'; 50 | payload: Parameters[0]; 51 | } 52 | | { 53 | action: 'UPDATE_CONNECTOR'; 54 | payload: Parameters[0]; 55 | } 56 | | { 57 | action: 'DELETE_CONNECTOR'; 58 | payload: Parameters[0]; 59 | } 60 | | { 61 | action: 'SYNC_CONNECTOR'; 62 | payload: Parameters[0]; 63 | } 64 | | { 65 | action: 'CREATE_TEXTBOX'; 66 | payload: Parameters[0]; 67 | } 68 | | { 69 | action: 'UPDATE_TEXTBOX'; 70 | payload: Parameters[0]; 71 | } 72 | | { 73 | action: 'DELETE_TEXTBOX'; 74 | payload: Parameters[0]; 75 | } 76 | | { 77 | action: 'CREATE_RECTANGLE'; 78 | payload: Parameters[0]; 79 | } 80 | | { 81 | action: 'UPDATE_RECTANGLE'; 82 | payload: Parameters[0]; 83 | } 84 | | { 85 | action: 'DELETE_RECTANGLE'; 86 | payload: Parameters[0]; 87 | } 88 | | { 89 | action: 'CHANGE_LAYER_ORDER'; 90 | payload: Parameters[0]; 91 | }; 92 | 93 | export type ViewReducerParams = ViewReducerAction & { ctx: ViewReducerContext }; 94 | -------------------------------------------------------------------------------- /src/components/ItemControls/IconSelectionControls/IconSelectionControls.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { Stack, Alert, IconButton as MUIIconButton, Box } from '@mui/material'; 3 | import { ControlsContainer } from 'src/components/ItemControls/components/ControlsContainer'; 4 | import { useUiStateStore } from 'src/stores/uiStateStore'; 5 | import { Icon } from 'src/types'; 6 | import { Section } from 'src/components/ItemControls/components/Section'; 7 | import { Searchbox } from 'src/components/ItemControls/IconSelectionControls/Searchbox'; 8 | import { useIconFiltering } from 'src/hooks/useIconFiltering'; 9 | import { useIconCategories } from 'src/hooks/useIconCategories'; 10 | import { Close as CloseIcon } from '@mui/icons-material'; 11 | import { Icons } from './Icons'; 12 | import { IconGrid } from './IconGrid'; 13 | 14 | export const IconSelectionControls = () => { 15 | const uiStateActions = useUiStateStore((state) => { 16 | return state.actions; 17 | }); 18 | const mode = useUiStateStore((state) => { 19 | return state.mode; 20 | }); 21 | const { setFilter, filteredIcons, filter } = useIconFiltering(); 22 | const { iconCategories } = useIconCategories(); 23 | 24 | const onMouseDown = useCallback( 25 | (icon: Icon) => { 26 | if (mode.type !== 'PLACE_ICON') return; 27 | 28 | uiStateActions.setMode({ 29 | type: 'PLACE_ICON', 30 | showCursor: true, 31 | id: icon.id 32 | }); 33 | }, 34 | [mode, uiStateActions] 35 | ); 36 | 37 | return ( 38 | 49 | {/* Close button */} 50 | { 53 | return uiStateActions.setItemControls(null); 54 | }} 55 | sx={{ 56 | position: 'absolute', 57 | top: 12, 58 | right: 12, 59 | zIndex: 2, 60 | padding: 0, 61 | background: 'none' 62 | }} 63 | size="small" 64 | > 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | You can drag and drop any item below onto the canvas. 73 | 74 | 75 | 76 | } 77 | > 78 | {filteredIcons && ( 79 |
80 | 81 |
82 | )} 83 | {!filteredIcons && ( 84 | 85 | )} 86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /src/styles/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme, ThemeOptions } from '@mui/material'; 2 | 3 | interface CustomThemeVars { 4 | appPadding: { 5 | x: number; 6 | y: number; 7 | }; 8 | toolMenu: { 9 | height: number; 10 | }; 11 | customPalette: { 12 | [key in string]: string; 13 | }; 14 | } 15 | 16 | declare module '@mui/material/styles' { 17 | interface Theme { 18 | customVars: CustomThemeVars; 19 | } 20 | 21 | interface ThemeOptions { 22 | customVars: CustomThemeVars; 23 | } 24 | } 25 | 26 | export const customVars: CustomThemeVars = { 27 | appPadding: { 28 | x: 40, 29 | y: 40 30 | }, 31 | toolMenu: { 32 | height: 40 33 | }, 34 | customPalette: { 35 | diagramBg: '#f6faff', 36 | defaultColor: '#a5b8f3' 37 | } 38 | }; 39 | 40 | const createShadows = () => { 41 | const shadows = Array(25) 42 | .fill('none') 43 | .map((shadow, i) => { 44 | if (i === 0) return 'none'; 45 | 46 | return `0px 10px 20px ${i - 10}px rgba(0,0,0,0.25)`; 47 | }) as Required['shadows']; 48 | 49 | return shadows; 50 | }; 51 | 52 | export const themeConfig: ThemeOptions = { 53 | customVars, 54 | shadows: createShadows(), 55 | typography: { 56 | h2: { 57 | fontSize: '4em', 58 | fontStyle: 'bold', 59 | lineHeight: 1.2 60 | }, 61 | h5: { 62 | fontSize: '1.3em', 63 | lineHeight: 1.2 64 | }, 65 | body1: { 66 | fontSize: '0.85em', 67 | lineHeight: 1.2 68 | }, 69 | body2: { 70 | fontSize: '0.75em', 71 | lineHeight: 1.2 72 | } 73 | }, 74 | palette: { 75 | secondary: { 76 | main: '#df004c' 77 | } 78 | }, 79 | components: { 80 | MuiCard: { 81 | defaultProps: { 82 | elevation: 0, 83 | variant: 'outlined' 84 | } 85 | }, 86 | MuiToolbar: { 87 | styleOverrides: { 88 | root: { 89 | backgroundColor: 'white' 90 | } 91 | } 92 | }, 93 | MuiButtonBase: { 94 | defaultProps: { 95 | disableRipple: true, 96 | disableTouchRipple: true 97 | } 98 | }, 99 | MuiButton: { 100 | defaultProps: { 101 | disableElevation: true, 102 | variant: 'contained', 103 | disableRipple: true, 104 | disableTouchRipple: true 105 | }, 106 | styleOverrides: { 107 | root: { 108 | textTransform: 'none' 109 | } 110 | } 111 | }, 112 | MuiSvgIcon: { 113 | defaultProps: { 114 | color: 'action' 115 | }, 116 | styleOverrides: { 117 | root: { 118 | width: 17, 119 | height: 17 120 | } 121 | } 122 | }, 123 | MuiTextField: { 124 | defaultProps: { 125 | variant: 'outlined' 126 | }, 127 | styleOverrides: { 128 | root: { 129 | '.MuiInputBase-input': {} 130 | } 131 | } 132 | } 133 | } 134 | }; 135 | 136 | export const theme = createTheme(themeConfig); 137 | -------------------------------------------------------------------------------- /src/stores/reducers/__tests__/layerOrdering.test.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { model as modelFixture } from 'src/fixtures/model'; 3 | import { ItemReference } from 'src/types'; 4 | import * as reducers from 'src/stores/reducers'; 5 | 6 | const getModel = () => { 7 | return produce(modelFixture, (draft) => { 8 | draft.views[0].rectangles = [ 9 | { 10 | id: 'rect1', 11 | from: { x: 0, y: 0 }, 12 | to: { x: 1, y: 1 } 13 | }, 14 | { 15 | id: 'rect2', 16 | from: { x: 0, y: 0 }, 17 | to: { x: 1, y: 1 } 18 | }, 19 | { 20 | id: 'rect3', 21 | from: { x: 0, y: 0 }, 22 | to: { x: 1, y: 1 } 23 | } 24 | ]; 25 | }); 26 | }; 27 | 28 | const scene = { 29 | connectors: {}, 30 | textBoxes: {} 31 | }; 32 | 33 | describe('Layer ordering reducers works correctly', () => { 34 | test('Brings layer forwards correctly', () => { 35 | const model = getModel(); 36 | const item: ItemReference = { 37 | type: 'RECTANGLE', 38 | id: 'rect3' 39 | }; 40 | 41 | const result = reducers.view({ 42 | action: 'CHANGE_LAYER_ORDER', 43 | payload: { 44 | action: 'BRING_FORWARD', 45 | item 46 | }, 47 | ctx: { 48 | viewId: 'view1', 49 | state: { model, scene } 50 | } 51 | }); 52 | 53 | expect(result.model.views[0].rectangles?.[1].id).toBe('rect3'); 54 | }); 55 | 56 | test('Brings layer to front correctly', () => { 57 | const model = getModel(); 58 | const item: ItemReference = { 59 | type: 'RECTANGLE', 60 | id: 'rect3' 61 | }; 62 | 63 | const result = reducers.view({ 64 | action: 'CHANGE_LAYER_ORDER', 65 | payload: { 66 | action: 'BRING_TO_FRONT', 67 | item 68 | }, 69 | ctx: { 70 | viewId: 'view1', 71 | state: { model, scene } 72 | } 73 | }); 74 | 75 | expect(result.model.views[0].rectangles?.[0].id).toBe('rect3'); 76 | }); 77 | 78 | test('Sends layer backward correctly', () => { 79 | const model = getModel(); 80 | const item: ItemReference = { 81 | type: 'RECTANGLE', 82 | id: 'rect1' 83 | }; 84 | 85 | const result = reducers.view({ 86 | action: 'CHANGE_LAYER_ORDER', 87 | payload: { 88 | action: 'SEND_BACKWARD', 89 | item 90 | }, 91 | ctx: { 92 | viewId: 'view1', 93 | state: { model, scene } 94 | } 95 | }); 96 | 97 | expect(result.model.views[0].rectangles?.[1].id).toBe('rect1'); 98 | }); 99 | 100 | test('Sends layer to back correctly', () => { 101 | const model = getModel(); 102 | const item: ItemReference = { 103 | type: 'RECTANGLE', 104 | id: 'rect1' 105 | }; 106 | 107 | const result = reducers.view({ 108 | action: 'CHANGE_LAYER_ORDER', 109 | payload: { 110 | action: 'SEND_TO_BACK', 111 | item 112 | }, 113 | ctx: { 114 | viewId: 'view1', 115 | state: { model, scene } 116 | } 117 | }); 118 | 119 | expect(result.model.views[0].rectangles?.[2].id).toBe('rect1'); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/stores/reducers/viewItem.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { ViewItem } from 'src/types'; 3 | import { getItemByIdOrThrow, getConnectorsByViewItem } from 'src/utils'; 4 | import { validateView } from 'src/schemas/validation'; 5 | import { State, ViewReducerContext } from './types'; 6 | import * as reducers from './view'; 7 | 8 | export const updateViewItem = ( 9 | { id, ...updates }: { id: string } & Partial, 10 | { viewId, state }: ViewReducerContext 11 | ): State => { 12 | const newState = produce(state, (draft) => { 13 | const view = getItemByIdOrThrow(draft.model.views, viewId); 14 | const { items } = view.value; 15 | 16 | if (!items) return; 17 | 18 | const viewItem = getItemByIdOrThrow(items, id); 19 | const newItem = { ...viewItem.value, ...updates }; 20 | items[viewItem.index] = newItem; 21 | 22 | if (updates.tile) { 23 | const connectorsToUpdate = getConnectorsByViewItem( 24 | viewItem.value.id, 25 | view.value.connectors ?? [] 26 | ); 27 | 28 | const updatedConnectors = connectorsToUpdate.reduce((acc, connector) => { 29 | return reducers.view({ 30 | action: 'UPDATE_CONNECTOR', 31 | payload: connector, 32 | ctx: { viewId, state: acc } 33 | }); 34 | }, draft); 35 | 36 | draft.model.views[view.index].connectors = 37 | updatedConnectors.model.views[view.index].connectors; 38 | 39 | draft.scene.connectors = updatedConnectors.scene.connectors; 40 | } 41 | }); 42 | 43 | const newView = getItemByIdOrThrow(newState.model.views, viewId); 44 | const issues = validateView(newView.value, { model: newState.model }); 45 | 46 | if (issues.length > 0) { 47 | throw new Error(issues[0].message); 48 | } 49 | 50 | return newState; 51 | }; 52 | 53 | export const createViewItem = ( 54 | newViewItem: ViewItem, 55 | ctx: ViewReducerContext 56 | ): State => { 57 | const { state, viewId } = ctx; 58 | const view = getItemByIdOrThrow(state.model.views, viewId); 59 | 60 | const newState = produce(state, (draft) => { 61 | const { items } = draft.model.views[view.index]; 62 | items.unshift(newViewItem); 63 | }); 64 | 65 | return updateViewItem(newViewItem, { viewId, state: newState }); 66 | }; 67 | 68 | export const deleteViewItem = ( 69 | id: string, 70 | { state, viewId }: ViewReducerContext 71 | ): State => { 72 | const newState = produce(state, (draft) => { 73 | const view = getItemByIdOrThrow(draft.model.views, viewId); 74 | const viewItem = getItemByIdOrThrow(view.value.items, id); 75 | 76 | draft.model.views[view.index].items.splice(viewItem.index, 1); 77 | 78 | const connectorsToUpdate = getConnectorsByViewItem( 79 | viewItem.value.id, 80 | view.value.connectors ?? [] 81 | ); 82 | 83 | const updatedConnectors = connectorsToUpdate.reduce((acc, connector) => { 84 | return reducers.view({ 85 | action: 'SYNC_CONNECTOR', 86 | payload: connector.id, 87 | ctx: { viewId, state: acc } 88 | }); 89 | }, draft); 90 | 91 | draft.model.views[view.index].connectors = 92 | updatedConnectors.model.views[view.index].connectors; 93 | 94 | draft.scene.connectors = updatedConnectors.scene.connectors; 95 | }); 96 | 97 | return newState; 98 | }; 99 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Size, 3 | InitialData, 4 | MainMenuOptions, 5 | Icon, 6 | Connector, 7 | TextBox, 8 | ViewItem, 9 | View, 10 | Rectangle, 11 | Colors 12 | } from 'src/types'; 13 | import { CoordsUtils } from 'src/utils'; 14 | import { customVars } from './styles/theme'; 15 | 16 | // TODO: This file could do with better organisation and convention for easier reading. 17 | export const UNPROJECTED_TILE_SIZE = 100; 18 | export const TILE_PROJECTION_MULTIPLIERS: Size = { 19 | width: 1.415, 20 | height: 0.819 21 | }; 22 | export const PROJECTED_TILE_SIZE = { 23 | width: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.width, 24 | height: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.height 25 | }; 26 | 27 | export const DEFAULT_COLOR: Colors[0] = { 28 | id: '__DEFAULT__', 29 | value: customVars.customPalette.defaultColor 30 | }; 31 | 32 | export const DEFAULT_FONT_FAMILY = 'Roboto, Arial, sans-serif'; 33 | 34 | export const VIEW_DEFAULTS: Required< 35 | Omit 36 | > = { 37 | name: 'Untitled view', 38 | items: [], 39 | connectors: [], 40 | rectangles: [], 41 | textBoxes: [] 42 | }; 43 | 44 | export const VIEW_ITEM_DEFAULTS: Required> = { 45 | labelHeight: 80 46 | }; 47 | 48 | export const CONNECTOR_DEFAULTS: Required> = { 49 | width: 10, 50 | description: '', 51 | anchors: [], 52 | style: 'SOLID' 53 | }; 54 | 55 | // The boundaries of the search area for the pathfinder algorithm 56 | // is the grid that encompasses the two nodes + the offset below. 57 | export const CONNECTOR_SEARCH_OFFSET = { x: 1, y: 1 }; 58 | 59 | export const TEXTBOX_DEFAULTS: Required> = { 60 | orientation: 'X', 61 | fontSize: 0.6, 62 | content: 'Text' 63 | }; 64 | 65 | export const TEXTBOX_PADDING = 0.2; 66 | export const TEXTBOX_FONT_WEIGHT = 'bold'; 67 | 68 | export const RECTANGLE_DEFAULTS: Required< 69 | Omit 70 | > = {}; 71 | 72 | export const ZOOM_INCREMENT = 0.2; 73 | export const MIN_ZOOM = 0.2; 74 | export const MAX_ZOOM = 1; 75 | export const TRANSFORM_ANCHOR_SIZE = 30; 76 | export const TRANSFORM_CONTROLS_COLOR = '#0392ff'; 77 | export const INITIAL_DATA: InitialData = { 78 | title: 'Untitled', 79 | version: '', 80 | icons: [], 81 | colors: [DEFAULT_COLOR], 82 | items: [], 83 | views: [], 84 | fitToView: false 85 | }; 86 | export const INITIAL_UI_STATE = { 87 | zoom: 1, 88 | scroll: { 89 | position: CoordsUtils.zero(), 90 | offset: CoordsUtils.zero() 91 | } 92 | }; 93 | export const INITIAL_SCENE_STATE = { 94 | connectors: {}, 95 | textBoxes: {} 96 | }; 97 | export const MAIN_MENU_OPTIONS: MainMenuOptions = [ 98 | 'ACTION.OPEN', 99 | 'EXPORT.JSON', 100 | 'EXPORT.PNG', 101 | 'ACTION.CLEAR_CANVAS', 102 | 'LINK.DISCORD', 103 | 'LINK.GITHUB', 104 | 'VERSION' 105 | ]; 106 | 107 | export const DEFAULT_ICON: Icon = { 108 | id: 'default', 109 | name: 'block', 110 | isIsometric: true, 111 | url: '' 112 | }; 113 | 114 | export const DEFAULT_LABEL_HEIGHT = 20; 115 | export const PROJECT_BOUNDING_BOX_PADDING = 3; 116 | export const MARKDOWN_EMPTY_VALUE = '


'; 117 | -------------------------------------------------------------------------------- /src/interaction/modes/Connector.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | import { 3 | generateId, 4 | getItemAtTile, 5 | getItemByIdOrThrow, 6 | hasMovedTile, 7 | setWindowCursor 8 | } from 'src/utils'; 9 | import { ModeActions, Connector as ConnectorI } from 'src/types'; 10 | 11 | export const Connector: ModeActions = { 12 | entry: () => { 13 | setWindowCursor('crosshair'); 14 | }, 15 | exit: () => { 16 | setWindowCursor('default'); 17 | }, 18 | mousemove: ({ uiState, scene }) => { 19 | if ( 20 | uiState.mode.type !== 'CONNECTOR' || 21 | !uiState.mode.id || 22 | !hasMovedTile(uiState.mouse) 23 | ) 24 | return; 25 | 26 | const connector = getItemByIdOrThrow( 27 | scene.currentView.connectors ?? [], 28 | uiState.mode.id 29 | ); 30 | 31 | const itemAtTile = getItemAtTile({ 32 | tile: uiState.mouse.position.tile, 33 | scene 34 | }); 35 | 36 | if (itemAtTile?.type === 'ITEM') { 37 | const newConnector = produce(connector.value, (draft) => { 38 | draft.anchors[1] = { id: generateId(), ref: { item: itemAtTile.id } }; 39 | }); 40 | 41 | scene.updateConnector(uiState.mode.id, newConnector); 42 | } else { 43 | const newConnector = produce(connector.value, (draft) => { 44 | draft.anchors[1] = { 45 | id: generateId(), 46 | ref: { tile: uiState.mouse.position.tile } 47 | }; 48 | }); 49 | 50 | scene.updateConnector(uiState.mode.id, newConnector); 51 | } 52 | }, 53 | mousedown: ({ uiState, scene, isRendererInteraction }) => { 54 | if (uiState.mode.type !== 'CONNECTOR' || !isRendererInteraction) return; 55 | 56 | const newConnector: ConnectorI = { 57 | id: generateId(), 58 | color: scene.colors[0].id, 59 | anchors: [] 60 | }; 61 | 62 | const itemAtTile = getItemAtTile({ 63 | tile: uiState.mouse.position.tile, 64 | scene 65 | }); 66 | 67 | if (itemAtTile && itemAtTile.type === 'ITEM') { 68 | newConnector.anchors = [ 69 | { id: generateId(), ref: { item: itemAtTile.id } }, 70 | { id: generateId(), ref: { item: itemAtTile.id } } 71 | ]; 72 | } else { 73 | newConnector.anchors = [ 74 | { id: generateId(), ref: { tile: uiState.mouse.position.tile } }, 75 | { id: generateId(), ref: { tile: uiState.mouse.position.tile } } 76 | ]; 77 | } 78 | 79 | scene.createConnector(newConnector); 80 | 81 | uiState.actions.setMode({ 82 | type: 'CONNECTOR', 83 | showCursor: true, 84 | id: newConnector.id 85 | }); 86 | }, 87 | mouseup: ({ uiState, scene }) => { 88 | if (uiState.mode.type !== 'CONNECTOR' || !uiState.mode.id) return; 89 | 90 | const connector = getItemByIdOrThrow(scene.connectors, uiState.mode.id); 91 | const firstAnchor = connector.value.anchors[0]; 92 | const lastAnchor = 93 | connector.value.anchors[connector.value.anchors.length - 1]; 94 | 95 | if ( 96 | connector.value.path.tiles.length < 2 || 97 | !(firstAnchor.ref.item && lastAnchor.ref.item) 98 | ) { 99 | scene.deleteConnector(uiState.mode.id); 100 | } 101 | 102 | uiState.actions.setMode({ 103 | type: 'CURSOR', 104 | showCursor: true, 105 | mousedownItem: null 106 | }); 107 | } 108 | }; 109 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | import chroma from 'chroma-js'; 2 | import { Icon, EditorModeEnum, Mode } from 'src/types'; 3 | import { v4 as uuid } from 'uuid'; 4 | 5 | export const generateId = () => { 6 | return uuid(); 7 | }; 8 | 9 | export const clamp = (num: number, min: number, max: number) => { 10 | return Math.max(Math.min(num, max), min); 11 | }; 12 | 13 | export const getRandom = (min: number, max: number) => { 14 | return Math.floor(Math.random() * (max - min) + min); 15 | }; 16 | 17 | export const roundToOneDecimalPlace = (num: number) => { 18 | return Math.round(num * 10) / 10; 19 | }; 20 | 21 | interface GetColorVariantOpts { 22 | alpha?: number; 23 | grade?: number; 24 | } 25 | 26 | export const getColorVariant = ( 27 | color: string, 28 | variant: 'light' | 'dark', 29 | { alpha = 1, grade = 1 }: GetColorVariantOpts 30 | ) => { 31 | switch (variant) { 32 | case 'light': 33 | return chroma(color).brighten(grade).alpha(alpha).css(); 34 | case 'dark': 35 | return chroma(color).darken(grade).saturate(grade).alpha(alpha).css(); 36 | default: 37 | return chroma(color).alpha(alpha).css(); 38 | } 39 | }; 40 | 41 | export const setWindowCursor = (cursor: string) => { 42 | window.document.body.style.cursor = cursor; 43 | }; 44 | 45 | export const toPx = (value: number | string) => { 46 | return `${value}px`; 47 | }; 48 | 49 | export const categoriseIcons = (icons: Icon[]) => { 50 | const categories: { name?: string; icons: Icon[] }[] = []; 51 | 52 | icons.forEach((icon) => { 53 | const collection = categories.find((cat) => { 54 | return cat.name === icon.collection; 55 | }); 56 | 57 | if (!collection) { 58 | categories.push({ name: icon.collection, icons: [icon] }); 59 | } else { 60 | collection.icons.push(icon); 61 | } 62 | }); 63 | 64 | return categories; 65 | }; 66 | 67 | export const getStartingMode = ( 68 | editorMode: keyof typeof EditorModeEnum 69 | ): Mode => { 70 | switch (editorMode) { 71 | case 'EDITABLE': 72 | return { type: 'CURSOR', showCursor: true, mousedownItem: null }; 73 | case 'EXPLORABLE_READONLY': 74 | return { type: 'PAN', showCursor: false }; 75 | case 'NON_INTERACTIVE': 76 | return { type: 'INTERACTIONS_DISABLED', showCursor: false }; 77 | default: 78 | throw new Error('Invalid editor mode.'); 79 | } 80 | }; 81 | 82 | export function getItemByIdOrThrow( 83 | values: T[], 84 | id: string 85 | ): { value: T; index: number } { 86 | const index = values.findIndex((val) => { 87 | return val.id === id; 88 | }); 89 | 90 | if (index === -1) { 91 | throw new Error(`Item with id "${id}" not found.`); 92 | } 93 | 94 | return { value: values[index], index }; 95 | } 96 | 97 | export function getItemById( 98 | values: T[], 99 | id: string 100 | ): { value: T; index: number } | null { 101 | const index = values.findIndex((val) => { 102 | return val.id === id; 103 | }); 104 | 105 | if (index === -1) { 106 | return null; 107 | } 108 | 109 | return { value: values[index], index }; 110 | } 111 | 112 | export function getItemByIndexOrThrow(items: T[], index: number): T { 113 | const item = items[index]; 114 | 115 | if (!item) { 116 | throw new Error(`Item with index "${index}" not found.`); 117 | } 118 | 119 | return item; 120 | } 121 | -------------------------------------------------------------------------------- /src/stores/reducers/connector.ts: -------------------------------------------------------------------------------- 1 | import { Connector } from 'src/types'; 2 | import { produce } from 'immer'; 3 | import { getItemByIdOrThrow, getConnectorPath, getAllAnchors } from 'src/utils'; 4 | import { validateConnector } from 'src/schemas/validation'; 5 | import { State, ViewReducerContext } from './types'; 6 | 7 | export const deleteConnector = ( 8 | id: string, 9 | { viewId, state }: ViewReducerContext 10 | ): State => { 11 | const view = getItemByIdOrThrow(state.model.views, viewId); 12 | const connector = getItemByIdOrThrow(view.value.connectors ?? [], id); 13 | 14 | const newState = produce(state, (draft) => { 15 | draft.model.views[view.index].connectors?.splice(connector.index, 1); 16 | delete draft.scene.connectors[connector.index]; 17 | }); 18 | 19 | return newState; 20 | }; 21 | 22 | export const syncConnector = ( 23 | id: string, 24 | { viewId, state }: ViewReducerContext 25 | ) => { 26 | const newState = produce(state, (draft) => { 27 | const view = getItemByIdOrThrow(draft.model.views, viewId); 28 | const connector = getItemByIdOrThrow(view.value.connectors ?? [], id); 29 | const allAnchors = getAllAnchors(view.value.connectors ?? []); 30 | const issues = validateConnector(connector.value, { 31 | view: view.value, 32 | model: state.model, 33 | allAnchors 34 | }); 35 | 36 | if (issues.length > 0) { 37 | const stateAfterDelete = deleteConnector(id, { viewId, state: draft }); 38 | 39 | draft.scene = stateAfterDelete.scene; 40 | draft.model = stateAfterDelete.model; 41 | } else { 42 | const path = getConnectorPath({ 43 | anchors: connector.value.anchors, 44 | view: view.value 45 | }); 46 | 47 | draft.scene.connectors[connector.value.id] = { path }; 48 | } 49 | }); 50 | 51 | return newState; 52 | }; 53 | 54 | export const updateConnector = ( 55 | { id, ...updates }: { id: string } & Partial, 56 | { state, viewId }: ViewReducerContext 57 | ): State => { 58 | const newState = produce(state, (draft) => { 59 | const view = getItemByIdOrThrow(draft.model.views, viewId); 60 | const { connectors } = draft.model.views[view.index]; 61 | 62 | if (!connectors) return; 63 | 64 | const connector = getItemByIdOrThrow(connectors, id); 65 | const newConnector = { ...connector.value, ...updates }; 66 | connectors[connector.index] = newConnector; 67 | 68 | if (updates.anchors) { 69 | const stateAfterSync = syncConnector(newConnector.id, { 70 | viewId, 71 | state: draft 72 | }); 73 | 74 | draft.model = stateAfterSync.model; 75 | draft.scene = stateAfterSync.scene; 76 | } 77 | }); 78 | 79 | return newState; 80 | }; 81 | 82 | export const createConnector = ( 83 | newConnector: Connector, 84 | { state, viewId }: ViewReducerContext 85 | ): State => { 86 | const newState = produce(state, (draft) => { 87 | const view = getItemByIdOrThrow(draft.model.views, viewId); 88 | const { connectors } = draft.model.views[view.index]; 89 | 90 | if (!connectors) { 91 | draft.model.views[view.index].connectors = [newConnector]; 92 | } else { 93 | draft.model.views[view.index].connectors?.unshift(newConnector); 94 | } 95 | 96 | const stateAfterSync = syncConnector(newConnector.id, { 97 | viewId, 98 | state: draft 99 | }); 100 | 101 | draft.model = stateAfterSync.model; 102 | draft.scene = stateAfterSync.scene; 103 | }); 104 | 105 | return newState; 106 | }; 107 | -------------------------------------------------------------------------------- /src/Isoflow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { ThemeProvider } from '@mui/material/styles'; 3 | import { Box } from '@mui/material'; 4 | import { theme } from 'src/styles/theme'; 5 | import { IsoflowProps } from 'src/types'; 6 | import { setWindowCursor, modelFromModelStore } from 'src/utils'; 7 | import { useModelStore, ModelProvider } from 'src/stores/modelStore'; 8 | import { SceneProvider } from 'src/stores/sceneStore'; 9 | import { GlobalStyles } from 'src/styles/GlobalStyles'; 10 | import { Renderer } from 'src/components/Renderer/Renderer'; 11 | import { UiOverlay } from 'src/components/UiOverlay/UiOverlay'; 12 | import { UiStateProvider, useUiStateStore } from 'src/stores/uiStateStore'; 13 | import { INITIAL_DATA, MAIN_MENU_OPTIONS } from 'src/config'; 14 | import { useInitialDataManager } from 'src/hooks/useInitialDataManager'; 15 | 16 | const App = ({ 17 | initialData, 18 | mainMenuOptions = MAIN_MENU_OPTIONS, 19 | width = '100%', 20 | height = '100%', 21 | onModelUpdated, 22 | enableDebugTools = false, 23 | editorMode = 'EDITABLE', 24 | renderer 25 | }: IsoflowProps) => { 26 | const uiStateActions = useUiStateStore((state) => { 27 | return state.actions; 28 | }); 29 | const initialDataManager = useInitialDataManager(); 30 | const model = useModelStore((state) => { 31 | return modelFromModelStore(state); 32 | }); 33 | 34 | const { load } = initialDataManager; 35 | 36 | useEffect(() => { 37 | load({ ...INITIAL_DATA, ...initialData }); 38 | }, [initialData, load]); 39 | 40 | useEffect(() => { 41 | uiStateActions.setEditorMode(editorMode); 42 | uiStateActions.setMainMenuOptions(mainMenuOptions); 43 | }, [editorMode, uiStateActions, mainMenuOptions]); 44 | 45 | useEffect(() => { 46 | return () => { 47 | setWindowCursor('default'); 48 | }; 49 | }, []); 50 | 51 | useEffect(() => { 52 | if (!initialDataManager.isReady || !onModelUpdated) return; 53 | 54 | onModelUpdated(model); 55 | }, [model, initialDataManager.isReady, onModelUpdated]); 56 | 57 | useEffect(() => { 58 | uiStateActions.setEnableDebugTools(enableDebugTools); 59 | }, [enableDebugTools, uiStateActions]); 60 | 61 | if (!initialDataManager.isReady) return null; 62 | 63 | return ( 64 | <> 65 | 66 | 75 | 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export const Isoflow = (props: IsoflowProps) => { 83 | return ( 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | ); 94 | }; 95 | 96 | const useIsoflow = () => { 97 | const rendererEl = useUiStateStore((state) => { 98 | return state.rendererEl; 99 | }); 100 | 101 | const ModelActions = useModelStore((state) => { 102 | return state.actions; 103 | }); 104 | 105 | const uiStateActions = useUiStateStore((state) => { 106 | return state.actions; 107 | }); 108 | 109 | return { 110 | Model: ModelActions, 111 | uiState: uiStateActions, 112 | rendererEl 113 | }; 114 | }; 115 | 116 | export { useIsoflow }; 117 | export * from 'src/standaloneExports'; 118 | export default Isoflow; 119 | -------------------------------------------------------------------------------- /src/components/ItemControls/ConnectorControls/ConnectorControls.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Connector, connectorStyleOptions } from 'src/types'; 3 | import { 4 | Box, 5 | Slider, 6 | Select, 7 | MenuItem, 8 | TextField, 9 | IconButton as MUIIconButton 10 | } from '@mui/material'; 11 | import { useConnector } from 'src/hooks/useConnector'; 12 | import { ColorSelector } from 'src/components/ColorSelector/ColorSelector'; 13 | import { useUiStateStore } from 'src/stores/uiStateStore'; 14 | import { useScene } from 'src/hooks/useScene'; 15 | import { Close as CloseIcon } from '@mui/icons-material'; 16 | import { ControlsContainer } from '../components/ControlsContainer'; 17 | import { Section } from '../components/Section'; 18 | import { DeleteButton } from '../components/DeleteButton'; 19 | 20 | interface Props { 21 | id: string; 22 | } 23 | 24 | export const ConnectorControls = ({ id }: Props) => { 25 | const uiStateActions = useUiStateStore((state) => { 26 | return state.actions; 27 | }); 28 | const connector = useConnector(id); 29 | const { updateConnector, deleteConnector } = useScene(); 30 | 31 | // If connector doesn't exist, return null 32 | if (!connector) { 33 | return null; 34 | } 35 | 36 | return ( 37 | 38 | 39 | {/* Close button */} 40 | { 43 | return uiStateActions.setItemControls(null); 44 | }} 45 | sx={{ 46 | position: 'absolute', 47 | top: 16, 48 | right: 16, 49 | zIndex: 2 50 | }} 51 | size="small" 52 | > 53 | 54 | 55 |
56 | { 60 | updateConnector(connector.id, { 61 | description: e.target.value as string 62 | }); 63 | }} 64 | /> 65 |
66 |
67 | { 69 | return updateConnector(connector.id, { color }); 70 | }} 71 | activeColor={connector.color} 72 | /> 73 |
74 |
75 | { 82 | updateConnector(connector.id, { width: newWidth as number }); 83 | }} 84 | /> 85 |
86 |
87 | 99 |
100 |
101 | 102 | { 104 | uiStateActions.setItemControls(null); 105 | deleteConnector(connector.id); 106 | }} 107 | /> 108 | 109 |
110 |
111 |
112 | ); 113 | }; 114 | --------------------------------------------------------------------------------