feat: add LLM-friendly export features and format code with Prettier

This commit is contained in:
pi22by7
2025-07-17 10:33:23 +05:30
parent fff601e213
commit 77b304c98a
29 changed files with 1894 additions and 138 deletions

315
LLM-GENERATION-GUIDE.md Normal file
View File

@@ -0,0 +1,315 @@
# LLM Generation Guide for Isoflow Compact Format
## Overview
This guide explains how to generate JSON files in the Isoflow compact format that can be successfully imported into the application. The compact format is designed for LLM generation with minimal token usage while preserving all essential diagram information.
## Format Structure
The compact format uses this JSON structure:
```json
{
"t": "Diagram Title (max 40 chars)",
"i": [
["Item Name (max 30 chars)", "icon_name", "Description (max 100 chars)"],
["Another Item", "storage", "Database server for user data"]
],
"v": [
[
[[0, 2, 4], [1, -2, 6]],
[[0, 1], [1, 0]]
]
],
"_": { "f": "compact", "v": "1.0" }
}
```
## Structure Explanation
### Root Level
- `t`: **Title** - Short diagram title (max 40 characters)
- `i`: **Items** - Array of diagram elements
- `v`: **Views** - Array of views (usually just one)
- `_`: **Metadata** - Format identifier (always `{"f": "compact", "v": "1.0"}`)
### Items Array (`i`)
Each item is an array with 3 elements:
1. **Name** (string, max 30 chars): Display name of the item
2. **Icon** (string): Icon identifier from available icons
3. **Description** (string, max 100 chars): Brief description
### Views Array (`v`)
Each view contains:
1. **Positions Array**: `[[itemIndex, x, y], ...]` - Position of each item
2. **Connections Array**: `[[fromIndex, toIndex], ...]` - Connections between items
## Available Icons
### Basic Icons (ISOFLOW Collection)
Common icons for general use:
- `storage` - Database/storage
- `server` - Generic server
- `user` - User/person
- `cloud` - Cloud services
- `network` - Network component
- `security` - Security/firewall
- `api` - API/interface
- `queue` - Message queue
- `cache` - Caching system
- `function` - Function/lambda
- `mobile` - Mobile device
- `web` - Web application
- `email` - Email service
- `analytics` - Analytics/monitoring
- `backup` - Backup system
- `load-balancer` - Load balancer
- `cdn` - Content delivery network
- `vpn` - VPN connection
- `firewall` - Firewall/security
- `monitor` - Monitoring system
### AWS Icons (320 available)
Use `aws-` prefix for AWS services:
- `aws-ec2` - EC2 instances
- `aws-s3` - S3 storage
- `aws-rds` - RDS database
- `aws-lambda` - Lambda functions
- `aws-api-gateway` - API Gateway
- `aws-cloudfront` - CloudFront CDN
- `aws-route-53` - Route 53 DNS
- `aws-vpc` - Virtual Private Cloud
- `aws-elb` - Load balancer
- `aws-iam` - Identity management
- `aws-cloudwatch` - Monitoring
- `aws-sns` - Simple notification
- `aws-sqs` - Simple queue
- `aws-dynamodb` - DynamoDB database
- `aws-elasticsearch` - Elasticsearch
- `aws-redshift` - Data warehouse
- `aws-kinesis` - Data streaming
- `aws-eks` - Kubernetes service
- `aws-fargate` - Container service
- `aws-cognito` - User authentication
### Azure Icons (369 available)
Use `azure-` prefix for Azure services:
- `azure-virtual-machine` - Virtual machines
- `azure-storage-account` - Storage
- `azure-sql-database` - SQL database
- `azure-app-service` - Web apps
- `azure-function-app` - Functions
- `azure-api-management` - API management
- `azure-cdn` - Content delivery
- `azure-dns` - DNS service
- `azure-load-balancer` - Load balancer
- `azure-active-directory` - Identity
- `azure-monitor` - Monitoring
- `azure-service-bus` - Message bus
- `azure-cosmos-db` - NoSQL database
- `azure-redis-cache` - Redis cache
- `azure-kubernetes-service` - Kubernetes
- `azure-container-instances` - Containers
- `azure-logic-apps` - Logic apps
- `azure-data-factory` - Data pipeline
- `azure-key-vault` - Key management
- `azure-cognitive-services` - AI services
### GCP Icons (280 available)
Use `gcp-` prefix for Google Cloud services:
- `gcp-compute-engine` - Virtual machines
- `gcp-cloud-storage` - Storage
- `gcp-cloud-sql` - SQL database
- `gcp-app-engine` - Web apps
- `gcp-cloud-functions` - Functions
- `gcp-api-gateway` - API gateway
- `gcp-cloud-cdn` - Content delivery
- `gcp-cloud-dns` - DNS service
- `gcp-cloud-load-balancing` - Load balancer
- `gcp-identity-access-management` - IAM
- `gcp-cloud-monitoring` - Monitoring
- `gcp-cloud-pub-sub` - Message queue
- `gcp-cloud-firestore` - NoSQL database
- `gcp-memorystore` - Redis cache
- `gcp-kubernetes-engine` - Kubernetes
- `gcp-cloud-run` - Container service
- `gcp-cloud-workflows` - Workflows
- `gcp-cloud-dataflow` - Data pipeline
- `gcp-secret-manager` - Secret management
- `gcp-ai-platform` - AI/ML platform
### Kubernetes Icons (56 available)
Use `k8s-` prefix for Kubernetes resources:
- `k8s-pod` - Pods
- `k8s-service` - Services
- `k8s-deployment` - Deployments
- `k8s-configmap` - ConfigMaps
- `k8s-secret` - Secrets
- `k8s-ingress` - Ingress
- `k8s-namespace` - Namespaces
- `k8s-node` - Nodes
- `k8s-persistent-volume` - Storage
- `k8s-daemonset` - DaemonSets
- `k8s-statefulset` - StatefulSets
- `k8s-job` - Jobs
- `k8s-cronjob` - CronJobs
- `k8s-hpa` - Auto-scaling
- `k8s-rbac` - Role-based access
## Positioning System
The positioning system uses a grid-based coordinate system:
- **X-axis**: Horizontal position (negative = left, positive = right)
- **Y-axis**: Vertical position (negative = up, positive = down)
- **Grid spacing**: Each unit represents one grid cell
- **Typical range**: -20 to +20 for both axes
### Positioning Guidelines:
- Start with main components around (0, 0)
- Place related components close together
- Use consistent spacing (3-5 units between components)
- Arrange in logical flow (left to right, top to bottom)
## Connection Guidelines
Connections are defined as `[fromIndex, toIndex]` pairs:
- **fromIndex**: Index of source item in items array
- **toIndex**: Index of destination item in items array
- **Direction**: Connections are directional (from → to)
### Common Connection Patterns:
- **Linear flow**: [0,1], [1,2], [2,3]
- **Hub and spoke**: [0,1], [0,2], [0,3]
- **Mesh**: Multiple bidirectional connections
- **Layered**: Connections between architectural layers
## Generation Examples
### Example 1: Simple Web Application
```json
{
"t": "Simple Web App Architecture",
"i": [
["Web App", "web", "Frontend application"],
["API Gateway", "api", "API management layer"],
["Database", "storage", "User data storage"],
["Cache", "cache", "Redis caching layer"]
],
"v": [
[
[[0, -6, 0], [1, 0, 0], [2, 6, 0], [3, 0, -4]],
[[0, 1], [1, 2], [1, 3]]
]
],
"_": { "f": "compact", "v": "1.0" }
}
```
### Example 2: AWS Architecture
```json
{
"t": "AWS Serverless Architecture",
"i": [
["CloudFront", "aws-cloudfront", "Content delivery network"],
["API Gateway", "aws-api-gateway", "API management"],
["Lambda", "aws-lambda", "Serverless functions"],
["DynamoDB", "aws-dynamodb", "NoSQL database"],
["S3", "aws-s3", "Static file storage"]
],
"v": [
[
[[0, -8, -4], [1, 0, 0], [2, 0, 4], [3, 8, 4], [4, 8, -4]],
[[0, 1], [1, 2], [2, 3], [0, 4]]
]
],
"_": { "f": "compact", "v": "1.0" }
}
```
### Example 3: Kubernetes Architecture
```json
{
"t": "Kubernetes Application",
"i": [
["Ingress", "k8s-ingress", "Traffic routing"],
["Frontend", "k8s-pod", "React application"],
["API Service", "k8s-service", "Backend API"],
["Database", "k8s-pod", "PostgreSQL database"],
["ConfigMap", "k8s-configmap", "Configuration data"]
],
"v": [
[
[[0, 0, -6], [1, -4, 0], [2, 4, 0], [3, 4, 6], [4, -4, 6]],
[[0, 1], [0, 2], [2, 3], [4, 1], [4, 2]]
]
],
"_": { "f": "compact", "v": "1.0" }
}
```
## Best Practices for LLM Generation
### 1. Icon Selection
- Use specific cloud provider icons when targeting that platform
- Use generic icons for platform-agnostic diagrams
- Match icon semantics to component function
- Prefer well-known service icons over generic ones
### 2. Naming
- Keep names concise but descriptive
- Use standard terminology for components
- Include version/type info when relevant
- Avoid special characters in names
### 3. Descriptions
- Provide context about component purpose
- Include key technologies/versions
- Mention important configurations
- Keep under 100 characters
### 4. Layout
- Group related components together
- Use consistent spacing between layers
- Consider data flow direction
- Leave space for connection lines
### 5. Connections
- Model actual data/control flow
- Avoid crossing connections when possible
- Use consistent connection semantics
- Consider bidirectional vs unidirectional flows
## Validation Checklist
Before generating, ensure:
- [ ] All icon names exist in available icons list
- [ ] Item names are ≤ 30 characters
- [ ] Descriptions are ≤ 100 characters
- [ ] Title is ≤ 40 characters
- [ ] Position coordinates are reasonable (-20 to +20)
- [ ] Connection indices reference valid items
- [ ] Metadata format is exactly `{"f": "compact", "v": "1.0"}`
- [ ] JSON structure matches the required format
- [ ] All required fields are present
## Common Pitfalls to Avoid
1. **Invalid icon names**: Always use exact icon IDs from the available list
2. **Missing descriptions**: Always provide the third element in item arrays
3. **Incorrect metadata**: Use exact format `{"f": "compact", "v": "1.0"}`
4. **Invalid connections**: Ensure indices refer to existing items
5. **Extreme coordinates**: Keep positions within reasonable bounds
6. **Missing views**: Always include at least one view with positions
7. **Inconsistent arrays**: Ensure positions and items arrays align
## Token Optimization Tips
- Use shorter but meaningful names
- Truncate descriptions to essential info
- Use efficient coordinate values
- Minimize redundant connections
- Group related components to reduce positioning complexity
This format typically uses 70-90% fewer tokens than the full JSON format while maintaining complete functionality and visual fidelity when imported into the Isoflow application.

View File

File diff suppressed because it is too large Load Diff

View File

@@ -50,7 +50,7 @@ export const ContextMenuManager = ({ anchorEl }: Props) => {
if (model.icons.length > 0) {
const modelItemId = generateId();
const firstIcon = model.icons[0];
scene.placeIcon({
modelItem: {
id: modelItemId,

View File

@@ -58,7 +58,10 @@ export const DebugUtils = () => {
value={`${scene.items.length} items in scene`}
/>
<LineItem title="Mode" value={uiState.mode.type} />
<LineItem title="Mode data" value={JSON.stringify(uiState.mode, null, 2)} />
<LineItem
title="Mode data"
value={JSON.stringify(uiState.mode, null, 2)}
/>
</Box>
);
};

View File

@@ -1,24 +1,24 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { DebugUtils } from '../DebugUtils';
import { ThemeProvider } from '@mui/material/styles';
import { theme } from 'src/styles/theme';
import { ModelProvider } from 'src/stores/modelStore';
import { SceneProvider } from 'src/stores/sceneStore';
import { UiStateProvider } from 'src/stores/uiStateStore';
import { DebugUtils } from '../DebugUtils';
describe('DebugUtils', () => {
const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<ThemeProvider theme={theme}>
<ModelProvider>
<SceneProvider>
<UiStateProvider>
{children}
</UiStateProvider>
</SceneProvider>
</ModelProvider>
</ThemeProvider>
);
const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<ThemeProvider theme={theme}>
<ModelProvider>
<SceneProvider>
<UiStateProvider>{children}</UiStateProvider>
</SceneProvider>
</ModelProvider>
</ThemeProvider>
);
};
it('renders without crashing', () => {
render(
@@ -37,4 +37,4 @@ describe('DebugUtils', () => {
);
expect(asFragment()).toMatchSnapshot();
});
});
});

View File

@@ -10,7 +10,9 @@ describe('LineItem', () => {
});
it('matches snapshot', () => {
const { asFragment } = render(<LineItem title="Snapshot Title" value="Snapshot Value" />);
const { asFragment } = render(
<LineItem title="Snapshot Title" value="Snapshot Value" />
);
expect(asFragment()).toMatchSnapshot();
});
});
});

View File

@@ -1,24 +1,24 @@
import React from 'react';
import { render } from '@testing-library/react';
import { SizeIndicator } from '../SizeIndicator';
import { ThemeProvider } from '@mui/material/styles';
import { theme } from 'src/styles/theme';
import { ModelProvider } from 'src/stores/modelStore';
import { SceneProvider } from 'src/stores/sceneStore';
import { UiStateProvider } from 'src/stores/uiStateStore';
import { SizeIndicator } from '../SizeIndicator';
describe('SizeIndicator', () => {
const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<ThemeProvider theme={theme}>
<ModelProvider>
<SceneProvider>
<UiStateProvider>
{children}
</UiStateProvider>
</SceneProvider>
</ModelProvider>
</ThemeProvider>
);
const Providers: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<ThemeProvider theme={theme}>
<ModelProvider>
<SceneProvider>
<UiStateProvider>{children}</UiStateProvider>
</SceneProvider>
</ModelProvider>
</ThemeProvider>
);
};
it('renders without crashing', () => {
const { container } = render(
@@ -39,4 +39,4 @@ describe('SizeIndicator', () => {
);
expect(asFragment()).toMatchSnapshot();
});
});
});

View File

@@ -12,4 +12,4 @@ describe('Value', () => {
const { asFragment } = render(<Value value="Snapshot Value" />);
expect(asFragment()).toMatchSnapshot();
});
});
});

View File

@@ -103,8 +103,12 @@ const mouseInteractions: ShortcutItem[] = [
];
export const HelpDialog = () => {
const dialog = useUiStateStore((state) => state.dialog);
const setDialog = useUiStateStore((state) => state.actions.setDialog);
const dialog = useUiStateStore((state) => {
return state.dialog;
});
const setDialog = useUiStateStore((state) => {
return state.actions.setDialog;
});
const isOpen = dialog === DialogTypeEnum.HELP;
@@ -131,7 +135,15 @@ export const HelpDialog = () => {
</Typography>
<Button
onClick={handleClose}
sx={{ minWidth: 'auto', p: 1, bgcolor: 'transparent', boxShadow: 'none', '&:hover': { bgcolor: 'transparent' }, '&:focus': { bgcolor: 'transparent' }, '&:active': { bgcolor: 'transparent' } }}
sx={{
minWidth: 'auto',
p: 1,
bgcolor: 'transparent',
boxShadow: 'none',
'&:hover': { bgcolor: 'transparent' },
'&:focus': { bgcolor: 'transparent' },
'&:active': { bgcolor: 'transparent' }
}}
>
<CloseIcon />
</Button>
@@ -153,22 +165,26 @@ export const HelpDialog = () => {
</TableRow>
</TableHead>
<TableBody>
{keyboardShortcuts.map((shortcut, index) => (
<TableRow key={index}>
<TableCell>{shortcut.action}</TableCell>
<TableCell>
<code style={{
backgroundColor: '#f5f5f5',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'monospace'
}}>
{shortcut.shortcut}
</code>
</TableCell>
<TableCell>{shortcut.description}</TableCell>
</TableRow>
))}
{keyboardShortcuts.map((shortcut, index) => {
return (
<TableRow key={index}>
<TableCell>{shortcut.action}</TableCell>
<TableCell>
<code
style={{
backgroundColor: '#f5f5f5',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'monospace'
}}
>
{shortcut.shortcut}
</code>
</TableCell>
<TableCell>{shortcut.description}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
@@ -190,22 +206,26 @@ export const HelpDialog = () => {
</TableRow>
</TableHead>
<TableBody>
{mouseInteractions.map((interaction, index) => (
<TableRow key={index}>
<TableCell>{interaction.action}</TableCell>
<TableCell>
<code style={{
backgroundColor: '#f5f5f5',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'monospace'
}}>
{interaction.shortcut}
</code>
</TableCell>
<TableCell>{interaction.description}</TableCell>
</TableRow>
))}
{mouseInteractions.map((interaction, index) => {
return (
<TableRow key={index}>
<TableCell>{interaction.action}</TableCell>
<TableCell>
<code
style={{
backgroundColor: '#f5f5f5',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'monospace'
}}
>
{interaction.shortcut}
</code>
</TableCell>
<TableCell>{interaction.description}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
@@ -213,8 +233,9 @@ export const HelpDialog = () => {
<Box sx={{ mt: 3, p: 2, bgcolor: 'info.light', borderRadius: 1 }}>
<Typography variant="body2" color="info.contrastText">
<strong>Note:</strong> Keyboard shortcuts are disabled when typing in input fields,
text areas, or content-editable elements to prevent conflicts.
<strong>Note:</strong> Keyboard shortcuts are disabled when typing
in input fields, text areas, or content-editable elements to prevent
conflicts.
</Typography>
</Box>
</DialogContent>
@@ -226,4 +247,4 @@ export const HelpDialog = () => {
</DialogActions>
</Dialog>
);
};
};

View File

@@ -1,14 +1,21 @@
import React from 'react';
import { Connector, connectorStyleOptions } from 'src/types';
import { Box, Slider, Select, MenuItem, TextField, IconButton as MUIIconButton } from '@mui/material';
import {
Box,
Slider,
Select,
MenuItem,
TextField,
IconButton as MUIIconButton
} from '@mui/material';
import { useConnector } from 'src/hooks/useConnector';
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useScene } from 'src/hooks/useScene';
import { Close as CloseIcon } from '@mui/icons-material';
import { ControlsContainer } from '../components/ControlsContainer';
import { Section } from '../components/Section';
import { DeleteButton } from '../components/DeleteButton';
import { Close as CloseIcon } from '@mui/icons-material';
interface Props {
id: string;
@@ -32,12 +39,14 @@ export const ConnectorControls = ({ id }: Props) => {
{/* Close button */}
<MUIIconButton
aria-label="Close"
onClick={() => uiStateActions.setItemControls(null)}
onClick={() => {
return uiStateActions.setItemControls(null);
}}
sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 2,
zIndex: 2
}}
size="small"
>

View File

@@ -7,9 +7,9 @@ import { Section } from 'src/components/ItemControls/components/Section';
import { Searchbox } from 'src/components/ItemControls/IconSelectionControls/Searchbox';
import { useIconFiltering } from 'src/hooks/useIconFiltering';
import { useIconCategories } from 'src/hooks/useIconCategories';
import { Close as CloseIcon } from '@mui/icons-material';
import { Icons } from './Icons';
import { IconGrid } from './IconGrid';
import { Close as CloseIcon } from '@mui/icons-material';
export const IconSelectionControls = () => {
const uiStateActions = useUiStateStore((state) => {
@@ -37,18 +37,28 @@ export const IconSelectionControls = () => {
return (
<ControlsContainer
header={
<Section sx={{ top: 0, pt: 6, pb: 3, position: 'relative', paddingTop: '32px' }}>
<Section
sx={{
top: 0,
pt: 6,
pb: 3,
position: 'relative',
paddingTop: '32px'
}}
>
{/* Close button */}
<MUIIconButton
aria-label="Close"
onClick={() => uiStateActions.setItemControls(null)}
onClick={() => {
return uiStateActions.setItemControls(null);
}}
sx={{
position: 'absolute',
top: 12,
right: 12,
zIndex: 2,
padding: 0,
background: 'none',
background: 'none'
}}
size="small"
>

View File

@@ -51,19 +51,23 @@ export const NodeControls = ({ id }: Props) => {
<ControlsContainer>
<Box
sx={{
bgcolor: (theme) => theme.customVars.customPalette.diagramBg,
position: 'relative',
bgcolor: (theme) => {
return theme.customVars.customPalette.diagramBg;
},
position: 'relative'
}}
>
{/* Close button */}
<MUIIconButton
aria-label="Close"
onClick={() => uiStateActions.setItemControls(null)}
onClick={() => {
return uiStateActions.setItemControls(null);
}}
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 2,
zIndex: 2
}}
size="small"
>
@@ -134,4 +138,3 @@ export const NodeControls = ({ id }: Props) => {
</ControlsContainer>
);
};

View File

@@ -4,10 +4,10 @@ import { useRectangle } from 'src/hooks/useRectangle';
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useScene } from 'src/hooks/useScene';
import { Close as CloseIcon } from '@mui/icons-material';
import { ControlsContainer } from '../components/ControlsContainer';
import { Section } from '../components/Section';
import { DeleteButton } from '../components/DeleteButton';
import { Close as CloseIcon } from '@mui/icons-material';
interface Props {
id: string;
@@ -31,12 +31,14 @@ export const RectangleControls = ({ id }: Props) => {
{/* Close button */}
<MUIIconButton
aria-label="Close"
onClick={() => uiStateActions.setItemControls(null)}
onClick={() => {
return uiStateActions.setItemControls(null);
}}
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 2,
zIndex: 2
}}
size="small"
>

View File

@@ -8,7 +8,10 @@ import {
Slider,
IconButton as MUIIconButton
} from '@mui/material';
import { TextRotationNone as TextRotationNoneIcon, Close as CloseIcon } from '@mui/icons-material';
import {
TextRotationNone as TextRotationNoneIcon,
Close as CloseIcon
} from '@mui/icons-material';
import { useTextBox } from 'src/hooks/useTextBox';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { getIsoProjectionCss } from 'src/utils';
@@ -39,12 +42,14 @@ export const TextBoxControls = ({ id }: Props) => {
{/* Close button */}
<MUIIconButton
aria-label="Close"
onClick={() => uiStateActions.setItemControls(null)}
onClick={() => {
return uiStateActions.setItemControls(null);
}}
sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 2,
zIndex: 2
}}
size="small"
>

View File

@@ -14,12 +14,17 @@ import {
import { UiElement } from 'src/components/UiElement/UiElement';
import { IconButton } from 'src/components/IconButton/IconButton';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { exportAsJSON, modelFromModelStore } from 'src/utils';
import {
exportAsJSON,
exportAsCompactJSON,
transformFromCompactFormat
} from 'src/utils/exportOptions';
import { modelFromModelStore } from 'src/utils';
import { useInitialDataManager } from 'src/hooks/useInitialDataManager';
import { useModelStore } from 'src/stores/modelStore';
import { useHistory } from 'src/hooks/useHistory';
import { MenuItem } from './MenuItem';
import { DialogTypeEnum } from 'src/types/ui';
import { MenuItem } from './MenuItem';
export const MainMenu = () => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
@@ -67,7 +72,14 @@ export const MainMenu = () => {
const fileReader = new FileReader();
fileReader.onload = async (e) => {
const modelData = JSON.parse(e.target?.result as string);
const rawData = JSON.parse(e.target?.result as string);
let modelData = rawData;
// Check format and transform if needed
if (rawData._?.f === 'compact') {
modelData = transformFromCompactFormat(rawData);
}
load(modelData);
clearHistory(); // Clear history when loading new model
};
@@ -85,6 +97,11 @@ export const MainMenu = () => {
uiStateActions.setIsMainMenuOpen(false);
}, [model, uiStateActions]);
const onExportAsCompactJSON = useCallback(async () => {
exportAsCompactJSON(model);
uiStateActions.setIsMainMenuOpen(false);
}, [model, uiStateActions]);
const onExportAsImage = useCallback(() => {
uiStateActions.setIsMainMenuOpen(false);
uiStateActions.setDialog(DialogTypeEnum.EXPORT_IMAGE);
@@ -130,7 +147,12 @@ export const MainMenu = () => {
return (
<UiElement>
<IconButton Icon={<MenuIcon />} name="Main menu" onClick={onToggleMenu} isActive={isMainMenuOpen} />
<IconButton
Icon={<MenuIcon />}
name="Main menu"
onClick={onToggleMenu}
isActive={isMainMenuOpen}
/>
<Menu
anchorEl={anchorEl}
@@ -182,6 +204,12 @@ export const MainMenu = () => {
</MenuItem>
)}
{mainMenuOptions.includes('EXPORT.JSON') && (
<MenuItem onClick={onExportAsCompactJSON} Icon={<ExportJsonIcon />}>
Export as Compact JSON
</MenuItem>
)}
{mainMenuOptions.includes('EXPORT.PNG') && (
<MenuItem onClick={onExportAsImage} Icon={<ExportImageIcon />}>
Export as image

View File

@@ -23,11 +23,11 @@ export const Connector = ({ connector: _connector, isSelected }: Props) => {
const color = useColor(_connector.color);
const { currentView } = useScene();
const connector = useConnector(_connector.id);
if (!connector || !color) {
return null;
}
const { css, pxSize } = useIsoProjection({
...connector.path.rectangle
});

View File

@@ -66,7 +66,9 @@ export const ZoomControls = () => {
<IconButton
name="Help (F1)"
Icon={<HelpIcon />}
onClick={() => uiStateStoreActions.setDialog(DialogTypeEnum.HELP)}
onClick={() => {
return uiStateStoreActions.setDialog(DialogTypeEnum.HELP);
}}
/>
</UiElement>
</Stack>

View File

@@ -92,8 +92,6 @@ const mousedown: ModeActionsAction = ({
draft.mousedownItem = itemAtTile;
})
);
} else {
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
@@ -102,7 +100,7 @@ const mousedown: ModeActionsAction = ({
);
uiState.actions.setItemControls(null);
// Show context menu for empty space on left click
uiState.actions.setContextMenu({
type: 'EMPTY',
@@ -158,7 +156,7 @@ export const Cursor: ModeActions = {
if (uiState.mode.type !== 'CURSOR' || !isRendererInteraction) return;
const hasMoved = uiState.mouse.mousedown && hasMovedTile(uiState.mouse);
if (uiState.mode.mousedownItem && !hasMoved) {
if (uiState.mode.mousedownItem.type === 'ITEM') {
uiState.actions.setItemControls({

View File

@@ -10,7 +10,11 @@ describe('colorSchema', () => {
const result = colorSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('value'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('value');
})
).toBe(true);
}
});
});
@@ -31,7 +35,11 @@ describe('colorsSchema', () => {
const result = colorsSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('value'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('value');
})
).toBe(true);
}
});
});
});

View File

@@ -10,7 +10,11 @@ describe('anchorSchema', () => {
const result = anchorSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('id'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('id');
})
).toBe(true);
}
});
});
@@ -25,7 +29,11 @@ describe('connectorSchema', () => {
const result = connectorSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('anchors'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('anchors');
})
).toBe(true);
}
});
});
});

View File

@@ -10,7 +10,11 @@ describe('iconSchema', () => {
const result = iconSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('id'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('id');
})
).toBe(true);
}
});
});
@@ -31,7 +35,11 @@ describe('iconsSchema', () => {
const result = iconsSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('id'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('id');
})
).toBe(true);
}
});
});
});

View File

@@ -2,7 +2,12 @@ import { modelItemSchema, modelItemsSchema } from '../modelItems';
describe('modelItemSchema', () => {
it('validates a correct model item', () => {
const valid = { id: 'item1', name: 'Test', icon: 'icon1', description: 'desc' };
const valid = {
id: 'item1',
name: 'Test',
icon: 'icon1',
description: 'desc'
};
expect(modelItemSchema.safeParse(valid).success).toBe(true);
});
it('fails if required fields are missing', () => {
@@ -10,7 +15,11 @@ describe('modelItemSchema', () => {
const result = modelItemSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('id'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('id');
})
).toBe(true);
}
});
});
@@ -24,14 +33,15 @@ describe('modelItemsSchema', () => {
expect(modelItemsSchema.safeParse(valid).success).toBe(true);
});
it('fails if any item is invalid', () => {
const invalid = [
{ id: 'item1', name: 'Test1' },
{ name: 'MissingId' }
];
const invalid = [{ id: 'item1', name: 'Test1' }, { name: 'MissingId' }];
const result = modelItemsSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('id'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('id');
})
).toBe(true);
}
});
});
});

View File

@@ -10,7 +10,11 @@ describe('rectangleSchema', () => {
const result = rectangleSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('from'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('from');
})
).toBe(true);
}
});
});
});

View File

@@ -10,7 +10,11 @@ describe('textBoxSchema', () => {
const result = textBoxSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('content'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('content');
})
).toBe(true);
}
});
});
});

View File

@@ -129,33 +129,47 @@ describe('modelSchema Zod validation', () => {
delete invalidModel.title;
const result = require('../model').modelSchema.safeParse(invalidModel);
expect(result.success).toBe(false);
expect(result.error.issues.some((issue: any) => issue.path.includes('title'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('title');
})
).toBe(true);
});
test('Model with invalid color reference fails modelSchema validation', () => {
const { ...invalidModel } = model;
// Add a rectangle with an invalid color to the first view
invalidModel.views = invalidModel.views.map((view: any, i: number) =>
i === 0
invalidModel.views = invalidModel.views.map((view: any, i: number) => {
return i === 0
? {
...view,
rectangles: [
...(view.rectangles || []),
{ id: 'rect-invalid', color: 'notAColor', from: { x: 0, y: 0 }, to: { x: 1, y: 1 } }
{
id: 'rect-invalid',
color: 'notAColor',
from: { x: 0, y: 0 },
to: { x: 1, y: 1 }
}
]
}
: view
);
: view;
});
const result = require('../model').modelSchema.safeParse(invalidModel);
expect(result.success).toBe(false);
if (!result.success) {
// Print all issues for debugging
console.log('Zod issues:', result.error.issues);
expect(result.error.issues.some((issue: any) =>
issue.message === 'Rectangle references a color that does not exist in the model.' &&
issue.params &&
issue.params.rectangle === 'rect-invalid'
)).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return (
issue.message ===
'Rectangle references a color that does not exist in the model.' &&
issue.params &&
issue.params.rectangle === 'rect-invalid'
);
})
).toBe(true);
}
});
});

View File

@@ -10,7 +10,11 @@ describe('viewItemSchema', () => {
const result = viewItemSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('id'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('id');
})
).toBe(true);
}
});
});
@@ -29,7 +33,11 @@ describe('viewSchema', () => {
const result = viewSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('items'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('items');
})
).toBe(true);
}
});
});
@@ -37,19 +45,31 @@ describe('viewSchema', () => {
describe('viewsSchema', () => {
it('validates an array of views', () => {
const valid = [
{ id: 'view1', name: 'View', items: [{ id: 'item1', tile: { x: 0, y: 0 } }] }
{
id: 'view1',
name: 'View',
items: [{ id: 'item1', tile: { x: 0, y: 0 } }]
}
];
expect(viewsSchema.safeParse(valid).success).toBe(true);
});
it('fails if any view is invalid', () => {
const invalid = [
{ id: 'view1', name: 'View', items: [{ id: 'item1', tile: { x: 0, y: 0 } }] },
{
id: 'view1',
name: 'View',
items: [{ id: 'item1', tile: { x: 0, y: 0 } }]
},
{ id: 'view2', name: 'View2' }
];
const result = viewsSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.some((issue: any) => issue.path.includes('items'))).toBe(true);
expect(
result.error.issues.some((issue: any) => {
return issue.path.includes('items');
})
).toBe(true);
}
});
});
});

View File

@@ -1,6 +1,7 @@
import domtoimage from 'dom-to-image';
import FileSaver from 'file-saver';
import { Model, Size } from '../types';
import { icons as availableIcons } from '../examples/initialData';
export const generateGenericFilename = (extension: string) => {
return `isoflow-export-${new Date().toISOString()}.${extension}`;
@@ -36,6 +37,127 @@ export const downloadFile = (data: Blob, filename: string) => {
FileSaver.saveAs(data, filename);
};
export const transformToCompactFormat = (model: Model) => {
const { items, views, icons, title } = model;
// Compact format: ultra-minimal for LLM generation
const compactItems = items.map((item, index) => [
item.name.substring(0, 30), // Truncated name
item.icon || 'block', // Icon reference only (no base64)
item.description?.substring(0, 100) || '' // Truncated description
]);
const compactViews = views.map((view) => {
const positions = view.items.map((viewItem) => {
const itemIndex = items.findIndex(item => item.id === viewItem.id);
return [itemIndex, viewItem.tile.x, viewItem.tile.y];
});
const connections = view.connectors?.map((connector) => {
const fromIndex = items.findIndex(item => item.id === connector.anchors[0]?.ref.item);
const toIndex = items.findIndex(item => item.id === connector.anchors[connector.anchors.length - 1]?.ref.item);
return [fromIndex, toIndex];
}).filter(conn => conn[0] !== -1 && conn[1] !== -1) || [];
return [positions, connections];
});
return {
t: title?.substring(0, 40) || 'Untitled',
i: compactItems,
v: compactViews,
_: { f: 'compact', v: '1.0' }
};
};
export const transformFromCompactFormat = (compactModel: any): Model => {
const { t, i, v, _ } = compactModel;
// Restore from compact format
const fullItems = i.map((item: any, index: number) => ({
id: `item_${index}`,
name: item[0],
icon: item[1],
description: item[2] || '' // Restore description if available
}));
// Resolve icons from the internal icon library
const iconSet = new Set<string>();
i.forEach((item: any) => {
if (item[1]) iconSet.add(item[1]);
});
const fullIcons = Array.from(iconSet).map(iconName => {
// Find the icon in the available icons library
const existingIcon = availableIcons.find(icon => icon.id === iconName || icon.name === iconName);
if (existingIcon) {
// Use the existing icon data with proper URL
return {
id: iconName,
name: existingIcon.name,
url: existingIcon.url,
collection: existingIcon.collection,
isIsometric: existingIcon.isIsometric ?? true
};
} else {
// Fallback for unknown icons
return {
id: iconName,
name: iconName,
url: '', // App will use default icon
isIsometric: true
};
}
});
const fullViews = v.map((view: any, viewIndex: number) => {
const [positions, connections] = view;
const viewItems = positions.map((pos: any) => {
const [itemIndex, x, y] = pos;
return {
id: `item_${itemIndex}`,
tile: { x, y },
labelHeight: 80
};
});
const connectors = connections.map((conn: any, connIndex: number) => {
const [fromIndex, toIndex] = conn;
return {
id: `conn_${viewIndex}_${connIndex}`,
color: 'color1',
anchors: [
{ id: `a_${viewIndex}_${connIndex}_0`, ref: { item: `item_${fromIndex}` } },
{ id: `a_${viewIndex}_${connIndex}_1`, ref: { item: `item_${toIndex}` } }
],
width: 10,
description: '',
style: 'SOLID'
};
});
return {
id: `view_${viewIndex}`,
name: `View ${viewIndex + 1}`,
items: viewItems,
connectors,
rectangles: [],
textBoxes: []
};
});
return {
title: t,
version: '1.0',
items: fullItems,
views: fullViews,
icons: fullIcons,
colors: [{ id: 'color1', value: '#a5b8f3' }]
};
};
export const exportAsJSON = (model: Model) => {
const data = new Blob([JSON.stringify(model)], {
type: 'application/json;charset=utf-8'
@@ -44,6 +166,15 @@ export const exportAsJSON = (model: Model) => {
downloadFile(data, generateGenericFilename('json'));
};
export const exportAsCompactJSON = (model: Model) => {
const compactModel = transformToCompactFormat(model);
const data = new Blob([JSON.stringify(compactModel)], {
type: 'application/json;charset=utf-8'
});
downloadFile(data, generateGenericFilename('compact.json'));
};
export const exportAsImage = async (el: HTMLDivElement, size?: Size) => {
const imageData = await domtoimage.toPng(el, {
...size,

7
tsconfig.dev.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false,
"emitDeclarationOnly": false
}
}

View File

@@ -26,7 +26,12 @@ module.exports = {
rules: [
{
test: /\.(ts|tsx)$/,
use: 'ts-loader',
use: {
loader: 'ts-loader',
options: {
configFile: 'tsconfig.dev.json'
}
},
exclude: /node_modules/
},
{