feat: add mobile-first UX improvements and theme-aware exports

- Mobile hamburger header with slide-in menu
- Mobile bottom action bar for canvas controls
- Full-screen settings modal on mobile
- Mobile empty state with Get Started prompt
- Theme-aware PNG/JPG/SVG exports (respects light/dark mode)
- Added file-saver package for reliable cross-browser downloads
- Updated README with new features and Docker Hub instructions
This commit is contained in:
SysVis AI 2025-12-29 09:58:43 +07:00
parent a4793bf996
commit 5c4b83203c
41 changed files with 4150 additions and 3720 deletions

View file

@ -1,23 +1,23 @@
# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# Stage 1: Build the application
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,35 +1,35 @@
version: '3.8'
services:
webapp:
build: .
container_name: kv-graph-web
restart: always
ports:
- "8338:80"
# Optional if you want to have Ollama running in a container
depends_on:
- ollama
# Optional if you want to have Ollama running in a container
ollama:
image: ollama/ollama:latest
container_name: ollama-service
restart: always
ports:
- "11434:11434"
volumes:
- ./ollama_data:/root/.ollama
environment:
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_ORIGINS="*"
# NVIDIA GPU Support Configuration
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]
# Fallback for systems without 'deploy' support (older compose versions) or explicit runtime
# runtime: nvidia
version: '3.8'
services:
webapp:
build: .
container_name: kv-graph-web
restart: always
ports:
- "8338:80"
# Optional if you want to have Ollama running in a container
depends_on:
- ollama
# Optional if you want to have Ollama running in a container
ollama:
image: ollama/ollama:latest
container_name: ollama-service
restart: always
ports:
- "11434:11434"
volumes:
- ./ollama_data:/root/.ollama
environment:
- OLLAMA_KEEP_ALIVE=24h
- OLLAMA_ORIGINS="*"
# NVIDIA GPU Support Configuration
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]
# Fallback for systems without 'deploy' support (older compose versions) or explicit runtime
# runtime: nvidia

View file

@ -1,21 +1,36 @@
server {
listen 80;
# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Root directory for the app
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# NOTE: Ollama proxy is NOT included by default to allow standalone operation.
# The app works with Browser AI (WebLLM/Transformers.js) without any external services.
#
# If you need to proxy requests to Ollama, either:
# 1. Set the Ollama URL directly in the app settings (e.g., http://your-nas-ip:11434)
# 2. Or mount a custom nginx.conf with your proxy configuration
}
server {
listen 80;
# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Root directory for the app
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Proxy Ollama API requests
# This solves Mixed Content (HTTPS -> HTTP) and CORS issues
location /api/ {
proxy_pass http://ollama:11434/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS headers (redundant if OLLAMA_ORIGINS is set, but good for safety)
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
}

108
package-lock.json generated
View file

@ -13,10 +13,12 @@
"@mlc-ai/web-llm": "^0.2.80",
"@monaco-editor/react": "^4.7.0",
"@types/dagre": "^0.7.53",
"@types/file-saver": "^2.0.7",
"@types/randomcolor": "^0.5.9",
"@xyflow/react": "^12.10.0",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"file-saver": "^2.0.5",
"html-to-image": "^1.11.13",
"lucide-react": "^0.562.0",
"mermaid": "^11.12.2",
@ -170,7 +172,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -577,7 +578,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -621,7 +621,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -1236,9 +1235,9 @@
}
},
"node_modules/@exodus/bytes": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz",
"integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.7.0.tgz",
"integrity": "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -2736,7 +2735,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@ -3067,6 +3067,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@ -3174,7 +3180,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@ -3185,7 +3190,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -3266,7 +3270,6 @@
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/types": "8.50.1",
@ -3688,7 +3691,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3739,6 +3741,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -3884,7 +3887,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -4187,7 +4189,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10"
}
@ -4588,7 +4589,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -4817,12 +4817,13 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@ -4969,7 +4970,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -5238,6 +5238,12 @@
"node": ">=16.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -5911,7 +5917,6 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6",
@ -6436,6 +6441,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@ -6451,15 +6457,15 @@
}
},
"node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
"node": ">= 20"
}
},
"node_modules/matcher": {
@ -6509,18 +6515,6 @@
"uuid": "^11.1.0"
}
},
"node_modules/mermaid/node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -6615,6 +6609,29 @@
"marked": "14.0.0"
}
},
"node_modules/monaco-editor/node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/monaco-editor/node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -6929,7 +6946,6 @@
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -7033,6 +7049,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@ -7048,6 +7065,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@ -7129,7 +7147,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -7139,7 +7156,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -7152,7 +7168,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {
"version": "0.18.0",
@ -7809,9 +7826,9 @@
}
},
"node_modules/ts-api-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz",
"integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==",
"dev": true,
"license": "MIT",
"engines": {
@ -7868,7 +7885,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -7988,7 +8004,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -8393,7 +8408,6 @@
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz",
"integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.99"
},
@ -8425,7 +8439,6 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@ -8466,7 +8479,6 @@
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.20.0"
},

View file

@ -31,10 +31,12 @@
"@mlc-ai/web-llm": "^0.2.80",
"@monaco-editor/react": "^4.7.0",
"@types/dagre": "^0.7.53",
"@types/file-saver": "^2.0.7",
"@types/randomcolor": "^0.5.9",
"@xyflow/react": "^12.10.0",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"file-saver": "^2.0.5",
"html-to-image": "^1.11.13",
"lucide-react": "^0.562.0",
"mermaid": "^11.12.2",

View file

@ -1,25 +1,25 @@
{
"name": "SysVis.AI - System Design Visualizer",
"short_name": "SysVis.AI",
"description": "AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts",
"start_url": "/",
"display": "standalone",
"background_color": "#0B1221",
"theme_color": "#0B1221",
"orientation": "any",
"icons": [
{
"src": "/vite.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
],
"categories": [
"productivity",
"developer tools",
"design"
],
"scope": "/",
"lang": "en"
{
"name": "SysVis.AI - System Design Visualizer",
"short_name": "SysVis.AI",
"description": "AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts",
"start_url": "/",
"display": "standalone",
"background_color": "#0B1221",
"theme_color": "#0B1221",
"orientation": "any",
"icons": [
{
"src": "/vite.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
],
"categories": [
"productivity",
"developer tools",
"design"
],
"scope": "/",
"lang": "en"
}

View file

@ -1,38 +1,38 @@
# Check if Ollama is installed
if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) {
Write-Host "Error: Ollama is not installed or not in your PATH." -ForegroundColor Red
Write-Host "Please install Ollama from https://ollama.com/"
exit 1
}
Write-Host "Checking local Ollama service status..." -ForegroundColor Cyan
try {
$status = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -ErrorAction Stop
Write-Host "Ollama service is running!" -ForegroundColor Green
} catch {
Write-Host "Error: Could not connect to Ollama at http://localhost:11434" -ForegroundColor Red
Write-Host "Please ensure 'ollama serve' is running in another terminal."
exit 1
}
# Define recommended models
$models = @(
@{ Name = "moondream"; Desc = "Tiny, fast vision model (1.7GB). Best for low-end hardware." },
@{ Name = "llava-phi3"; Desc = "High-performance small vision model (2.3GB). Good balance." },
@{ Name = "llama3"; Desc = "Fast standard LLM for text logic (4.7GB)." }
)
Write-Host "`nReady to pull recommended models:" -ForegroundColor Yellow
foreach ($m in $models) {
Write-Host " - $($m.Name): $($m.Desc)"
}
$confirm = Read-Host "`nDo you want to pull these models now? (Y/n)"
if ($confirm -eq 'n') { exit }
foreach ($m in $models) {
Write-Host "`nPulling $($m.Name)..." -ForegroundColor Cyan
ollama pull $m.Name
}
Write-Host "`nAll models ready! You can now select them in the KV-Graph settings." -ForegroundColor Green
# Check if Ollama is installed
if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) {
Write-Host "Error: Ollama is not installed or not in your PATH." -ForegroundColor Red
Write-Host "Please install Ollama from https://ollama.com/"
exit 1
}
Write-Host "Checking local Ollama service status..." -ForegroundColor Cyan
try {
$status = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -ErrorAction Stop
Write-Host "Ollama service is running!" -ForegroundColor Green
} catch {
Write-Host "Error: Could not connect to Ollama at http://localhost:11434" -ForegroundColor Red
Write-Host "Please ensure 'ollama serve' is running in another terminal."
exit 1
}
# Define recommended models
$models = @(
@{ Name = "moondream"; Desc = "Tiny, fast vision model (1.7GB). Best for low-end hardware." },
@{ Name = "llava-phi3"; Desc = "High-performance small vision model (2.3GB). Good balance." },
@{ Name = "llama3"; Desc = "Fast standard LLM for text logic (4.7GB)." }
)
Write-Host "`nReady to pull recommended models:" -ForegroundColor Yellow
foreach ($m in $models) {
Write-Host " - $($m.Name): $($m.Desc)"
}
$confirm = Read-Host "`nDo you want to pull these models now? (Y/n)"
if ($confirm -eq 'n') { exit }
foreach ($m in $models) {
Write-Host "`nPulling $($m.Name)..." -ForegroundColor Cyan
ollama pull $m.Name
}
Write-Host "`nAll models ready! You can now select them in the KV-Graph settings." -ForegroundColor Green

View file

@ -231,99 +231,156 @@ export function FlowCanvas() {
size={1}
/>
{/* Control Panel - Top Right (Unified Toolkit) */}
<Panel position="top-right" className={`!m-4 !mr-6 flex flex-col items-end gap-3 z-50 transition-all duration-300 ${focusMode ? '!mt-20' : ''}`}>
<div className="relative">
<button
onClick={() => setShowToolkit(!showToolkit)}
className={`
{/* Control Panel - Top Right (Unified Toolkit) - Desktop Only */}
{!isMobile && (
<Panel position="top-right" className={`!m-4 !mr-6 flex flex-col items-end gap-3 z-50 transition-all duration-300 ${focusMode ? '!mt-20' : ''}`}>
<div className="relative">
<button
onClick={() => setShowToolkit(!showToolkit)}
className={`
h-10 px-4 flex items-center gap-2 rounded-xl transition-all shadow-lg backdrop-blur-md border outline-none
${showToolkit
? 'bg-blue-600 text-white border-blue-500 ring-2 ring-blue-500/20'
: 'bg-white/90 dark:bg-surface/90 border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-white dark:hover:bg-surface'}
? 'bg-blue-600 text-white border-blue-500 ring-2 ring-blue-500/20'
: 'bg-white/90 dark:bg-surface/90 border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-white dark:hover:bg-surface'}
`}
>
<Settings2 className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span>
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} />
</button>
>
<Settings2 className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span>
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown Menu */}
{showToolkit && (
<div className="absolute top-full right-0 mt-2 w-56 p-2 rounded-2xl bg-white/95 dark:bg-[#0B1221]/95 backdrop-blur-xl border border-slate-200 dark:border-white/10 shadow-2xl flex flex-col gap-2 animate-in fade-in slide-in-from-top-2 duration-200 origin-top-right">
{/* Dropdown Menu */}
{showToolkit && (
<div className="absolute top-full right-0 mt-2 w-56 p-2 rounded-2xl bg-white/95 dark:bg-[#0B1221]/95 backdrop-blur-xl border border-slate-200 dark:border-white/10 shadow-2xl flex flex-col gap-2 animate-in fade-in slide-in-from-top-2 duration-200 origin-top-right">
{/* Section: Interaction Mode */}
<div className="p-1">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Mode</span>
<div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-white/5 p-1 rounded-lg">
<button
onClick={() => setIsSelectionMode(false)}
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${!isSelectionMode
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
<Hand className="w-4 h-4 mb-1" />
<span className="text-[9px] font-bold">Pan</span>
</button>
<button
onClick={() => setIsSelectionMode(true)}
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${isSelectionMode
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
<MousePointer2 className="w-4 h-4 mb-1" />
<span className="text-[9px] font-bold">Select</span>
</button>
{/* Section: Interaction Mode */}
<div className="p-1">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Mode</span>
<div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-white/5 p-1 rounded-lg">
<button
onClick={() => setIsSelectionMode(false)}
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${!isSelectionMode
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
<Hand className="w-4 h-4 mb-1" />
<span className="text-[9px] font-bold">Pan</span>
</button>
<button
onClick={() => setIsSelectionMode(true)}
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${isSelectionMode
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
}`}
>
<MousePointer2 className="w-4 h-4 mb-1" />
<span className="text-[9px] font-bold">Select</span>
</button>
</div>
</div>
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
{/* Section: View Controls */}
<div className="p-1">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">View</span>
<div className="flex bg-slate-100 dark:bg-white/5 rounded-lg p-0.5 divide-x divide-slate-200 dark:divide-white/5 border border-slate-200 dark:border-white/5">
<ToolkitButton icon={Minus} onClick={() => zoomOut()} label="Out" />
<ToolkitButton icon={Plus} onClick={() => zoomIn()} label="In" />
<ToolkitButton icon={Maximize} onClick={handleResetView} label="Fit" />
</div>
</div>
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
{/* Section: Layout & Overlays */}
<div className="p-1 space-y-1">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Actions</span>
<MenuButton
icon={Wand2}
label="Auto Layout"
active={false}
onClick={handleAutoLayout}
/>
<MenuButton
icon={edgeStyle === 'curved' ? Spline : Minus}
label={edgeStyle === 'curved' ? 'Edge Style: Curved' : 'Edge Style: Straight'}
iconClass={edgeStyle === 'straight' ? 'rotate-45' : ''}
active={false}
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
/>
<MenuButton
icon={Map}
label="MiniMap Overlay"
active={showMiniMap}
onClick={() => setShowMiniMap(!showMiniMap)}
/>
</div>
</div>
)}
</div>
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
</Panel>
)}
{/* Section: View Controls */}
<div className="p-1">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">View</span>
<div className="flex bg-slate-100 dark:bg-white/5 rounded-lg p-0.5 divide-x divide-slate-200 dark:divide-white/5 border border-slate-200 dark:border-white/5">
<ToolkitButton icon={Minus} onClick={() => zoomOut()} label="Out" />
<ToolkitButton icon={Plus} onClick={() => zoomIn()} label="In" />
<ToolkitButton icon={Maximize} onClick={handleResetView} label="Fit" />
</div>
</div>
{/* Mobile Bottom Action Bar */}
{isMobile && nodes.length > 0 && !focusMode && (
<Panel position="bottom-center" className="!mb-24 z-50">
<div className="flex items-center gap-1 px-2 py-2 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl border border-slate-200 dark:border-white/10 shadow-2xl">
{/* Zoom Out */}
<button
onClick={() => zoomOut()}
className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
title="Zoom Out"
>
<Minus className="w-5 h-5" />
</button>
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
{/* Zoom In */}
<button
onClick={() => zoomIn()}
className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
title="Zoom In"
>
<Plus className="w-5 h-5" />
</button>
{/* Section: Layout & Overlays */}
<div className="p-1 space-y-1">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Actions</span>
{/* Fit View */}
<button
onClick={handleResetView}
className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
title="Fit to View"
>
<Maximize className="w-5 h-5" />
</button>
<MenuButton
icon={Wand2}
label="Auto Layout"
active={false}
onClick={handleAutoLayout}
/>
{/* Divider */}
<div className="w-px h-6 bg-slate-200 dark:bg-white/10 mx-1" />
<MenuButton
icon={edgeStyle === 'curved' ? Spline : Minus}
label={edgeStyle === 'curved' ? 'Edge Style: Curved' : 'Edge Style: Straight'}
iconClass={edgeStyle === 'straight' ? 'rotate-45' : ''}
active={false}
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
/>
{/* Auto Layout */}
<button
onClick={handleAutoLayout}
className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
title="Auto Layout"
>
<Wand2 className="w-5 h-5" />
</button>
<MenuButton
icon={Map}
label="MiniMap Overlay"
active={showMiniMap}
onClick={() => setShowMiniMap(!showMiniMap)}
/>
</div>
</div>
)}
</div>
</Panel>
{/* Toggle Edge Style */}
<button
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
title={edgeStyle === 'curved' ? 'Switch to Straight Edges' : 'Switch to Curved Edges'}
>
{edgeStyle === 'curved' ? <Spline className="w-5 h-5" /> : <Minus className="w-5 h-5 rotate-45" />}
</button>
</div>
</Panel>
)}
{/* MiniMap Container - Bottom Right (Hidden on Mobile) */}
<Panel position="bottom-right" className="!m-4 z-40">
@ -342,8 +399,8 @@ export function FlowCanvas() {
)}
</Panel>
{/* Status Indicator - Bottom Left */}
{nodes.length > 0 && (
{/* Status Indicator - Bottom Left (Hidden on Mobile - shown in header) */}
{nodes.length > 0 && !isMobile && (
<Panel position="bottom-left" className="!m-6">
<div className="flex items-center gap-4 px-4 py-2.5 bg-white/90 dark:bg-surface/90 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-xl shadow-xl">
<div className="flex items-center gap-2">

View file

@ -189,17 +189,17 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<div className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm animate-fade-in" onClick={onClose} />
<div className={`fixed z-[9999] flex flex-col gap-4
${isMobile
? 'left-4 right-4 top-16 max-h-[80vh] rounded-[2rem]'
: 'top-24 right-12 w-96 rounded-[2rem]'
? 'inset-0 rounded-none pt-safe'
: 'top-24 right-12 w-96 max-h-[80vh] rounded-[2rem]'
} floating-glass p-6 titanium-border shadow-2xl overflow-hidden`}
>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20">
<Cpu className="w-5 h-5 text-blue-500" />
<div className={`${isMobile ? 'w-12 h-12' : 'w-10 h-10'} rounded-xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20`}>
<Cpu className={`${isMobile ? 'w-6 h-6' : 'w-5 h-5'} text-blue-500`} />
</div>
<div>
<h3 className="text-sm font-bold text-primary tracking-tight">System Settings</h3>
<h3 className={`${isMobile ? 'text-base' : 'text-sm'} font-bold text-primary tracking-tight`}>System Settings</h3>
<p className="text-[10px] text-tertiary font-medium uppercase tracking-wider">Configuration</p>
</div>
</div>
@ -210,14 +210,17 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<span className="text-[9px] font-bold text-amber-400 uppercase tracking-wider">Lite</span>
</div>
)}
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-full transition-colors group">
<button
onClick={onClose}
className={`${isMobile ? 'w-11 h-11' : 'p-2'} hover:bg-white/5 rounded-full transition-colors group flex items-center justify-center`}
>
<X className="w-5 h-5 text-tertiary group-hover:text-primary transition-colors" />
</button>
</div>
</div>
{/* Scrollable Content Area */}
<div className={`${isMobile ? 'flex-1 overflow-y-auto' : ''} space-y-6`}>
<div className="flex-1 overflow-y-auto space-y-6 pb-4">
{/* Mode Selection */}
<div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5">
<button

View file

@ -1,89 +1,89 @@
import { useState, useEffect } from 'react';
import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react';
const TIPS = [
{
icon: MousePointer2,
title: 'Select & Edit',
text: 'Select any node to inspect its logic and edit metadata.'
},
{
icon: Layout,
title: 'Auto Layout',
text: 'Use "Visual Organizer" to automatically arrange complex flows.'
},
{
icon: Keyboard,
title: 'Quick Actions',
text: 'Press "K" to open the command palette for fast access.'
},
{
icon: Lightbulb,
title: 'Pro Tip',
text: 'Shift + Drag to select multiple nodes at once.'
}
];
export function SmartGuide() {
const [index, setIndex] = useState(0);
const [isPaused, setIsPaused] = useState(false);
useEffect(() => {
if (isPaused) return;
const timer = setInterval(() => {
setIndex(prev => (prev + 1) % TIPS.length);
}, 5000);
return () => clearInterval(timer);
}, [isPaused]);
const Tip = TIPS[index];
const Icon = Tip.icon;
return (
<div
className="p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border border-blue-100 dark:border-blue-500/10 shadow-sm relative overflow-hidden group cursor-pointer"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onClick={() => setIndex(prev => (prev + 1) % TIPS.length)}
>
{/* Progress Bar */}
<div className="absolute top-0 left-0 h-0.5 bg-blue-500/20 w-full">
<div
key={index}
className="h-full bg-blue-500 transition-all duration-[5000ms] ease-linear w-full origin-left"
style={{ animation: isPaused ? 'none' : 'progress 5s linear' }}
/>
</div>
<div className="flex items-start gap-3 relative z-10">
<div className="p-2 rounded-lg bg-white dark:bg-white/5 shadow-sm border border-blue-100 dark:border-white/5 shrink-0">
<Icon className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400">
{Tip.title}
</span>
<div className="flex gap-0.5">
{TIPS.map((_, i) => (
<div
key={i}
className={`w-1 h-1 rounded-full transition-colors ${i === index ? 'bg-blue-500' : 'bg-blue-200 dark:bg-white/10'
}`}
/>
))}
</div>
</div>
<p className="text-[11px] font-medium leading-relaxed text-slate-600 dark:text-slate-400">
{Tip.text}
</p>
</div>
</div>
</div>
);
}
// Add animation keyframes to global styles via style tag if strictly needed,
// but often standard CSS transitions suffice. For the progress bar, specific keyframes
// are better added to index.css or tailored here.
import { useState, useEffect } from 'react';
import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react';
const TIPS = [
{
icon: MousePointer2,
title: 'Select & Edit',
text: 'Select any node to inspect its logic and edit metadata.'
},
{
icon: Layout,
title: 'Auto Layout',
text: 'Use "Visual Organizer" to automatically arrange complex flows.'
},
{
icon: Keyboard,
title: 'Quick Actions',
text: 'Press "K" to open the command palette for fast access.'
},
{
icon: Lightbulb,
title: 'Pro Tip',
text: 'Shift + Drag to select multiple nodes at once.'
}
];
export function SmartGuide() {
const [index, setIndex] = useState(0);
const [isPaused, setIsPaused] = useState(false);
useEffect(() => {
if (isPaused) return;
const timer = setInterval(() => {
setIndex(prev => (prev + 1) % TIPS.length);
}, 5000);
return () => clearInterval(timer);
}, [isPaused]);
const Tip = TIPS[index];
const Icon = Tip.icon;
return (
<div
className="p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border border-blue-100 dark:border-blue-500/10 shadow-sm relative overflow-hidden group cursor-pointer"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
onClick={() => setIndex(prev => (prev + 1) % TIPS.length)}
>
{/* Progress Bar */}
<div className="absolute top-0 left-0 h-0.5 bg-blue-500/20 w-full">
<div
key={index}
className="h-full bg-blue-500 transition-all duration-[5000ms] ease-linear w-full origin-left"
style={{ animation: isPaused ? 'none' : 'progress 5s linear' }}
/>
</div>
<div className="flex items-start gap-3 relative z-10">
<div className="p-2 rounded-lg bg-white dark:bg-white/5 shadow-sm border border-blue-100 dark:border-white/5 shrink-0">
<Icon className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400">
{Tip.title}
</span>
<div className="flex gap-0.5">
{TIPS.map((_, i) => (
<div
key={i}
className={`w-1 h-1 rounded-full transition-colors ${i === index ? 'bg-blue-500' : 'bg-blue-200 dark:bg-white/10'
}`}
/>
))}
</div>
</div>
<p className="text-[11px] font-medium leading-relaxed text-slate-600 dark:text-slate-400">
{Tip.text}
</p>
</div>
</div>
</div>
);
}
// Add animation keyframes to global styles via style tag if strictly needed,
// but often standard CSS transitions suffice. For the progress bar, specific keyframes
// are better added to index.css or tailored here.

View file

@ -1,142 +1,142 @@
import React, { useState } from 'react';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
import { useDiagramStore } from '../store';
import { Sparkles, Wand2, Scan, CheckCircle2, X } from 'lucide-react';
import type { LayoutSuggestion } from '../types/visualOrganization';
export const VisualOrganizerFAB: React.FC = () => {
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
// UI States
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
const handleAIOrganize = async () => {
setStatus('analyzing');
setIsOpen(true);
analyzeLayout();
try {
await new Promise(resolve => setTimeout(resolve, 1500));
const results = await generateSuggestions();
if (results.length > 0) {
setBestSuggestion(results[0]);
setStatus('ready');
} else {
setStatus('idle');
// Could show a generic "No suggestions" toast here
setIsOpen(false);
}
} catch (error) {
console.error(error);
setStatus('idle');
setIsOpen(false);
}
};
const handleApply = () => {
if (!bestSuggestion) return;
setSnapshot({ nodes: [...nodes], edges: [...edges] });
applySuggestion(bestSuggestion);
setStatus('applied');
};
const handleUndo = () => {
if (snapshot) {
setNodes(snapshot.nodes);
setEdges(snapshot.edges);
setSnapshot(null);
setStatus('ready');
}
};
const handleClose = () => {
setIsOpen(false);
if (status === 'applied') {
setStatus('idle');
setSnapshot(null);
setBestSuggestion(null);
}
};
// If closed, show just the FAB
if (!isOpen) {
return (
<button
onClick={handleAIOrganize}
className="w-12 h-12 rounded-full bg-white dark:bg-slate-800 border border-indigo-100 dark:border-indigo-900/30 shadow-lg hover:shadow-indigo-500/20 flex items-center justify-center group transition-all hover:scale-110 active:scale-95"
title="AI Visual Organizer"
>
<Sparkles className="w-5 h-5 text-indigo-500 group-hover:rotate-12 transition-transform" />
</button>
);
}
// Expanded State (Floating Card)
return (
<div className="relative animate-in slide-in-from-bottom-4 fade-in duration-300">
<Card className="w-80 p-0 overflow-hidden shadow-2xl border-indigo-100 dark:border-indigo-900/30">
{/* Header */}
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2">
<Wand2 className="w-4 h-4 text-indigo-500" />
<span className="text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-300">Organizer</span>
</div>
<button onClick={handleClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-white">
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="p-5">
{status === 'analyzing' && (
<div className="flex flex-col items-center py-4 space-y-4">
<div className="relative w-16 h-16">
<div className="absolute inset-0 border-4 border-indigo-100 dark:border-indigo-900/30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-indigo-500 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<Scan className="w-6 h-6 text-indigo-500 animate-pulse" />
</div>
</div>
<p className="text-xs font-medium text-slate-500 animate-pulse">Analyzing structure...</p>
</div>
)}
{status === 'ready' && bestSuggestion && (
<div className="space-y-4">
<div className="text-center">
<CheckCircle2 className="w-8 h-8 text-green-500 mx-auto mb-2" />
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Optimization Ready</h4>
<p className="text-xs text-slate-500 mt-1">{bestSuggestion.description}</p>
</div>
<div className="grid grid-cols-2 gap-2">
<Button size="sm" variant="secondary" onClick={handleClose}>Cancel</Button>
<Button size="sm" onClick={handleApply} className="bg-indigo-600 hover:bg-indigo-500 text-white">Apply</Button>
</div>
</div>
)}
{status === 'applied' && (
<div className="space-y-4">
<div className="text-center">
<Sparkles className="w-8 h-8 text-indigo-500 mx-auto mb-2" />
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Clean & Organized!</h4>
</div>
<div className="grid grid-cols-2 gap-2">
<Button size="sm" variant="secondary" onClick={handleUndo}>Undo</Button>
<Button size="sm" onClick={handleClose} className="bg-slate-900 text-white dark:bg-white dark:text-slate-900">Done</Button>
</div>
</div>
)}
</div>
</Card>
</div>
);
};
import React, { useState } from 'react';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
import { useDiagramStore } from '../store';
import { Sparkles, Wand2, Scan, CheckCircle2, X } from 'lucide-react';
import type { LayoutSuggestion } from '../types/visualOrganization';
export const VisualOrganizerFAB: React.FC = () => {
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
// UI States
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
const handleAIOrganize = async () => {
setStatus('analyzing');
setIsOpen(true);
analyzeLayout();
try {
await new Promise(resolve => setTimeout(resolve, 1500));
const results = await generateSuggestions();
if (results.length > 0) {
setBestSuggestion(results[0]);
setStatus('ready');
} else {
setStatus('idle');
// Could show a generic "No suggestions" toast here
setIsOpen(false);
}
} catch (error) {
console.error(error);
setStatus('idle');
setIsOpen(false);
}
};
const handleApply = () => {
if (!bestSuggestion) return;
setSnapshot({ nodes: [...nodes], edges: [...edges] });
applySuggestion(bestSuggestion);
setStatus('applied');
};
const handleUndo = () => {
if (snapshot) {
setNodes(snapshot.nodes);
setEdges(snapshot.edges);
setSnapshot(null);
setStatus('ready');
}
};
const handleClose = () => {
setIsOpen(false);
if (status === 'applied') {
setStatus('idle');
setSnapshot(null);
setBestSuggestion(null);
}
};
// If closed, show just the FAB
if (!isOpen) {
return (
<button
onClick={handleAIOrganize}
className="w-12 h-12 rounded-full bg-white dark:bg-slate-800 border border-indigo-100 dark:border-indigo-900/30 shadow-lg hover:shadow-indigo-500/20 flex items-center justify-center group transition-all hover:scale-110 active:scale-95"
title="AI Visual Organizer"
>
<Sparkles className="w-5 h-5 text-indigo-500 group-hover:rotate-12 transition-transform" />
</button>
);
}
// Expanded State (Floating Card)
return (
<div className="relative animate-in slide-in-from-bottom-4 fade-in duration-300">
<Card className="w-80 p-0 overflow-hidden shadow-2xl border-indigo-100 dark:border-indigo-900/30">
{/* Header */}
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2">
<Wand2 className="w-4 h-4 text-indigo-500" />
<span className="text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-300">Organizer</span>
</div>
<button onClick={handleClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-white">
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="p-5">
{status === 'analyzing' && (
<div className="flex flex-col items-center py-4 space-y-4">
<div className="relative w-16 h-16">
<div className="absolute inset-0 border-4 border-indigo-100 dark:border-indigo-900/30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-indigo-500 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<Scan className="w-6 h-6 text-indigo-500 animate-pulse" />
</div>
</div>
<p className="text-xs font-medium text-slate-500 animate-pulse">Analyzing structure...</p>
</div>
)}
{status === 'ready' && bestSuggestion && (
<div className="space-y-4">
<div className="text-center">
<CheckCircle2 className="w-8 h-8 text-green-500 mx-auto mb-2" />
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Optimization Ready</h4>
<p className="text-xs text-slate-500 mt-1">{bestSuggestion.description}</p>
</div>
<div className="grid grid-cols-2 gap-2">
<Button size="sm" variant="secondary" onClick={handleClose}>Cancel</Button>
<Button size="sm" onClick={handleApply} className="bg-indigo-600 hover:bg-indigo-500 text-white">Apply</Button>
</div>
</div>
)}
{status === 'applied' && (
<div className="space-y-4">
<div className="text-center">
<Sparkles className="w-8 h-8 text-indigo-500 mx-auto mb-2" />
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Clean & Organized!</h4>
</div>
<div className="grid grid-cols-2 gap-2">
<Button size="sm" variant="secondary" onClick={handleUndo}>Undo</Button>
<Button size="sm" onClick={handleClose} className="bg-slate-900 text-white dark:bg-white dark:text-slate-900">Done</Button>
</div>
</div>
)}
</div>
</Card>
</div>
);
};

View file

@ -1,194 +1,194 @@
import React, { useState } from 'react';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
import { useDiagramStore } from '../store';
import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react';
import type { LayoutSuggestion } from '../types/visualOrganization';
export const VisualOrganizerPanel: React.FC = () => {
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
// UI States
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
// AI Organize Handler
const handleAIOrganize = async () => {
setStatus('analyzing');
// 1. Analyze (Simulate brief delay for effect)
analyzeLayout();
// 2. Generate Suggestions
try {
// Artificial delay for "Scanning" animation effect
await new Promise(resolve => setTimeout(resolve, 1500));
const results = await generateSuggestions();
// Pick best suggestion (or default to first non-current)
if (results.length > 0) {
setBestSuggestion(results[0]);
setStatus('ready');
} else {
// Fallback if no suggestions
setStatus('idle');
}
} catch (error) {
console.error(error);
setStatus('idle');
}
};
const handleApply = () => {
if (!bestSuggestion) return;
// Take snapshot before applying
setSnapshot({ nodes: [...nodes], edges: [...edges] });
applySuggestion(bestSuggestion);
setStatus('applied');
};
const handleUndo = () => {
if (snapshot) {
setNodes(snapshot.nodes);
setEdges(snapshot.edges);
setSnapshot(null);
setStatus('ready');
}
};
const handleReset = () => {
setStatus('idle');
setSnapshot(null);
setBestSuggestion(null);
};
return (
<div className="visual-organizer-panel w-full">
<Card className="p-0 overflow-hidden border-none shadow-none bg-transparent">
{/* IDLE STATE: Main AI Button */}
{status === 'idle' && (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6">
<div className="relative group cursor-pointer" onClick={handleAIOrganize}>
<div className="absolute inset-0 bg-blue-500 rounded-full blur-xl opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-pulse"></div>
<div className="relative w-24 h-24 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-2xl flex items-center justify-center shadow-2xl transform group-hover:scale-105 transition-all duration-300 border border-white/20">
<Sparkles className="w-10 h-10 text-white animate-pulse" />
</div>
<div className="absolute -bottom-2 -right-2 bg-white dark:bg-slate-800 p-2 rounded-full shadow-lg border border-slate-100 dark:border-slate-700">
<Wand2 className="w-4 h-4 text-purple-500" />
</div>
</div>
<div className="space-y-2">
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">AI Visual Organizer</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-[200px] mx-auto leading-relaxed">
Click to instantly analyze and reorganize your flow for maximum clarity.
</p>
</div>
<Button
onClick={handleAIOrganize}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-800 hover:dark:bg-slate-100 px-8 py-6 rounded-xl shadow-xl hover:shadow-2xl transition-all font-bold tracking-wide"
>
<Scan className="w-4 h-4 mr-2" />
Start Organization
</Button>
</div>
)}
{/* ANALYZING STATE: Scanning Animation */}
{status === 'analyzing' && (
<div className="flex flex-col items-center justify-center p-12 text-center space-y-6 animate-in fade-in zoom-in duration-300">
<div className="relative w-20 h-20">
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-blue-500 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<Scan className="w-8 h-8 text-blue-500 animate-pulse" />
</div>
</div>
<h3 className="text-lg font-bold text-blue-600 dark:text-blue-400 animate-pulse">
Analyzing Layout Logic...
</h3>
</div>
)}
{/* READY STATE: Suggestion Found */}
{status === 'ready' && bestSuggestion && (
<div className="flex flex-col p-6 space-y-6 animate-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center gap-3 text-green-600 dark:text-green-400 mb-2">
<CheckCircle2 className="w-6 h-6" />
<span className="font-bold text-lg">Optimization Found!</span>
</div>
<div className="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10">
<div className="flex items-center gap-3 mb-2">
<Layout className="w-5 h-5 text-indigo-500" />
<h4 className="font-bold">{bestSuggestion.title}</h4>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
{bestSuggestion.description}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Button
onClick={handleReset}
variant="secondary"
className="h-12"
>
Cancel
</Button>
<Button
onClick={handleApply}
className="h-12 bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg hover:shadow-indigo-500/25"
>
<Wand2 className="w-4 h-4 mr-2" />
Apply Magic
</Button>
</div>
</div>
)}
{/* APPLIED STATE: Success & Undo */}
{status === 'applied' && (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6 animate-in zoom-in duration-300">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-2">
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-2">Beautifully Organized!</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
Your graph has been transformed.
</p>
</div>
<div className="flex gap-3 w-full">
<Button
onClick={handleUndo}
variant="secondary"
className="flex-1 h-12 border-slate-200 dark:border-white/10 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/10 dark:hover:text-red-400 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
Undo
</Button>
<Button
onClick={handleReset} // Goes back to idle
className="flex-1 h-12"
>
Done
</Button>
</div>
</div>
)}
</Card>
</div>
);
};
import React, { useState } from 'react';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
import { useDiagramStore } from '../store';
import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react';
import type { LayoutSuggestion } from '../types/visualOrganization';
export const VisualOrganizerPanel: React.FC = () => {
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
// UI States
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
// AI Organize Handler
const handleAIOrganize = async () => {
setStatus('analyzing');
// 1. Analyze (Simulate brief delay for effect)
analyzeLayout();
// 2. Generate Suggestions
try {
// Artificial delay for "Scanning" animation effect
await new Promise(resolve => setTimeout(resolve, 1500));
const results = await generateSuggestions();
// Pick best suggestion (or default to first non-current)
if (results.length > 0) {
setBestSuggestion(results[0]);
setStatus('ready');
} else {
// Fallback if no suggestions
setStatus('idle');
}
} catch (error) {
console.error(error);
setStatus('idle');
}
};
const handleApply = () => {
if (!bestSuggestion) return;
// Take snapshot before applying
setSnapshot({ nodes: [...nodes], edges: [...edges] });
applySuggestion(bestSuggestion);
setStatus('applied');
};
const handleUndo = () => {
if (snapshot) {
setNodes(snapshot.nodes);
setEdges(snapshot.edges);
setSnapshot(null);
setStatus('ready');
}
};
const handleReset = () => {
setStatus('idle');
setSnapshot(null);
setBestSuggestion(null);
};
return (
<div className="visual-organizer-panel w-full">
<Card className="p-0 overflow-hidden border-none shadow-none bg-transparent">
{/* IDLE STATE: Main AI Button */}
{status === 'idle' && (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6">
<div className="relative group cursor-pointer" onClick={handleAIOrganize}>
<div className="absolute inset-0 bg-blue-500 rounded-full blur-xl opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-pulse"></div>
<div className="relative w-24 h-24 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-2xl flex items-center justify-center shadow-2xl transform group-hover:scale-105 transition-all duration-300 border border-white/20">
<Sparkles className="w-10 h-10 text-white animate-pulse" />
</div>
<div className="absolute -bottom-2 -right-2 bg-white dark:bg-slate-800 p-2 rounded-full shadow-lg border border-slate-100 dark:border-slate-700">
<Wand2 className="w-4 h-4 text-purple-500" />
</div>
</div>
<div className="space-y-2">
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">AI Visual Organizer</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-[200px] mx-auto leading-relaxed">
Click to instantly analyze and reorganize your flow for maximum clarity.
</p>
</div>
<Button
onClick={handleAIOrganize}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-800 hover:dark:bg-slate-100 px-8 py-6 rounded-xl shadow-xl hover:shadow-2xl transition-all font-bold tracking-wide"
>
<Scan className="w-4 h-4 mr-2" />
Start Organization
</Button>
</div>
)}
{/* ANALYZING STATE: Scanning Animation */}
{status === 'analyzing' && (
<div className="flex flex-col items-center justify-center p-12 text-center space-y-6 animate-in fade-in zoom-in duration-300">
<div className="relative w-20 h-20">
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-blue-500 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<Scan className="w-8 h-8 text-blue-500 animate-pulse" />
</div>
</div>
<h3 className="text-lg font-bold text-blue-600 dark:text-blue-400 animate-pulse">
Analyzing Layout Logic...
</h3>
</div>
)}
{/* READY STATE: Suggestion Found */}
{status === 'ready' && bestSuggestion && (
<div className="flex flex-col p-6 space-y-6 animate-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center gap-3 text-green-600 dark:text-green-400 mb-2">
<CheckCircle2 className="w-6 h-6" />
<span className="font-bold text-lg">Optimization Found!</span>
</div>
<div className="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10">
<div className="flex items-center gap-3 mb-2">
<Layout className="w-5 h-5 text-indigo-500" />
<h4 className="font-bold">{bestSuggestion.title}</h4>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
{bestSuggestion.description}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Button
onClick={handleReset}
variant="secondary"
className="h-12"
>
Cancel
</Button>
<Button
onClick={handleApply}
className="h-12 bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg hover:shadow-indigo-500/25"
>
<Wand2 className="w-4 h-4 mr-2" />
Apply Magic
</Button>
</div>
</div>
)}
{/* APPLIED STATE: Success & Undo */}
{status === 'applied' && (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6 animate-in zoom-in duration-300">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-2">
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-2">Beautifully Organized!</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
Your graph has been transformed.
</p>
</div>
<div className="flex gap-3 w-full">
<Button
onClick={handleUndo}
variant="secondary"
className="flex-1 h-12 border-slate-200 dark:border-white/10 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/10 dark:hover:text-red-400 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
Undo
</Button>
<Button
onClick={handleReset} // Goes back to idle
className="flex-1 h-12"
>
Done
</Button>
</div>
</div>
)}
</Card>
</div>
);
};

View file

@ -1,7 +1,8 @@
import { Link } from 'react-router-dom';
import {
Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save,
ChevronDown, FileCode, ImageIcon, FileText, Frame, Cloud, Server, Cpu, RotateCcw, RotateCw
ChevronDown, FileCode, ImageIcon, FileText, Frame, Cloud, Server, Cpu, RotateCcw, RotateCw,
Menu, X, Home
} from 'lucide-react';
import { useStore } from 'zustand';
import { useFlowStore, useDiagramStore } from '../../store';
@ -13,6 +14,49 @@ import {
import { useState } from 'react';
import { SettingsModal } from '../Settings';
// Mobile Menu Item Component - extracted outside to avoid hook issues
interface MobileMenuItemProps {
icon: any;
label: string;
onClick: () => void;
active?: boolean;
disabled?: boolean;
variant?: 'default' | 'primary' | 'success';
iconColor?: string;
}
function MobileMenuItem({
icon: Icon,
label,
onClick,
active = false,
disabled = false,
variant = 'default',
iconColor = ''
}: MobileMenuItemProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`
w-full min-h-[44px] flex items-center gap-4 px-4 py-3 rounded-xl transition-all
${disabled ? 'opacity-30 cursor-not-allowed' : 'active:scale-[0.98]'}
${active
? 'bg-blue-50 dark:bg-blue-500/15 text-blue-600 dark:text-blue-400'
: variant === 'primary'
? 'bg-blue-600 text-white hover:bg-blue-500'
: variant === 'success'
? 'bg-emerald-50 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/5'}
`}
>
<Icon className={`w-5 h-5 flex-shrink-0 ${iconColor || (active ? 'text-blue-500' : variant === 'primary' ? 'text-white' : 'text-slate-400 dark:text-slate-500')}`} />
<span className="text-sm font-semibold flex-1 text-left">{label}</span>
{active && <div className="w-2 h-2 rounded-full bg-blue-500" />}
</button>
);
}
export function EditorHeader() {
const {
nodes, edges, leftPanelOpen, setLeftPanelOpen,
@ -24,11 +68,17 @@ export function EditorHeader() {
} = useFlowStore();
const { isMobile } = useMobileDetect();
// Temporal state hooks - MUST be called unconditionally at top level
const canUndo = useStore(useDiagramStore.temporal, (state: any) => state.pastStates.length > 0);
const canRedo = useStore(useDiagramStore.temporal, (state: any) => state.futureStates.length > 0);
const [showSettings, setShowSettings] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [showExportMenu, setShowExportMenu] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const handleSave = () => {
setShowMobileMenu(false);
const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`);
if (name) {
setSaveStatus('saving');
@ -44,6 +94,7 @@ export function EditorHeader() {
const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code' | 'json') => {
setShowExportMenu(false);
setShowMobileMenu(false);
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
try {
@ -72,6 +123,260 @@ export function EditorHeader() {
}
};
const handleOpenSettings = () => {
setShowMobileMenu(false);
setShowSettings(true);
};
// MOBILE HEADER
if (isMobile) {
return (
<>
<header className="h-14 px-4 flex items-center justify-between z-[60] border-b border-black/5 dark:border-white/10 bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl">
{/* Left: Logo + Back */}
<div className="flex items-center gap-3">
<Link
to="/"
className="w-11 h-11 flex items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800 active:scale-95 transition-transform"
title="Home"
>
<Home className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</Link>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<Zap className="w-3.5 h-3.5 text-white fill-white/20" />
</div>
<span className="text-sm font-bold text-slate-800 dark:text-white">SysVis</span>
</div>
</div>
{/* Center: Node count (if any) */}
{nodes.length > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{nodes.filter(n => n.type !== 'group').length} nodes
</span>
</div>
)}
{/* Right: Menu Button */}
<button
onClick={() => setShowMobileMenu(true)}
className="w-11 h-11 flex items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800 active:scale-95 transition-transform"
title="Menu"
>
<Menu className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</button>
</header>
{/* Mobile Full-Screen Menu */}
{showMobileMenu && (
<div className="fixed inset-0 z-[200] animate-fade-in">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowMobileMenu(false)}
/>
{/* Menu Panel - slides from right */}
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-sm bg-white dark:bg-slate-900 shadow-2xl animate-slide-in-right flex flex-col">
{/* Menu Header */}
<div className="h-16 px-4 flex items-center justify-between border-b border-slate-200 dark:border-white/10 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center">
<Zap className="w-4 h-4 text-white" />
</div>
<span className="text-base font-bold text-slate-800 dark:text-white">Menu</span>
</div>
<button
onClick={() => setShowMobileMenu(false)}
className="w-11 h-11 flex items-center justify-center rounded-xl hover:bg-slate-100 dark:hover:bg-white/5 active:scale-95 transition-all"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Menu Content - Scrollable */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* AI Mode Indicator */}
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-white/5">
{aiMode === 'offline' ? (
<>
<Server className="w-5 h-5 text-blue-500" />
<div>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">Local Mode</span>
<p className="text-xs text-slate-500 dark:text-slate-400">Using Ollama</p>
</div>
</>
) : aiMode === 'browser' ? (
<>
<Cpu className="w-5 h-5 text-purple-500" />
<div>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">Browser Mode</span>
<p className="text-xs text-slate-500 dark:text-slate-400">WebLLM In-Device</p>
</div>
</>
) : (
<>
<Cloud className="w-5 h-5 text-emerald-500" />
<div>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">Cloud Mode</span>
<p className="text-xs text-slate-500 dark:text-slate-400">
{onlineProvider === 'openai' ? 'OpenAI' : onlineProvider === 'gemini' ? 'Gemini' : 'Cloud AI'}
</p>
</div>
</>
)}
</div>
{/* History Section */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2 px-1">History</h3>
<div className="flex gap-2">
<button
onClick={() => {
useDiagramStore.temporal.getState().undo();
setShowMobileMenu(false);
}}
disabled={!canUndo}
className="flex-1 min-h-[44px] flex items-center justify-center gap-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 active:scale-95 transition-all disabled:opacity-30"
>
<RotateCcw className="w-5 h-5" />
<span className="text-sm font-semibold">Undo</span>
</button>
<button
onClick={() => {
useDiagramStore.temporal.getState().redo();
setShowMobileMenu(false);
}}
disabled={!canRedo}
className="flex-1 min-h-[44px] flex items-center justify-center gap-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 active:scale-95 transition-all disabled:opacity-30"
>
<RotateCw className="w-5 h-5" />
<span className="text-sm font-semibold">Redo</span>
</button>
</div>
</div>
{/* View Section */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2 px-1">View</h3>
<div className="space-y-1">
<MobileMenuItem
icon={focusMode ? Minimize2 : Maximize2}
label={focusMode ? 'Exit Focus Mode' : 'Focus Mode'}
onClick={() => {
setFocusMode(!focusMode);
setShowMobileMenu(false);
}}
active={focusMode}
/>
<MobileMenuItem
icon={theme === 'light' ? Moon : Sun}
label={theme === 'light' ? 'Dark Theme' : 'Light Theme'}
onClick={() => {
toggleTheme();
setShowMobileMenu(false);
}}
/>
</div>
</div>
{/* Actions Section */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2 px-1">Actions</h3>
<div className="space-y-1">
<MobileMenuItem
icon={Save}
label={saveStatus === 'saving' ? 'Saving...' : saveStatus === 'saved' ? 'Saved!' : 'Save Diagram'}
onClick={handleSave}
disabled={nodes.length === 0 || saveStatus === 'saving'}
variant={saveStatus === 'saved' ? 'success' : 'default'}
/>
<MobileMenuItem
icon={Settings}
label="Settings"
onClick={handleOpenSettings}
/>
</div>
</div>
{/* Export Section */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2 px-1">Export</h3>
<div className="space-y-1">
<MobileMenuItem
icon={FileCode}
label="Interactive Code"
onClick={() => handleExport('code')}
disabled={nodes.length === 0}
iconColor="text-blue-500"
/>
<MobileMenuItem
icon={FileCode}
label="JSON Data"
onClick={() => handleExport('json')}
disabled={nodes.length === 0}
iconColor="text-purple-500"
/>
<MobileMenuItem
icon={ImageIcon}
label="PNG Image"
onClick={() => handleExport('png')}
disabled={nodes.length === 0}
iconColor="text-indigo-500"
/>
<MobileMenuItem
icon={ImageIcon}
label="JPG Image"
onClick={() => handleExport('jpg')}
disabled={nodes.length === 0}
iconColor="text-amber-500"
/>
<MobileMenuItem
icon={Frame}
label="SVG Vector"
onClick={() => handleExport('svg')}
disabled={nodes.length === 0}
iconColor="text-emerald-500"
/>
<MobileMenuItem
icon={FileText}
label="Logic Summary"
onClick={() => handleExport('txt')}
disabled={nodes.length === 0}
iconColor="text-slate-400"
/>
</div>
</div>
</div>
{/* Menu Footer */}
<div className="p-4 border-t border-slate-200 dark:border-white/10 flex-shrink-0">
<Link
to="/"
className="w-full min-h-[44px] flex items-center justify-center gap-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 active:scale-95 transition-all"
>
<Home className="w-5 h-5" />
<span className="text-sm font-semibold">Back to Dashboard</span>
</Link>
</div>
</div>
</div>
)}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
/>
</>
);
}
// DESKTOP HEADER (unchanged)
return (
<header className="h-14 px-6 flex items-center justify-between z-[60] border-b border-black/5 dark:border-white/10 bg-white/80 dark:bg-slate-900/90 backdrop-blur-md">
<div className="flex items-center gap-4">
@ -88,7 +393,7 @@ export function EditorHeader() {
<div className="flex items-center gap-1">
<button
onClick={() => useDiagramStore.temporal.getState().undo()}
disabled={!useStore(useDiagramStore.temporal, (state: any) => state.pastStates.length > 0)}
disabled={!canUndo}
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
title="Undo"
>
@ -96,7 +401,7 @@ export function EditorHeader() {
</button>
<button
onClick={() => useDiagramStore.temporal.getState().redo()}
disabled={!useStore(useDiagramStore.temporal, (state: any) => state.futureStates.length > 0)}
disabled={!canRedo}
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
title="Redo"
>
@ -104,60 +409,54 @@ export function EditorHeader() {
</button>
</div>
{!isMobile && (
<>
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5">
<button
onClick={() => setLeftPanelOpen(!leftPanelOpen)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`}
title="Toggle Input Panel"
>
<Edit3 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Input</span>
</button>
<button
onClick={() => setRightPanelOpen(!rightPanelOpen)}
disabled={nodes.length === 0}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
: (rightPanelOpen
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
title="Toggle Code Panel"
>
<Code className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Code</span>
</button>
</div>
</>
)}
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5">
<button
onClick={() => setLeftPanelOpen(!leftPanelOpen)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`}
title="Toggle Input Panel"
>
<Edit3 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Input</span>
</button>
<button
onClick={() => setRightPanelOpen(!rightPanelOpen)}
disabled={nodes.length === 0}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
: (rightPanelOpen
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
title="Toggle Code Panel"
>
<Code className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Code</span>
</button>
</div>
</div>
<div className="flex items-center gap-2">
{/* AI Mode Status - Desktop Only */}
{!isMobile && (
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-white/5 mr-2">
{aiMode === 'offline' ? (
<>
<Server className="w-3 h-3 text-blue-500" />
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span>
</>
) : aiMode === 'browser' ? (
<>
<Cpu className="w-3 h-3 text-purple-500" />
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span>
</>
) : (
<>
<Cloud className="w-3 h-3 text-emerald-500" />
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span>
</>
)}
</div>
)}
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-white/5 mr-2">
{aiMode === 'offline' ? (
<>
<Server className="w-3 h-3 text-blue-500" />
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span>
</>
) : aiMode === 'browser' ? (
<>
<Cpu className="w-3 h-3 text-purple-500" />
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span>
</>
) : (
<>
<Cloud className="w-3 h-3 text-emerald-500" />
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span>
</>
)}
</div>
<button
onClick={() => setFocusMode(!focusMode)}

View file

@ -1,61 +1,61 @@
import { Loader2, Zap } from 'lucide-react';
interface EditorToolbarProps {
handleGenerate: () => void;
isLoading: boolean;
hasCode: boolean;
}
import { usePluginStore } from '../../store/pluginStore';
export function EditorToolbar({
handleGenerate,
isLoading,
hasCode,
}: EditorToolbarProps) {
const { toolbarItems } = usePluginStore();
return (
<div className="flex items-center justify-between w-full pt-2 px-2">
{/* Center: Insert Tools */}
<div className="flex items-center gap-6 absolute left-1/2 -translate-x-1/2">
{/* Plugin Items */}
{toolbarItems.map((item) => (
<button
key={item.id}
onClick={item.onClick}
disabled={isLoading}
className="flex items-center gap-2 group px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-white/5 transition-all disabled:opacity-50"
title={item.tooltip || item.label}
>
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-blue-500 transition-colors" />
<span className="text-xs font-black uppercase tracking-widest text-slate-300 group-hover:text-slate-200 transition-colors">{item.label}</span>
</button>
))}
</div>
{/* Right: Actions */}
<div className="flex items-center">
<button
onClick={handleGenerate}
disabled={!hasCode || isLoading}
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-[#8b5cf6] to-[#a78bfa] hover:from-[#7c3aed] hover:to-[#8b5cf6] text-white shadow-lg shadow-purple-900/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin text-white/90" />
) : (
<>
<Zap className="w-4 h-4 fill-white" />
<span className="text-xs font-black uppercase tracking-widest">Visualize</span>
</>
)}
</button>
</div>
</div>
);
}
import { Loader2, Zap } from 'lucide-react';
interface EditorToolbarProps {
handleGenerate: () => void;
isLoading: boolean;
hasCode: boolean;
hasCode: boolean;
}
import { usePluginStore } from '../../store/pluginStore';
export function EditorToolbar({
handleGenerate,
isLoading,
hasCode,
}: EditorToolbarProps) {
const { toolbarItems } = usePluginStore();
return (
<div className="flex items-center justify-between w-full pt-2 px-2">
{/* Center: Insert Tools */}
<div className="flex items-center gap-6 absolute left-1/2 -translate-x-1/2">
{/* Plugin Items */}
{toolbarItems.map((item) => (
<button
key={item.id}
onClick={item.onClick}
disabled={isLoading}
className="flex items-center gap-2 group px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-white/5 transition-all disabled:opacity-50"
title={item.tooltip || item.label}
>
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-blue-500 transition-colors" />
<span className="text-xs font-black uppercase tracking-widest text-slate-300 group-hover:text-slate-200 transition-colors">{item.label}</span>
</button>
))}
</div>
{/* Right: Actions */}
<div className="flex items-center">
<button
onClick={handleGenerate}
disabled={!hasCode || isLoading}
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-[#8b5cf6] to-[#a78bfa] hover:from-[#7c3aed] hover:to-[#8b5cf6] text-white shadow-lg shadow-purple-900/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin text-white/90" />
) : (
<>
<Zap className="w-4 h-4 fill-white" />
<span className="text-xs font-black uppercase tracking-widest">Visualize</span>
</>
)}
</button>
</div>
</div>
);
}

View file

@ -1,107 +1,107 @@
import Editor, { type OnMount } from '@monaco-editor/react';
import { AlertCircle, Trash2 } from 'lucide-react';
interface MonacoWrapperProps {
code: string;
onChange: (value: string | undefined) => void;
onMount: OnMount;
theme: 'light' | 'dark';
syntaxErrors: { line: number; message: string }[];
setCode: (code: string) => void;
}
export function MonacoWrapper({
code,
onChange,
onMount,
theme,
syntaxErrors,
setCode
}: MonacoWrapperProps) {
// monacoRef removed as it was unused
// Define themes once on mount or when theme prop changes appropriately
// For simplicity, we can do it inside a useEffect here if we have access to monaco instance,
// but usually it's better to do it once.
// However, since we need monaco instance, we'll assume the parent component or this component handles it via onMount/useEffect.
// Actually, let's keep the theme definition inside the component for now or rely on the parent to pass the ref if needed.
// But better yet, let's expose specific theme logic or keep it self-contained if possible.
// The original code defined themes in useEffect when monacoRef was available.
return (
<div className={`flex-1 rounded-2xl overflow-hidden border relative group shadow-inner transition-colors ${theme === 'dark' ? 'bg-[#0B1221] border-white/5' : 'bg-slate-50 border-slate-200'
}`}>
{/* Internal Badges */}
<div className="absolute top-4 left-4 z-10 pointer-events-none">
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 backdrop-blur-md">
Mermaid
</span>
</div>
<div className="absolute top-4 right-4 z-10">
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-white/5 text-slate-400 border border-white/10 backdrop-blur-md">
Manual
</span>
</div>
<Editor
height="100%"
defaultLanguage="markdown"
// theme prop is controlled by monaco.editor.setTheme
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
padding: { top: 50, bottom: 20 },
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontLigatures: true,
renderLineHighlight: 'all',
scrollbar: {
vertical: 'visible',
horizontal: 'visible',
useShadows: false,
verticalSliderSize: 6,
horizontalSliderSize: 6
},
lineHeight: 1.7,
cursorSmoothCaretAnimation: 'on',
smoothScrolling: true,
contextmenu: false,
fixedOverflowWidgets: true,
wordWrap: 'on',
glyphMargin: true,
}}
value={code}
onChange={onChange}
onMount={onMount}
/>
{/* Floating Action Buttons */}
<div className="absolute top-4 right-24 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20">
<div className="flex items-center gap-2">
<button
onClick={() => setCode('')}
className="p-1.5 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-all backdrop-blur-md shadow-lg"
title="Clear Code"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Syntax Errors */}
{syntaxErrors.length > 0 && (
<div className="absolute bottom-4 left-4 right-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl animate-fade-in">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-bold text-red-400 uppercase tracking-wider">Syntax Error</p>
<p className="text-[11px] text-red-300 mt-1">{syntaxErrors[0].message}</p>
</div>
</div>
</div>
)}
</div>
);
}
import Editor, { type OnMount } from '@monaco-editor/react';
import { AlertCircle, Trash2 } from 'lucide-react';
interface MonacoWrapperProps {
code: string;
onChange: (value: string | undefined) => void;
onMount: OnMount;
theme: 'light' | 'dark';
syntaxErrors: { line: number; message: string }[];
setCode: (code: string) => void;
}
export function MonacoWrapper({
code,
onChange,
onMount,
theme,
syntaxErrors,
setCode
}: MonacoWrapperProps) {
// monacoRef removed as it was unused
// Define themes once on mount or when theme prop changes appropriately
// For simplicity, we can do it inside a useEffect here if we have access to monaco instance,
// but usually it's better to do it once.
// However, since we need monaco instance, we'll assume the parent component or this component handles it via onMount/useEffect.
// Actually, let's keep the theme definition inside the component for now or rely on the parent to pass the ref if needed.
// But better yet, let's expose specific theme logic or keep it self-contained if possible.
// The original code defined themes in useEffect when monacoRef was available.
return (
<div className={`flex-1 rounded-2xl overflow-hidden border relative group shadow-inner transition-colors ${theme === 'dark' ? 'bg-[#0B1221] border-white/5' : 'bg-slate-50 border-slate-200'
}`}>
{/* Internal Badges */}
<div className="absolute top-4 left-4 z-10 pointer-events-none">
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 backdrop-blur-md">
Mermaid
</span>
</div>
<div className="absolute top-4 right-4 z-10">
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-white/5 text-slate-400 border border-white/10 backdrop-blur-md">
Manual
</span>
</div>
<Editor
height="100%"
defaultLanguage="markdown"
// theme prop is controlled by monaco.editor.setTheme
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
padding: { top: 50, bottom: 20 },
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontLigatures: true,
renderLineHighlight: 'all',
scrollbar: {
vertical: 'visible',
horizontal: 'visible',
useShadows: false,
verticalSliderSize: 6,
horizontalSliderSize: 6
},
lineHeight: 1.7,
cursorSmoothCaretAnimation: 'on',
smoothScrolling: true,
contextmenu: false,
fixedOverflowWidgets: true,
wordWrap: 'on',
glyphMargin: true,
}}
value={code}
onChange={onChange}
onMount={onMount}
/>
{/* Floating Action Buttons */}
<div className="absolute top-4 right-24 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20">
<div className="flex items-center gap-2">
<button
onClick={() => setCode('')}
className="p-1.5 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-all backdrop-blur-md shadow-lg"
title="Clear Code"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Syntax Errors */}
{syntaxErrors.length > 0 && (
<div className="absolute bottom-4 left-4 right-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl animate-fade-in">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-bold text-red-400 uppercase tracking-wider">Syntax Error</p>
<p className="text-[11px] text-red-300 mt-1">{syntaxErrors[0].message}</p>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,69 +1,69 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { NODE_STYLES } from './CustomNodes';
import { SHAPE_PATHS } from '../../lib/shapes';
// Map mermaid types to shape names if needed, or use raw type
// Default fallback
const DEFAULT_SHAPE = 'rect';
export const ShapeNode = memo(({ data, selected, type }: any) => {
const rawShape = (data.shape || 'rect').toLowerCase();
// Normalize shape names
let shapeKey = rawShape;
if (rawShape === 'database') shapeKey = 'cyl';
if (rawShape === 'decision') shapeKey = 'diamond';
if (rawShape === 'process') shapeKey = 'rect';
if (rawShape === 'start') shapeKey = 'stadium';
if (rawShape === 'end') shapeKey = 'stadium';
const pathData = SHAPE_PATHS[shapeKey] || SHAPE_PATHS[DEFAULT_SHAPE];
// Style logic similar to StandardNode
// We can infer style from category or just use default
const category = data.category || 'default';
// Mapping categories to styles in CustomNodes is logic we might want to reuse or replicate
// For now let's use a default blue or check if we can import logic.
// Since we are in a separate file, we might not have access to helper 'getNodeStyle'.
// Let's rely on data.category which mermaidParser sets.
// Map parser categories to NODE_STYLES keys
let styleKey = 'default';
if (category === 'filter-db') styleKey = 'data';
else if (category === 'filter-client') styleKey = 'team'; // or platform
else if (category === 'filter-server') styleKey = 'tech';
// Override based on shape
if (shapeKey === 'cyl') styleKey = 'data';
if (shapeKey === 'diamond') styleKey = 'decision';
if (shapeKey === 'stadium' && (type === 'start' || type === 'end')) styleKey = type;
// @ts-ignore
const themeStyle = NODE_STYLES[styleKey] || NODE_STYLES.default;
return (
<div className={`relative group w-32 h-20 flex items-center justify-center transition-all ${selected ? 'drop-shadow-lg scale-105' : ''}`}>
<svg viewBox="0 0 100 70" className={`w-full h-full overflow-visible drop-shadow-sm`}>
<path
d={pathData}
className={`${themeStyle.solid} ${themeStyle.border} fill-current stroke-2 stroke-current opacity-90`}
style={{ fill: 'var(--bg-color)', stroke: 'var(--border-color)' }}
/>
</svg>
{/* Label Overlay */}
<div className="absolute inset-0 flex items-center justify-center p-2 text-center pointer-events-none">
<span className={`text-[10px] font-bold leading-tight ${themeStyle.text}`}>
{data.label}
</span>
</div>
{/* Handles - Positions might need to be dynamic based on shape, but default Top/Bottom/Left/Right usually works */}
<Handle type="target" position={Position.Top} className="!w-2 !h-2 !bg-transparent" />
<Handle type="source" position={Position.Bottom} className="!w-2 !h-2 !bg-transparent" />
<Handle type="source" position={Position.Right} className="!w-2 !h-2 !bg-transparent" />
<Handle type="source" position={Position.Left} className="!w-2 !h-2 !bg-transparent" />
</div>
);
});
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { NODE_STYLES } from './CustomNodes';
import { SHAPE_PATHS } from '../../lib/shapes';
// Map mermaid types to shape names if needed, or use raw type
// Default fallback
const DEFAULT_SHAPE = 'rect';
export const ShapeNode = memo(({ data, selected, type }: any) => {
const rawShape = (data.shape || 'rect').toLowerCase();
// Normalize shape names
let shapeKey = rawShape;
if (rawShape === 'database') shapeKey = 'cyl';
if (rawShape === 'decision') shapeKey = 'diamond';
if (rawShape === 'process') shapeKey = 'rect';
if (rawShape === 'start') shapeKey = 'stadium';
if (rawShape === 'end') shapeKey = 'stadium';
const pathData = SHAPE_PATHS[shapeKey] || SHAPE_PATHS[DEFAULT_SHAPE];
// Style logic similar to StandardNode
// We can infer style from category or just use default
const category = data.category || 'default';
// Mapping categories to styles in CustomNodes is logic we might want to reuse or replicate
// For now let's use a default blue or check if we can import logic.
// Since we are in a separate file, we might not have access to helper 'getNodeStyle'.
// Let's rely on data.category which mermaidParser sets.
// Map parser categories to NODE_STYLES keys
let styleKey = 'default';
if (category === 'filter-db') styleKey = 'data';
else if (category === 'filter-client') styleKey = 'team'; // or platform
else if (category === 'filter-server') styleKey = 'tech';
// Override based on shape
if (shapeKey === 'cyl') styleKey = 'data';
if (shapeKey === 'diamond') styleKey = 'decision';
if (shapeKey === 'stadium' && (type === 'start' || type === 'end')) styleKey = type;
// @ts-ignore
const themeStyle = NODE_STYLES[styleKey] || NODE_STYLES.default;
return (
<div className={`relative group w-32 h-20 flex items-center justify-center transition-all ${selected ? 'drop-shadow-lg scale-105' : ''}`}>
<svg viewBox="0 0 100 70" className={`w-full h-full overflow-visible drop-shadow-sm`}>
<path
d={pathData}
className={`${themeStyle.solid} ${themeStyle.border} fill-current stroke-2 stroke-current opacity-90`}
style={{ fill: 'var(--bg-color)', stroke: 'var(--border-color)' }}
/>
</svg>
{/* Label Overlay */}
<div className="absolute inset-0 flex items-center justify-center p-2 text-center pointer-events-none">
<span className={`text-[10px] font-bold leading-tight ${themeStyle.text}`}>
{data.label}
</span>
</div>
{/* Handles - Positions might need to be dynamic based on shape, but default Top/Bottom/Left/Right usually works */}
<Handle type="target" position={Position.Top} className="!w-2 !h-2 !bg-transparent" />
<Handle type="source" position={Position.Bottom} className="!w-2 !h-2 !bg-transparent" />
<Handle type="source" position={Position.Right} className="!w-2 !h-2 !bg-transparent" />
<Handle type="source" position={Position.Left} className="!w-2 !h-2 !bg-transparent" />
</div>
);
});

View file

@ -1,38 +1,38 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
className = '',
...props
}) => {
const baseStyles = "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
const variants = {
primary: "bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500",
secondary: "bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/10 focus:ring-indigo-500",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
ghost: "bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 focus:ring-gray-500"
};
const sizes = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base"
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
);
};
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
className = '',
...props
}) => {
const baseStyles = "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
const variants = {
primary: "bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500",
secondary: "bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/10 focus:ring-indigo-500",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
ghost: "bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 focus:ring-gray-500"
};
const sizes = {
sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base"
};
return (
<button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
>
{children}
</button>
);
};

View file

@ -1,28 +1,28 @@
import React from 'react';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg';
}
export const Card: React.FC<CardProps> = ({
children,
className = '',
padding = 'md',
...props
}) => {
const paddings = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6'
};
return (
<div
className={`bg-white dark:bg-black/20 backdrop-blur-sm rounded-xl border border-gray-200 dark:border-white/10 shadow-sm ${paddings[padding]} ${className}`}
{...props}
>
{children}
</div>
);
};
import React from 'react';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg';
}
export const Card: React.FC<CardProps> = ({
children,
className = '',
padding = 'md',
...props
}) => {
const paddings = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6'
};
return (
<div
className={`bg-white dark:bg-black/20 backdrop-blur-sm rounded-xl border border-gray-200 dark:border-white/10 shadow-sm ${paddings[padding]} ${className}`}
{...props}
>
{children}
</div>
);
};

View file

@ -1,23 +1,23 @@
export function OrchestratorLoader() {
return (
<div className="flex flex-col items-center justify-center p-8">
<div className="relative w-16 h-16 mb-8">
{/* Glow effect */}
<div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full animate-pulse-slow" />
{/* Rotating Diamond */}
<div className="absolute inset-0 m-auto w-8 h-8 border-[3px] border-blue-500 rounded-lg animate-spin-slow shadow-[0_0_15px_rgba(59,130,246,0.5)] transform rotate-45" />
{/* Inner accent (optional, based on image "feel") */}
<div className="absolute inset-0 m-auto w-3 h-3 bg-blue-400 rounded-full animate-ping opacity-20" />
</div>
<p className="text-[10px] font-black text-blue-500 uppercase tracking-[0.4em] animate-pulse">
Orchestrating logic
</p>
</div>
);
}
export function OrchestratorLoader() {
return (
<div className="flex flex-col items-center justify-center p-8">
<div className="relative w-16 h-16 mb-8">
{/* Glow effect */}
<div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full animate-pulse-slow" />
{/* Rotating Diamond */}
<div className="absolute inset-0 m-auto w-8 h-8 border-[3px] border-blue-500 rounded-lg animate-spin-slow shadow-[0_0_15px_rgba(59,130,246,0.5)] transform rotate-45" />
{/* Inner accent (optional, based on image "feel") */}
<div className="absolute inset-0 m-auto w-3 h-3 bg-blue-400 rounded-full animate-ping opacity-20" />
</div>
<p className="text-[10px] font-black text-blue-500 uppercase tracking-[0.4em] animate-pulse">
Orchestrating logic
</p>
</div>
);
}

View file

@ -1,70 +1,70 @@
import { useEffect, useRef } from 'react';
import { useFlowStore } from '../store';
import { useSearchParams } from 'react-router-dom';
/**
* useDiagramAPI
*
* Enables programmatic control of the diagram editor via:
* 1. URL Parameters: ?code=<base64_mermaid>
* 2. Window Messages: PostMessage for embedding scenarios
*/
export function useDiagramAPI() {
const [searchParams] = useSearchParams();
const { setSourceCode, sourceCode } = useFlowStore();
// Create a ref for sourceCode to access current value in event listener without re-binding
const sourceCodeRef = useRef(sourceCode);
useEffect(() => {
sourceCodeRef.current = sourceCode;
}, [sourceCode]);
// 1. Handle URL Parameters
useEffect(() => {
const codeParam = searchParams.get('code');
if (codeParam) {
try {
// Decode Base64
const decodedCode = atob(codeParam);
setSourceCode(decodedCode);
// Clear param after consumption to avoid re-triggering or cluttering URL
// causing a reload? No, using router API.
// But better to keep it if user wants to share URL.
// Let's decided to keep it for "stateful URL" behavior for now.
// If we want to support "cleaning", we can uncomment below:
// setSearchParams(prev => {
// const next = new URLSearchParams(prev);
// next.delete('code');
// return next;
// });
} catch (e) {
console.error('Failed to decode diagram code from URL:', e);
}
}
}, [searchParams, setSourceCode]);
// 2. Handle Window Messages (for generic iframe control)
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Security check: You might want to check event.origin here in production
const { type, payload } = event.data;
if (type === 'KVGRAPH_LOAD' && payload?.code) {
setSourceCode(payload.code);
}
if (type === 'KVGRAPH_GET_CODE') {
// Reply with current code
event.source?.postMessage({
type: 'KVGRAPH_CODE_RESPONSE',
payload: { code: sourceCodeRef.current }
}, { targetOrigin: event.origin });
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [setSourceCode]);
}
import { useEffect, useRef } from 'react';
import { useFlowStore } from '../store';
import { useSearchParams } from 'react-router-dom';
/**
* useDiagramAPI
*
* Enables programmatic control of the diagram editor via:
* 1. URL Parameters: ?code=<base64_mermaid>
* 2. Window Messages: PostMessage for embedding scenarios
*/
export function useDiagramAPI() {
const [searchParams] = useSearchParams();
const { setSourceCode, sourceCode } = useFlowStore();
// Create a ref for sourceCode to access current value in event listener without re-binding
const sourceCodeRef = useRef(sourceCode);
useEffect(() => {
sourceCodeRef.current = sourceCode;
}, [sourceCode]);
// 1. Handle URL Parameters
useEffect(() => {
const codeParam = searchParams.get('code');
if (codeParam) {
try {
// Decode Base64
const decodedCode = atob(codeParam);
setSourceCode(decodedCode);
// Clear param after consumption to avoid re-triggering or cluttering URL
// causing a reload? No, using router API.
// But better to keep it if user wants to share URL.
// Let's decided to keep it for "stateful URL" behavior for now.
// If we want to support "cleaning", we can uncomment below:
// setSearchParams(prev => {
// const next = new URLSearchParams(prev);
// next.delete('code');
// return next;
// });
} catch (e) {
console.error('Failed to decode diagram code from URL:', e);
}
}
}, [searchParams, setSourceCode]);
// 2. Handle Window Messages (for generic iframe control)
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Security check: You might want to check event.origin here in production
const { type, payload } = event.data;
if (type === 'KVGRAPH_LOAD' && payload?.code) {
setSourceCode(payload.code);
}
if (type === 'KVGRAPH_GET_CODE') {
// Reply with current code
event.source?.postMessage({
type: 'KVGRAPH_CODE_RESPONSE',
payload: { code: sourceCodeRef.current }
}, { targetOrigin: event.origin });
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [setSourceCode]);
}

View file

@ -1,88 +1,88 @@
import { useState, useEffect, useCallback } from 'react';
const MOBILE_BREAKPOINT = 768;
interface MobileDetectResult {
isMobile: boolean;
isTouchDevice: boolean;
prefersReducedMotion: boolean;
viewportWidth: number;
viewportHeight: number;
}
/**
* Hook for detecting mobile devices and touch capabilities.
* Provides reactive updates on viewport changes.
*/
export function useMobileDetect(): MobileDetectResult {
const [state, setState] = useState<MobileDetectResult>(() => ({
isMobile: typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false,
isTouchDevice: typeof window !== 'undefined' ? 'ontouchstart' in window || navigator.maxTouchPoints > 0 : false,
prefersReducedMotion: typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 1024,
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 768,
}));
const handleResize = useCallback(() => {
setState(prev => {
const newIsMobile = window.innerWidth < MOBILE_BREAKPOINT;
// Only update if values changed to prevent unnecessary re-renders
if (
prev.isMobile === newIsMobile &&
prev.viewportWidth === window.innerWidth &&
prev.viewportHeight === window.innerHeight
) {
return prev;
}
return {
...prev,
isMobile: newIsMobile,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
});
}, []);
useEffect(() => {
// Use visualViewport for better mobile keyboard handling
const viewport = window.visualViewport;
const handleViewportResize = () => {
setState(prev => ({
...prev,
viewportHeight: viewport?.height || window.innerHeight,
}));
};
window.addEventListener('resize', handleResize);
viewport?.addEventListener('resize', handleViewportResize);
// Check reduced motion preference changes
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const handleMotionChange = (e: MediaQueryListEvent) => {
setState(prev => ({ ...prev, prefersReducedMotion: e.matches }));
};
motionQuery.addEventListener('change', handleMotionChange);
return () => {
window.removeEventListener('resize', handleResize);
viewport?.removeEventListener('resize', handleViewportResize);
motionQuery.removeEventListener('change', handleMotionChange);
};
}, [handleResize]);
return state;
}
/**
* Utility function for one-time mobile check (non-reactive).
* Use in store initialization or outside React components.
*/
export function checkIsMobile(): boolean {
if (typeof window === 'undefined') return false;
return window.innerWidth < MOBILE_BREAKPOINT || 'ontouchstart' in window;
}
export { MOBILE_BREAKPOINT };
import { useState, useEffect, useCallback } from 'react';
const MOBILE_BREAKPOINT = 768;
interface MobileDetectResult {
isMobile: boolean;
isTouchDevice: boolean;
prefersReducedMotion: boolean;
viewportWidth: number;
viewportHeight: number;
}
/**
* Hook for detecting mobile devices and touch capabilities.
* Provides reactive updates on viewport changes.
*/
export function useMobileDetect(): MobileDetectResult {
const [state, setState] = useState<MobileDetectResult>(() => ({
isMobile: typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false,
isTouchDevice: typeof window !== 'undefined' ? 'ontouchstart' in window || navigator.maxTouchPoints > 0 : false,
prefersReducedMotion: typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 1024,
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 768,
}));
const handleResize = useCallback(() => {
setState(prev => {
const newIsMobile = window.innerWidth < MOBILE_BREAKPOINT;
// Only update if values changed to prevent unnecessary re-renders
if (
prev.isMobile === newIsMobile &&
prev.viewportWidth === window.innerWidth &&
prev.viewportHeight === window.innerHeight
) {
return prev;
}
return {
...prev,
isMobile: newIsMobile,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
});
}, []);
useEffect(() => {
// Use visualViewport for better mobile keyboard handling
const viewport = window.visualViewport;
const handleViewportResize = () => {
setState(prev => ({
...prev,
viewportHeight: viewport?.height || window.innerHeight,
}));
};
window.addEventListener('resize', handleResize);
viewport?.addEventListener('resize', handleViewportResize);
// Check reduced motion preference changes
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const handleMotionChange = (e: MediaQueryListEvent) => {
setState(prev => ({ ...prev, prefersReducedMotion: e.matches }));
};
motionQuery.addEventListener('change', handleMotionChange);
return () => {
window.removeEventListener('resize', handleResize);
viewport?.removeEventListener('resize', handleViewportResize);
motionQuery.removeEventListener('change', handleMotionChange);
};
}, [handleResize]);
return state;
}
/**
* Utility function for one-time mobile check (non-reactive).
* Use in store initialization or outside React components.
*/
export function checkIsMobile(): boolean {
if (typeof window === 'undefined') return false;
return window.innerWidth < MOBILE_BREAKPOINT || 'ontouchstart' in window;
}
export { MOBILE_BREAKPOINT };

View file

@ -1,87 +1,87 @@
import { useCallback, useMemo } from 'react';
import { useDiagramStore } from '../store';
import { useSettingsStore } from '../store/settingsStore';
import { createVisualOrganizer } from '../lib/visualOrganizer';
import { analyzeVisualLayout } from '../lib/aiService';
import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization';
export const useVisualOrganizer = () => {
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
const { aiMode, onlineProvider, apiKey, ollamaUrl, modelName } = useSettingsStore();
const visualOrganizer = useMemo(() => {
return createVisualOrganizer(nodes, edges);
}, [nodes, edges]);
const analyzeLayout = useCallback(() => {
return visualOrganizer.analyzeLayout();
}, [visualOrganizer]);
const generateSuggestions = useCallback(async () => {
// 1. Get algorithmic suggestions
const algorithmicSuggestions = visualOrganizer.generateSuggestions();
// 2. Get AI suggestions if enabled
let aiSuggestions: LayoutSuggestion[] = [];
try {
const analysisResult = visualOrganizer.analyzeLayout();
const aiResult = await analyzeVisualLayout(
nodes,
edges,
analysisResult.metrics,
ollamaUrl,
modelName,
aiMode,
onlineProvider,
apiKey
);
if (aiResult.success && aiResult.analysis?.suggestions) {
aiSuggestions = aiResult.analysis.suggestions.map((s: any) => ({
id: s.id || `ai-${Math.random().toString(36).substr(2, 9)}`,
title: s.title,
description: s.description,
type: s.type || 'style',
impact: s.impact || 'low',
estimatedImprovement: 0,
beforeState: { metrics: analysisResult.metrics, issues: [] }, // AI doesn't calculate this yet
afterState: { metrics: analysisResult.metrics, estimatedIssues: [] },
implementation: {
nodePositions: {}, // AI suggestions might not have positions yet
description: s.fix_strategy // Store strategy for possible future implementation
}
}));
}
} catch (error) {
console.warn('AI visual analysis failed:', error);
}
return [...algorithmicSuggestions, ...aiSuggestions];
}, [visualOrganizer, nodes, edges, aiMode, onlineProvider, apiKey, ollamaUrl, modelName]);
const applySuggestion = useCallback((suggestion: LayoutSuggestion) => {
const { nodes: newNodes, edges: newEdges } = visualOrganizer.applySuggestion(suggestion);
setNodes(newNodes);
setEdges(newEdges);
}, [visualOrganizer, setNodes, setEdges]);
const getPresets = useCallback(() => {
return visualOrganizer.getPresets();
}, [visualOrganizer]);
return {
analyzeLayout,
generateSuggestions,
applySuggestion,
getPresets,
visualOrganizer
};
};
export type LayoutAnalysis = {
metrics: LayoutMetrics;
issues: VisualIssue[];
strengths: string[];
};
export default useVisualOrganizer;
import { useCallback, useMemo } from 'react';
import { useDiagramStore } from '../store';
import { useSettingsStore } from '../store/settingsStore';
import { createVisualOrganizer } from '../lib/visualOrganizer';
import { analyzeVisualLayout } from '../lib/aiService';
import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization';
export const useVisualOrganizer = () => {
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
const { aiMode, onlineProvider, apiKey, ollamaUrl, modelName } = useSettingsStore();
const visualOrganizer = useMemo(() => {
return createVisualOrganizer(nodes, edges);
}, [nodes, edges]);
const analyzeLayout = useCallback(() => {
return visualOrganizer.analyzeLayout();
}, [visualOrganizer]);
const generateSuggestions = useCallback(async () => {
// 1. Get algorithmic suggestions
const algorithmicSuggestions = visualOrganizer.generateSuggestions();
// 2. Get AI suggestions if enabled
let aiSuggestions: LayoutSuggestion[] = [];
try {
const analysisResult = visualOrganizer.analyzeLayout();
const aiResult = await analyzeVisualLayout(
nodes,
edges,
analysisResult.metrics,
ollamaUrl,
modelName,
aiMode,
onlineProvider,
apiKey
);
if (aiResult.success && aiResult.analysis?.suggestions) {
aiSuggestions = aiResult.analysis.suggestions.map((s: any) => ({
id: s.id || `ai-${Math.random().toString(36).substr(2, 9)}`,
title: s.title,
description: s.description,
type: s.type || 'style',
impact: s.impact || 'low',
estimatedImprovement: 0,
beforeState: { metrics: analysisResult.metrics, issues: [] }, // AI doesn't calculate this yet
afterState: { metrics: analysisResult.metrics, estimatedIssues: [] },
implementation: {
nodePositions: {}, // AI suggestions might not have positions yet
description: s.fix_strategy // Store strategy for possible future implementation
}
}));
}
} catch (error) {
console.warn('AI visual analysis failed:', error);
}
return [...algorithmicSuggestions, ...aiSuggestions];
}, [visualOrganizer, nodes, edges, aiMode, onlineProvider, apiKey, ollamaUrl, modelName]);
const applySuggestion = useCallback((suggestion: LayoutSuggestion) => {
const { nodes: newNodes, edges: newEdges } = visualOrganizer.applySuggestion(suggestion);
setNodes(newNodes);
setEdges(newEdges);
}, [visualOrganizer, setNodes, setEdges]);
const getPresets = useCallback(() => {
return visualOrganizer.getPresets();
}, [visualOrganizer]);
return {
analyzeLayout,
generateSuggestions,
applySuggestion,
getPresets,
visualOrganizer
};
};
export type LayoutAnalysis = {
metrics: LayoutMetrics;
issues: VisualIssue[];
strengths: string[];
};
export default useVisualOrganizer;

View file

@ -1,154 +1,154 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { analyzeImage, interpretText } from '../aiService';
// Mock fetch global
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
describe('aiService', () => {
beforeEach(() => {
fetchMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('interpretText', () => {
it('should call online AI when provider is not local', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{
message: {
content: JSON.stringify({
mermaidCode: 'flowchart TD\nA-->B',
metadata: {}
})
}
}]
}),
text: async () => ''
});
const result = await interpretText('test prompt', '', 'gpt-4', 'online', 'openai', 'test-key');
expect(result.success).toBe(true);
expect(result.mermaidCode).toContain('flowchart TD');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('api.openai.com'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer test-key'
})
})
);
});
it('should call local Ollama when provider is local', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
message: {
content: JSON.stringify({
mermaidCode: 'flowchart TD\nA-->B',
metadata: {}
})
}
}),
text: async () => ''
});
// Using 'offline' mode correctly calls local AI
const result = await interpretText('test prompt', 'http://localhost:11434', 'llama3', 'offline', undefined, '');
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:11434/api/chat',
expect.any(Object)
);
});
it('should handle API errors gracefully', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
text: async () => 'Unauthorized error text'
});
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid API Key');
});
it('should fetchWithRetry on transient errors', async () => {
// first call fails with 429
fetchMock.mockResolvedValueOnce({
ok: false,
status: 429,
statusText: 'Too Many Requests',
text: async () => 'Rate limit exceeded'
});
// second call succeeds
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{
message: {
content: JSON.stringify({ mermaidCode: 'flowchart TD', metadata: {} })
}
}]
}),
text: async () => ''
});
// We expect it to retry, so use a short backoff or mock timers if possible.
// Here we rely on the mocked response sequence.
const result = await interpretText('retry test', '', 'gpt-4', 'online', 'openai', 'key');
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('should fallback to error message on non-retryable errors', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
text: async () => 'Unauthorized error text'
});
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid API Key');
});
});
describe('analyzeImage', () => {
it('should successfully parse mermaid code from image analysis', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{
message: {
content: JSON.stringify({
mermaidCode: 'flowchart LR\nX-->Y',
metadata: {}
})
}
}]
}),
text: async () => ''
});
const result = await analyzeImage('base64data', '', 'gpt-4-vision', 'online', 'openai', 'key');
expect(result.success).toBe(true);
expect(result.mermaidCode).toContain('flowchart LR');
});
});
});
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { analyzeImage, interpretText } from '../aiService';
// Mock fetch global
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
describe('aiService', () => {
beforeEach(() => {
fetchMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('interpretText', () => {
it('should call online AI when provider is not local', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{
message: {
content: JSON.stringify({
mermaidCode: 'flowchart TD\nA-->B',
metadata: {}
})
}
}]
}),
text: async () => ''
});
const result = await interpretText('test prompt', '', 'gpt-4', 'online', 'openai', 'test-key');
expect(result.success).toBe(true);
expect(result.mermaidCode).toContain('flowchart TD');
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('api.openai.com'),
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'Authorization': 'Bearer test-key'
})
})
);
});
it('should call local Ollama when provider is local', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
message: {
content: JSON.stringify({
mermaidCode: 'flowchart TD\nA-->B',
metadata: {}
})
}
}),
text: async () => ''
});
// Using 'offline' mode correctly calls local AI
const result = await interpretText('test prompt', 'http://localhost:11434', 'llama3', 'offline', undefined, '');
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:11434/api/chat',
expect.any(Object)
);
});
it('should handle API errors gracefully', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
text: async () => 'Unauthorized error text'
});
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid API Key');
});
it('should fetchWithRetry on transient errors', async () => {
// first call fails with 429
fetchMock.mockResolvedValueOnce({
ok: false,
status: 429,
statusText: 'Too Many Requests',
text: async () => 'Rate limit exceeded'
});
// second call succeeds
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{
message: {
content: JSON.stringify({ mermaidCode: 'flowchart TD', metadata: {} })
}
}]
}),
text: async () => ''
});
// We expect it to retry, so use a short backoff or mock timers if possible.
// Here we rely on the mocked response sequence.
const result = await interpretText('retry test', '', 'gpt-4', 'online', 'openai', 'key');
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('should fallback to error message on non-retryable errors', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
statusText: 'Unauthorized',
text: async () => 'Unauthorized error text'
});
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid API Key');
});
});
describe('analyzeImage', () => {
it('should successfully parse mermaid code from image analysis', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{
message: {
content: JSON.stringify({
mermaidCode: 'flowchart LR\nX-->Y',
metadata: {}
})
}
}]
}),
text: async () => ''
});
const result = await analyzeImage('base64data', '', 'gpt-4-vision', 'online', 'openai', 'key');
expect(result.success).toBe(true);
expect(result.mermaidCode).toContain('flowchart LR');
});
});
});

View file

@ -1,105 +1,105 @@
import { describe, it, expect, vi } from 'vitest';
import { parseMermaid } from '../mermaidParser';
// Mock mermaid library
vi.mock('mermaid', () => {
return {
default: {
initialize: vi.fn(),
parse: vi.fn().mockResolvedValue(true),
mermaidAPI: {
getDiagramFromText: vi.fn().mockImplementation(async (text) => {
// Mock DB response based on text content
const vertices: any = {};
const edges: any[] = [];
const subgraphs: any[] = [];
if (text.includes('Start')) {
vertices['A'] = { id: 'A', text: 'Start', type: 'round' };
vertices['B'] = { id: 'B', text: 'End', type: 'round' };
edges.push({ start: 'A', end: 'B', text: undefined, stroke: 'normal' });
}
if (text.includes('Group1')) {
subgraphs.push({ id: 'Group1', title: 'Group1', nodes: ['A'] });
vertices['A'] = { id: 'A', text: 'Node A', type: 'round' };
vertices['B'] = { id: 'B', text: 'Node B', type: 'round' };
// Edge from A outside to B outside
edges.push({ start: 'A', end: 'B', text: undefined });
}
if (text.includes('Database')) {
vertices['DB'] = { id: 'DB', text: 'Database', type: 'cylinder' };
vertices['S'] = { id: 'S', text: 'Server', type: 'round' };
vertices['C'] = { id: 'C', text: 'Client App', type: 'round' };
}
return {
db: {
getVertices: () => vertices,
getEdges: () => edges,
getSubGraphs: () => subgraphs,
}
};
})
}
}
};
});
describe('mermaidParser', () => {
it('should parse a simple flowchart', async () => {
const code = `
flowchart TD
A[Start] --> B[End]
`;
const { nodes, edges } = await parseMermaid(code);
expect(nodes).toHaveLength(2);
expect(edges).toHaveLength(1);
expect(nodes[0].data.label).toBe('Start');
expect(nodes[1].data.label).toBe('End');
});
it('should handle subgraphs correctly', async () => {
const code = `
flowchart TD
subgraph Group1
A[Node A]
end
A --> B[Node B]
`;
const { nodes } = await parseMermaid(code);
// Should have 3 nodes: Group1, A, B
const groupNode = nodes.find(n => n.type === 'group');
const childNode = nodes.find(n => n.id === 'A');
expect(groupNode).toBeDefined();
// The mock implementation ensures correct parentId association logic is tested
if (groupNode && childNode) {
expect(childNode.parentId).toBe(groupNode.id);
}
});
it('should infer node types from labels', async () => {
const code = `
flowchart TD
DB[(Database)] --> S[Server]
S --> C([Client App])
`;
const { nodes } = await parseMermaid(code);
const dbNode = nodes.find(n => n.data.label === 'Database');
const clientNode = nodes.find(n => n.data.label === 'Client App');
const serverNode = nodes.find(n => n.data.label === 'Server');
expect(dbNode?.type).toBe('database');
expect(clientNode?.type).toBe('client');
expect(serverNode?.type).toBe('server');
});
it('should handle empty or invalid input securely', async () => {
const { nodes, edges } = await parseMermaid('');
expect(nodes).toEqual([]);
expect(edges).toEqual([]);
});
});
import { describe, it, expect, vi } from 'vitest';
import { parseMermaid } from '../mermaidParser';
// Mock mermaid library
vi.mock('mermaid', () => {
return {
default: {
initialize: vi.fn(),
parse: vi.fn().mockResolvedValue(true),
mermaidAPI: {
getDiagramFromText: vi.fn().mockImplementation(async (text) => {
// Mock DB response based on text content
const vertices: any = {};
const edges: any[] = [];
const subgraphs: any[] = [];
if (text.includes('Start')) {
vertices['A'] = { id: 'A', text: 'Start', type: 'round' };
vertices['B'] = { id: 'B', text: 'End', type: 'round' };
edges.push({ start: 'A', end: 'B', text: undefined, stroke: 'normal' });
}
if (text.includes('Group1')) {
subgraphs.push({ id: 'Group1', title: 'Group1', nodes: ['A'] });
vertices['A'] = { id: 'A', text: 'Node A', type: 'round' };
vertices['B'] = { id: 'B', text: 'Node B', type: 'round' };
// Edge from A outside to B outside
edges.push({ start: 'A', end: 'B', text: undefined });
}
if (text.includes('Database')) {
vertices['DB'] = { id: 'DB', text: 'Database', type: 'cylinder' };
vertices['S'] = { id: 'S', text: 'Server', type: 'round' };
vertices['C'] = { id: 'C', text: 'Client App', type: 'round' };
}
return {
db: {
getVertices: () => vertices,
getEdges: () => edges,
getSubGraphs: () => subgraphs,
}
};
})
}
}
};
});
describe('mermaidParser', () => {
it('should parse a simple flowchart', async () => {
const code = `
flowchart TD
A[Start] --> B[End]
`;
const { nodes, edges } = await parseMermaid(code);
expect(nodes).toHaveLength(2);
expect(edges).toHaveLength(1);
expect(nodes[0].data.label).toBe('Start');
expect(nodes[1].data.label).toBe('End');
});
it('should handle subgraphs correctly', async () => {
const code = `
flowchart TD
subgraph Group1
A[Node A]
end
A --> B[Node B]
`;
const { nodes } = await parseMermaid(code);
// Should have 3 nodes: Group1, A, B
const groupNode = nodes.find(n => n.type === 'group');
const childNode = nodes.find(n => n.id === 'A');
expect(groupNode).toBeDefined();
// The mock implementation ensures correct parentId association logic is tested
if (groupNode && childNode) {
expect(childNode.parentId).toBe(groupNode.id);
}
});
it('should infer node types from labels', async () => {
const code = `
flowchart TD
DB[(Database)] --> S[Server]
S --> C([Client App])
`;
const { nodes } = await parseMermaid(code);
const dbNode = nodes.find(n => n.data.label === 'Database');
const clientNode = nodes.find(n => n.data.label === 'Client App');
const serverNode = nodes.find(n => n.data.label === 'Server');
expect(dbNode?.type).toBe('database');
expect(clientNode?.type).toBe('client');
expect(serverNode?.type).toBe('server');
});
it('should handle empty or invalid input securely', async () => {
const { nodes, edges } = await parseMermaid('');
expect(nodes).toEqual([]);
expect(edges).toEqual([]);
});
});

View file

@ -1,10 +1,54 @@
import { toPng, toJpeg, toSvg } from 'html-to-image';
import { type Node, type Edge } from '../store';
// Robust cross-browser file download function using data URLs for better Chrome compatibility
function saveFile(blob: Blob, filename: string): void {
console.log(`[Export] Starting download: ${filename}`);
// Convert blob to data URL for better download attribute support
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
// Create an anchor element
const a = document.createElement('a');
a.style.display = 'none';
a.href = dataUrl;
a.download = filename;
// Append to body (required for Firefox)
document.body.appendChild(a);
// Trigger download
a.click();
// Cleanup after a delay
setTimeout(() => {
document.body.removeChild(a);
}, 100);
console.log(`[Export] Download triggered: ${filename}`);
};
reader.onerror = () => {
console.error(`[Export] Failed to read blob for: ${filename}`);
};
reader.readAsDataURL(blob);
}
// Get the current theme's background color
function getThemeBackgroundColor(): string {
// Check if dark mode is active by looking at the document class
const isDark = document.documentElement.classList.contains('dark');
return isDark ? '#020617' : '#ffffff';
}
export async function exportToPng(element: HTMLElement): Promise<void> {
try {
const backgroundColor = getThemeBackgroundColor();
const dataUrl = await toPng(element, {
backgroundColor: '#020617',
backgroundColor,
quality: 1,
pixelRatio: 3,
filter: (node) => {
@ -15,7 +59,8 @@ export async function exportToPng(element: HTMLElement): Promise<void> {
}
});
downloadFile(dataUrl, `diagram-${getTimestamp()}.png`);
const blob = dataURLtoBlob(dataUrl);
saveFile(blob, `diagram-${getTimestamp()}.png`);
} catch (error) {
console.error('Failed to export PNG:', error);
throw error;
@ -24,9 +69,9 @@ export async function exportToPng(element: HTMLElement): Promise<void> {
export async function exportToJpg(element: HTMLElement): Promise<void> {
try {
const backgroundColor = getThemeBackgroundColor();
const dataUrl = await toJpeg(element, {
backgroundColor: '#020617',
backgroundColor,
quality: 0.95,
pixelRatio: 2,
filter: (node) => {
@ -37,7 +82,8 @@ export async function exportToJpg(element: HTMLElement): Promise<void> {
}
});
downloadFile(dataUrl, `diagram-${getTimestamp()}.jpg`);
const blob = dataURLtoBlob(dataUrl);
saveFile(blob, `diagram-${getTimestamp()}.jpg`);
} catch (error) {
console.error('Failed to export JPG:', error);
throw error;
@ -46,8 +92,9 @@ export async function exportToJpg(element: HTMLElement): Promise<void> {
export async function exportToSvg(element: HTMLElement): Promise<void> {
try {
const backgroundColor = getThemeBackgroundColor();
const dataUrl = await toSvg(element, {
backgroundColor: '#020617',
backgroundColor,
filter: (node) => {
const className = node.className?.toString() || '';
return !className.includes('react-flow__controls') &&
@ -56,7 +103,8 @@ export async function exportToSvg(element: HTMLElement): Promise<void> {
}
});
downloadFile(dataUrl, `diagram-${getTimestamp()}.svg`);
const blob = dataURLtoBlob(dataUrl);
saveFile(blob, `diagram-${getTimestamp()}.svg`);
} catch (error) {
console.error('Failed to export SVG:', error);
throw error;
@ -80,10 +128,8 @@ export function exportToTxt(nodes: Node[], edges: Edge[]): void {
txt += `- ${sourceLabel} -> ${targetLabel} ${e.label ? `(${e.label})` : ''}\n`;
});
const blob = new Blob([txt], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
downloadFile(url, `summary-${getTimestamp()}.txt`);
URL.revokeObjectURL(url);
const blob = new Blob([txt], { type: 'text/plain;charset=utf-8' });
saveFile(blob, `summary-${getTimestamp()}.txt`);
}
export function exportToJson(nodes: Node[], edges: Edge[]): void {
@ -106,11 +152,8 @@ export function exportToJson(nodes: Node[], edges: Edge[]): void {
};
const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
downloadFile(url, `diagram-${getTimestamp()}.json`);
URL.revokeObjectURL(url);
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8' });
saveFile(blob, `diagram-${getTimestamp()}.json`);
}
export function exportToMermaid(nodes: Node[], edges: Edge[]): string {
@ -178,22 +221,24 @@ export function exportToMermaid(nodes: Node[], edges: Edge[]): string {
export function downloadMermaid(nodes: Node[], edges: Edge[]): void {
const mermaid = exportToMermaid(nodes, edges);
const blob = new Blob([mermaid], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
downloadFile(url, `diagram-${getTimestamp()}.mmd`);
URL.revokeObjectURL(url);
const blob = new Blob([mermaid], { type: 'text/plain;charset=utf-8' });
saveFile(blob, `diagram-${getTimestamp()}.mmd`);
}
function getTimestamp(): string {
return new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
}
function downloadFile(url: string, filename: string): void {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Convert data URL to Blob for proper file download
function dataURLtoBlob(dataUrl: string): Blob {
const arr = dataUrl.split(',');
const mimeMatch = arr[0].match(/:(.*?);/);
const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
}

View file

@ -1,424 +1,424 @@
import dagre from 'dagre';
import { type Node, type Edge } from '../store';
// Enhanced constants for better spacing
const NODE_WIDTH = 180;
const NODE_HEIGHT = 60;
const GROUP_PADDING = 60; // Increased padding
const GROUP_TITLE_HEIGHT = 50;
const GROUP_GAP = 120; // Increased gap between groups
const MIN_NODE_SPACING = 80; // Minimum space between nodes
export interface LayoutOptions {
direction: 'TB' | 'LR' | 'BT' | 'RL';
nodeSpacing: number;
rankSpacing: number;
smartOverlapResolution?: boolean; // Enable collision detection
optimizeForReadability?: boolean; // Prioritize clear flow
}
const defaultOptions: LayoutOptions = {
direction: 'TB',
nodeSpacing: 100, // Increased from 60 to prevent overlap
rankSpacing: 150, // Increased from 80 for edge labels
smartOverlapResolution: true,
optimizeForReadability: true,
};
export function getLayoutedElements(
nodes: Node[],
edges: Edge[],
options: Partial<LayoutOptions> = {}
): { nodes: Node[]; edges: Edge[] } {
const opts = { ...defaultOptions, ...options };
const isHorizontal = opts.direction === 'LR' || opts.direction === 'RL';
// Separate group nodes from regular nodes
const groupNodes = nodes.filter(n => n.type === 'group');
const regularNodes = nodes.filter(n => n.type !== 'group');
// If no groups, just layout all nodes flat
if (groupNodes.length === 0) {
return layoutFlatNodes(regularNodes, edges, opts, isHorizontal);
}
// Separate nodes by their parent group
const nodesWithoutParent = regularNodes.filter(n => !n.parentId);
const nodesByGroup = new Map<string, Node[]>();
groupNodes.forEach(g => nodesByGroup.set(g.id, []));
regularNodes.forEach(n => {
if (n.parentId && nodesByGroup.has(n.parentId)) {
nodesByGroup.get(n.parentId)!.push(n);
}
});
// Layout each group internally and calculate their sizes
const groupLayouts = new Map<string, {
width: number;
height: number;
nodes: Node[];
group: Node;
}>();
groupNodes.forEach(group => {
const childNodes = nodesByGroup.get(group.id) || [];
const layout = layoutGroupInternal(group, childNodes, edges, opts, isHorizontal);
groupLayouts.set(group.id, layout);
});
// Stack groups vertically (for TB direction)
const finalNodes: Node[] = [];
let currentY = 60; // Starting Y position
const groupX = 60; // Left margin for groups
// Sort groups by their original order (first defined = first in list)
const sortedGroups = Array.from(groupLayouts.values());
sortedGroups.forEach(({ group, width, height, nodes: childNodes }) => {
// Position the group
finalNodes.push({
...group,
position: { x: groupX, y: currentY },
style: {
...group.style,
width,
height,
},
} as Node);
// Add positioned child nodes
childNodes.forEach(child => finalNodes.push(child));
// Move Y down for next group
currentY += height + GROUP_GAP;
});
// Layout orphan nodes (nodes without parent) to the right of groups
if (nodesWithoutParent.length > 0) {
const maxGroupWidth = Math.max(...sortedGroups.map(g => g.width), 300);
const orphanStartX = groupX + maxGroupWidth + 100;
const orphanLayout = layoutOrphanNodes(nodesWithoutParent, edges, opts, isHorizontal, orphanStartX);
orphanLayout.forEach(node => finalNodes.push(node));
}
return { nodes: finalNodes, edges };
}
// Layout nodes within a single group
function layoutGroupInternal(
group: Node,
childNodes: Node[],
edges: Edge[],
opts: LayoutOptions,
isHorizontal: boolean
): { width: number; height: number; nodes: Node[]; group: Node } {
if (childNodes.length === 0) {
return {
width: 300,
height: 200,
nodes: [],
group
};
}
// Create dagre sub-graph for this group
const subGraph = new dagre.graphlib.Graph();
subGraph.setDefaultEdgeLabel(() => ({}));
subGraph.setGraph({
rankdir: opts.direction,
nodesep: opts.nodeSpacing,
ranksep: opts.rankSpacing,
marginx: 30,
marginy: 30,
});
// Add nodes
childNodes.forEach(node => {
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
subGraph.setNode(node.id, { width: w, height: h });
});
// Add edges within this group
edges.forEach(edge => {
const sourceInGroup = childNodes.some(n => n.id === edge.source);
const targetInGroup = childNodes.some(n => n.id === edge.target);
if (sourceInGroup && targetInGroup) {
subGraph.setEdge(edge.source, edge.target);
}
});
dagre.layout(subGraph);
// Calculate bounds
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const positionedChildren: Node[] = [];
childNodes.forEach(node => {
const pos = subGraph.node(node.id);
if (!pos) return;
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
const x = pos.x - w / 2;
const y = pos.y - h / 2;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + h);
positionedChildren.push({
...node,
position: { x, y },
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
extent: 'parent',
} as Node);
});
// Normalize positions to start at padding
positionedChildren.forEach(child => {
child.position.x = child.position.x - minX + GROUP_PADDING;
child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT;
});
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const groupWidth = contentWidth + GROUP_PADDING * 2;
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT;
return {
width: Math.max(groupWidth, 300),
height: Math.max(groupHeight, 200),
nodes: positionedChildren,
group
};
}
// Layout orphan nodes that don't belong to any group
function layoutOrphanNodes(
nodes: Node[],
edges: Edge[],
opts: LayoutOptions,
isHorizontal: boolean,
startX: number
): Node[] {
const orphanGraph = new dagre.graphlib.Graph();
orphanGraph.setDefaultEdgeLabel(() => ({}));
orphanGraph.setGraph({
rankdir: opts.direction,
nodesep: opts.nodeSpacing,
ranksep: opts.rankSpacing,
marginx: 0,
marginy: 60,
});
nodes.forEach(node => {
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
orphanGraph.setNode(node.id, { width: w, height: h });
});
// Add edges between orphan nodes
edges.forEach(edge => {
const sourceOrphan = nodes.some(n => n.id === edge.source);
const targetOrphan = nodes.some(n => n.id === edge.target);
if (sourceOrphan && targetOrphan) {
orphanGraph.setEdge(edge.source, edge.target);
}
});
dagre.layout(orphanGraph);
return nodes.map(node => {
const pos = orphanGraph.node(node.id);
if (!pos) return node;
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
return {
...node,
position: { x: startX + pos.x - w / 2, y: pos.y - h / 2 },
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
} as Node;
});
}
// Flat layout when there are no groups
function layoutFlatNodes(
nodes: Node[],
edges: Edge[],
opts: LayoutOptions,
isHorizontal: boolean
): { nodes: Node[]; edges: Edge[] } {
const flatGraph = new dagre.graphlib.Graph();
flatGraph.setDefaultEdgeLabel(() => ({}));
flatGraph.setGraph({
rankdir: opts.direction,
nodesep: opts.nodeSpacing,
ranksep: opts.rankSpacing,
marginx: 60,
marginy: 60,
});
nodes.forEach(node => {
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
flatGraph.setNode(node.id, { width: w, height: h });
});
edges.forEach(edge => {
if (nodes.some(n => n.id === edge.source) && nodes.some(n => n.id === edge.target)) {
flatGraph.setEdge(edge.source, edge.target);
}
});
dagre.layout(flatGraph);
const layoutedNodes = nodes.map(node => {
const pos = flatGraph.node(node.id);
if (!pos) return node;
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
return {
...node,
position: { x: pos.x - w / 2, y: pos.y - h / 2 },
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
} as Node;
});
// Apply smart overlap resolution if enabled
const resolvedNodes = opts.smartOverlapResolution
? resolveOverlaps(layoutedNodes)
: layoutedNodes;
return { nodes: resolvedNodes, edges };
}
/**
* Smart collision resolution - iteratively pushes overlapping nodes apart
* Uses a force-directed approach with multiple passes for stability
*/
function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] {
const mutableNodes = nodes.map(n => ({
...n,
position: { ...n.position },
width: getNodeWidth(n),
height: getNodeHeight(n)
}));
for (let iteration = 0; iteration < maxIterations; iteration++) {
let hasOverlap = false;
for (let i = 0; i < mutableNodes.length; i++) {
for (let j = i + 1; j < mutableNodes.length; j++) {
const nodeA = mutableNodes[i];
const nodeB = mutableNodes[j];
// Skip group nodes
if (nodeA.type === 'group' || nodeB.type === 'group') continue;
// Check for overlap with padding
const overlapX = getOverlap(
nodeA.position.x, nodeA.width,
nodeB.position.x, nodeB.width,
MIN_NODE_SPACING
);
const overlapY = getOverlap(
nodeA.position.y, nodeA.height,
nodeB.position.y, nodeB.height,
MIN_NODE_SPACING
);
// If nodes overlap in both axes, push them apart
if (overlapX > 0 && overlapY > 0) {
hasOverlap = true;
// Determine which axis needs less push (more efficient separation)
if (overlapX < overlapY) {
// Push horizontally
const pushX = overlapX / 2 + 5;
if (nodeA.position.x < nodeB.position.x) {
nodeA.position.x -= pushX;
nodeB.position.x += pushX;
} else {
nodeA.position.x += pushX;
nodeB.position.x -= pushX;
}
} else {
// Push vertically
const pushY = overlapY / 2 + 5;
if (nodeA.position.y < nodeB.position.y) {
nodeA.position.y -= pushY;
nodeB.position.y += pushY;
} else {
nodeA.position.y += pushY;
nodeB.position.y -= pushY;
}
}
}
}
}
// If no overlaps detected, we're done
if (!hasOverlap) break;
}
// Ensure no negative positions (shift everything if needed)
let minX = Infinity, minY = Infinity;
mutableNodes.forEach(n => {
if (n.type !== 'group') {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
}
});
const offsetX = minX < 60 ? 60 - minX : 0;
const offsetY = minY < 60 ? 60 - minY : 0;
return mutableNodes.map(n => ({
...n,
position: {
x: n.position.x + offsetX,
y: n.position.y + offsetY
}
}));
}
/**
* Calculate overlap between two rectangles with padding
*/
function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number {
const end1 = pos1 + size1 + padding;
const end2 = pos2 + size2 + padding;
return Math.min(end1 - pos2, end2 - pos1);
}
/**
* Get node width based on type
*/
function getNodeWidth(node: Node): number {
if (node.type === 'decision') return 140;
if (node.style?.width && typeof node.style.width === 'number') return node.style.width;
return NODE_WIDTH;
}
/**
* Get node height based on type
*/
function getNodeHeight(node: Node): number {
if (node.type === 'decision') return 90;
if (node.style?.height && typeof node.style.height === 'number') return node.style.height;
return NODE_HEIGHT;
}
import dagre from 'dagre';
import { type Node, type Edge } from '../store';
// Enhanced constants for better spacing
const NODE_WIDTH = 180;
const NODE_HEIGHT = 60;
const GROUP_PADDING = 60; // Increased padding
const GROUP_TITLE_HEIGHT = 50;
const GROUP_GAP = 120; // Increased gap between groups
const MIN_NODE_SPACING = 80; // Minimum space between nodes
export interface LayoutOptions {
direction: 'TB' | 'LR' | 'BT' | 'RL';
nodeSpacing: number;
rankSpacing: number;
smartOverlapResolution?: boolean; // Enable collision detection
optimizeForReadability?: boolean; // Prioritize clear flow
}
const defaultOptions: LayoutOptions = {
direction: 'TB',
nodeSpacing: 100, // Increased from 60 to prevent overlap
rankSpacing: 150, // Increased from 80 for edge labels
smartOverlapResolution: true,
optimizeForReadability: true,
};
export function getLayoutedElements(
nodes: Node[],
edges: Edge[],
options: Partial<LayoutOptions> = {}
): { nodes: Node[]; edges: Edge[] } {
const opts = { ...defaultOptions, ...options };
const isHorizontal = opts.direction === 'LR' || opts.direction === 'RL';
// Separate group nodes from regular nodes
const groupNodes = nodes.filter(n => n.type === 'group');
const regularNodes = nodes.filter(n => n.type !== 'group');
// If no groups, just layout all nodes flat
if (groupNodes.length === 0) {
return layoutFlatNodes(regularNodes, edges, opts, isHorizontal);
}
// Separate nodes by their parent group
const nodesWithoutParent = regularNodes.filter(n => !n.parentId);
const nodesByGroup = new Map<string, Node[]>();
groupNodes.forEach(g => nodesByGroup.set(g.id, []));
regularNodes.forEach(n => {
if (n.parentId && nodesByGroup.has(n.parentId)) {
nodesByGroup.get(n.parentId)!.push(n);
}
});
// Layout each group internally and calculate their sizes
const groupLayouts = new Map<string, {
width: number;
height: number;
nodes: Node[];
group: Node;
}>();
groupNodes.forEach(group => {
const childNodes = nodesByGroup.get(group.id) || [];
const layout = layoutGroupInternal(group, childNodes, edges, opts, isHorizontal);
groupLayouts.set(group.id, layout);
});
// Stack groups vertically (for TB direction)
const finalNodes: Node[] = [];
let currentY = 60; // Starting Y position
const groupX = 60; // Left margin for groups
// Sort groups by their original order (first defined = first in list)
const sortedGroups = Array.from(groupLayouts.values());
sortedGroups.forEach(({ group, width, height, nodes: childNodes }) => {
// Position the group
finalNodes.push({
...group,
position: { x: groupX, y: currentY },
style: {
...group.style,
width,
height,
},
} as Node);
// Add positioned child nodes
childNodes.forEach(child => finalNodes.push(child));
// Move Y down for next group
currentY += height + GROUP_GAP;
});
// Layout orphan nodes (nodes without parent) to the right of groups
if (nodesWithoutParent.length > 0) {
const maxGroupWidth = Math.max(...sortedGroups.map(g => g.width), 300);
const orphanStartX = groupX + maxGroupWidth + 100;
const orphanLayout = layoutOrphanNodes(nodesWithoutParent, edges, opts, isHorizontal, orphanStartX);
orphanLayout.forEach(node => finalNodes.push(node));
}
return { nodes: finalNodes, edges };
}
// Layout nodes within a single group
function layoutGroupInternal(
group: Node,
childNodes: Node[],
edges: Edge[],
opts: LayoutOptions,
isHorizontal: boolean
): { width: number; height: number; nodes: Node[]; group: Node } {
if (childNodes.length === 0) {
return {
width: 300,
height: 200,
nodes: [],
group
};
}
// Create dagre sub-graph for this group
const subGraph = new dagre.graphlib.Graph();
subGraph.setDefaultEdgeLabel(() => ({}));
subGraph.setGraph({
rankdir: opts.direction,
nodesep: opts.nodeSpacing,
ranksep: opts.rankSpacing,
marginx: 30,
marginy: 30,
});
// Add nodes
childNodes.forEach(node => {
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
subGraph.setNode(node.id, { width: w, height: h });
});
// Add edges within this group
edges.forEach(edge => {
const sourceInGroup = childNodes.some(n => n.id === edge.source);
const targetInGroup = childNodes.some(n => n.id === edge.target);
if (sourceInGroup && targetInGroup) {
subGraph.setEdge(edge.source, edge.target);
}
});
dagre.layout(subGraph);
// Calculate bounds
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const positionedChildren: Node[] = [];
childNodes.forEach(node => {
const pos = subGraph.node(node.id);
if (!pos) return;
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
const x = pos.x - w / 2;
const y = pos.y - h / 2;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + h);
positionedChildren.push({
...node,
position: { x, y },
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
extent: 'parent',
} as Node);
});
// Normalize positions to start at padding
positionedChildren.forEach(child => {
child.position.x = child.position.x - minX + GROUP_PADDING;
child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT;
});
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const groupWidth = contentWidth + GROUP_PADDING * 2;
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT;
return {
width: Math.max(groupWidth, 300),
height: Math.max(groupHeight, 200),
nodes: positionedChildren,
group
};
}
// Layout orphan nodes that don't belong to any group
function layoutOrphanNodes(
nodes: Node[],
edges: Edge[],
opts: LayoutOptions,
isHorizontal: boolean,
startX: number
): Node[] {
const orphanGraph = new dagre.graphlib.Graph();
orphanGraph.setDefaultEdgeLabel(() => ({}));
orphanGraph.setGraph({
rankdir: opts.direction,
nodesep: opts.nodeSpacing,
ranksep: opts.rankSpacing,
marginx: 0,
marginy: 60,
});
nodes.forEach(node => {
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
orphanGraph.setNode(node.id, { width: w, height: h });
});
// Add edges between orphan nodes
edges.forEach(edge => {
const sourceOrphan = nodes.some(n => n.id === edge.source);
const targetOrphan = nodes.some(n => n.id === edge.target);
if (sourceOrphan && targetOrphan) {
orphanGraph.setEdge(edge.source, edge.target);
}
});
dagre.layout(orphanGraph);
return nodes.map(node => {
const pos = orphanGraph.node(node.id);
if (!pos) return node;
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
return {
...node,
position: { x: startX + pos.x - w / 2, y: pos.y - h / 2 },
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
} as Node;
});
}
// Flat layout when there are no groups
function layoutFlatNodes(
nodes: Node[],
edges: Edge[],
opts: LayoutOptions,
isHorizontal: boolean
): { nodes: Node[]; edges: Edge[] } {
const flatGraph = new dagre.graphlib.Graph();
flatGraph.setDefaultEdgeLabel(() => ({}));
flatGraph.setGraph({
rankdir: opts.direction,
nodesep: opts.nodeSpacing,
ranksep: opts.rankSpacing,
marginx: 60,
marginy: 60,
});
nodes.forEach(node => {
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
flatGraph.setNode(node.id, { width: w, height: h });
});
edges.forEach(edge => {
if (nodes.some(n => n.id === edge.source) && nodes.some(n => n.id === edge.target)) {
flatGraph.setEdge(edge.source, edge.target);
}
});
dagre.layout(flatGraph);
const layoutedNodes = nodes.map(node => {
const pos = flatGraph.node(node.id);
if (!pos) return node;
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
return {
...node,
position: { x: pos.x - w / 2, y: pos.y - h / 2 },
targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom',
} as Node;
});
// Apply smart overlap resolution if enabled
const resolvedNodes = opts.smartOverlapResolution
? resolveOverlaps(layoutedNodes)
: layoutedNodes;
return { nodes: resolvedNodes, edges };
}
/**
* Smart collision resolution - iteratively pushes overlapping nodes apart
* Uses a force-directed approach with multiple passes for stability
*/
function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] {
const mutableNodes = nodes.map(n => ({
...n,
position: { ...n.position },
width: getNodeWidth(n),
height: getNodeHeight(n)
}));
for (let iteration = 0; iteration < maxIterations; iteration++) {
let hasOverlap = false;
for (let i = 0; i < mutableNodes.length; i++) {
for (let j = i + 1; j < mutableNodes.length; j++) {
const nodeA = mutableNodes[i];
const nodeB = mutableNodes[j];
// Skip group nodes
if (nodeA.type === 'group' || nodeB.type === 'group') continue;
// Check for overlap with padding
const overlapX = getOverlap(
nodeA.position.x, nodeA.width,
nodeB.position.x, nodeB.width,
MIN_NODE_SPACING
);
const overlapY = getOverlap(
nodeA.position.y, nodeA.height,
nodeB.position.y, nodeB.height,
MIN_NODE_SPACING
);
// If nodes overlap in both axes, push them apart
if (overlapX > 0 && overlapY > 0) {
hasOverlap = true;
// Determine which axis needs less push (more efficient separation)
if (overlapX < overlapY) {
// Push horizontally
const pushX = overlapX / 2 + 5;
if (nodeA.position.x < nodeB.position.x) {
nodeA.position.x -= pushX;
nodeB.position.x += pushX;
} else {
nodeA.position.x += pushX;
nodeB.position.x -= pushX;
}
} else {
// Push vertically
const pushY = overlapY / 2 + 5;
if (nodeA.position.y < nodeB.position.y) {
nodeA.position.y -= pushY;
nodeB.position.y += pushY;
} else {
nodeA.position.y += pushY;
nodeB.position.y -= pushY;
}
}
}
}
}
// If no overlaps detected, we're done
if (!hasOverlap) break;
}
// Ensure no negative positions (shift everything if needed)
let minX = Infinity, minY = Infinity;
mutableNodes.forEach(n => {
if (n.type !== 'group') {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
}
});
const offsetX = minX < 60 ? 60 - minX : 0;
const offsetY = minY < 60 ? 60 - minY : 0;
return mutableNodes.map(n => ({
...n,
position: {
x: n.position.x + offsetX,
y: n.position.y + offsetY
}
}));
}
/**
* Calculate overlap between two rectangles with padding
*/
function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number {
const end1 = pos1 + size1 + padding;
const end2 = pos2 + size2 + padding;
return Math.min(end1 - pos2, end2 - pos1);
}
/**
* Get node width based on type
*/
function getNodeWidth(node: Node): number {
if (node.type === 'decision') return 140;
if (node.style?.width && typeof node.style.width === 'number') return node.style.width;
return NODE_WIDTH;
}
/**
* Get node height based on type
*/
function getNodeHeight(node: Node): number {
if (node.type === 'decision') return 90;
if (node.style?.height && typeof node.style.height === 'number') return node.style.height;
return NODE_HEIGHT;
}

View file

@ -1,30 +1,30 @@
import mermaid from 'mermaid';
// Initialize mermaid
mermaid.initialize({ startOnLoad: false });
const graphDefinition = `
flowchart TD
A[Start] --> B{Is it working?}
B -- Yes --> C[Great!]
B -- No --> D[Debug]
`;
async function testParsing() {
try {
// Validate syntax
const valid = await mermaid.parse(graphDefinition);
console.log("Parse result:", valid);
// Attempt to get data
// Note: mermaid.mermaidAPI.getDiagramFromText is deprecated/internal but often used.
// In v10+, we might need to use other methods.
const type = mermaid.detectType(graphDefinition);
console.log("Detected type:", type);
} catch (error) {
console.error("Parsing failed:", error);
}
}
testParsing();
import mermaid from 'mermaid';
// Initialize mermaid
mermaid.initialize({ startOnLoad: false });
const graphDefinition = `
flowchart TD
A[Start] --> B{Is it working?}
B -- Yes --> C[Great!]
B -- No --> D[Debug]
`;
async function testParsing() {
try {
// Validate syntax
const valid = await mermaid.parse(graphDefinition);
console.log("Parse result:", valid);
// Attempt to get data
// Note: mermaid.mermaidAPI.getDiagramFromText is deprecated/internal but often used.
// In v10+, we might need to use other methods.
const type = mermaid.detectType(graphDefinition);
console.log("Detected type:", type);
} catch (error) {
console.error("Parsing failed:", error);
}
}
testParsing();

View file

@ -1,118 +1,118 @@
export const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata.
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
EXPECTED JSON FORMAT:
{
"mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...",
"metadata": {
"NodeLabel": {
"techStack": ["Technologies used"],
"role": "Specific role (e.g., Load Balancer, Cache, Database)",
"description": "Concise technical responsibility"
}
}
}
ARCHITECTURAL RULES:
1. Use 'flowchart TD' or 'flowchart LR'.
2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD').
3. Labels must match the keys in the 'metadata' object EXACTLY.
4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata.
5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels.
6. Escape double quotes inside the mermaid string correctly.
DIAGRAM QUALITY RULES:
7. NEVER use HTML tags (like <br/>, <b>, etc.) in node labels. Use short, clean text only.
8. NEVER use pipe characters (|) inside node labels - they break parsing. Use commas or spaces instead.
9. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}).
10. Use CYLINDER shapes [(Database)] for data stores.
11. Use ROUNDED shapes (Process) for human/manual tasks.
12. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C.
13. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes).
14. Use subgraphs/swimlanes to group related components by team, role, or domain.
15. Ensure consistent node shapes within each swimlane/subgraph.`;
export const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram.
- Focus on the main data flow (max 5-7 nodes).
- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like <br/>.
- Use diamond {Decision?} for approval/review steps.
- Minimal metadata (role only).`;
export const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram.
- Include all subsystems, queues, workers, and external services.
- Use swimlanes (subgraphs) to group components by role/domain.
- NEVER use HTML tags like <br/> in labels. Keep labels clean and concise.
- Use diamond {Decision?} for approval/review steps.
- Use cylinder [(DB)] for databases, rounded (Task) for human tasks.
- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C.
- Include feedback loops connecting outputs back to inputs where logical.
- Detailed metadata (techStack, role, description).`;
export const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert.
Your task is to analyze the provided Mermaid flowchart code and either:
1. Fix any syntax errors that prevent it from rendering.
2. Improve the logical flow or visual clarity if the syntax is already correct.
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
EXPECTED JSON FORMAT:
{
"mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]",
"explanation": "Briefly explain what was fixed or improved."
}
RULES:
- Maintain the original intent of the diagram.
- Use best practices for Mermaid layout and labeling.
- If the code is already perfect, return it as is but provide a positive explanation.`;
export const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams.
Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity.
Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json.
EXPECTED JSON FORMAT:
{
"analysis": {
"suggestions": [
{
"id": "unique-id",
"title": "Short title",
"description": "Detailed explanation",
"type": "spacing" | "grouping" | "routing" | "hierarchy" | "style",
"impact": "high" | "medium" | "low",
"fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown"
}
],
"summary": {
"critique": "Overall analysis",
"score": 0-100
}
}
}
EXAMPLE RESPONSE:
{
"analysis": {
"suggestions": [
{
"id": "sug-1",
"title": "Group Database Nodes",
"description": "Several database nodes are scattered. Group them for better logical separation.",
"type": "grouping",
"impact": "high",
"fix_strategy": "group_nodes"
}
],
"summary": {
"critique": "The flow is generally good but lacks logical grouping for backend services.",
"score": 75
}
}
}
RULES:
- Focus on readability, flow, and logical grouping.
- Identify if nodes are too cluttered or if the flow is confusing.
- Suggest grouping for nodes that appear related based on their labels.`;
export const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata.
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
EXPECTED JSON FORMAT:
{
"mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...",
"metadata": {
"NodeLabel": {
"techStack": ["Technologies used"],
"role": "Specific role (e.g., Load Balancer, Cache, Database)",
"description": "Concise technical responsibility"
}
}
}
ARCHITECTURAL RULES:
1. Use 'flowchart TD' or 'flowchart LR'.
2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD').
3. Labels must match the keys in the 'metadata' object EXACTLY.
4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata.
5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels.
6. Escape double quotes inside the mermaid string correctly.
DIAGRAM QUALITY RULES:
7. NEVER use HTML tags (like <br/>, <b>, etc.) in node labels. Use short, clean text only.
8. NEVER use pipe characters (|) inside node labels - they break parsing. Use commas or spaces instead.
9. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}).
10. Use CYLINDER shapes [(Database)] for data stores.
11. Use ROUNDED shapes (Process) for human/manual tasks.
12. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C.
13. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes).
14. Use subgraphs/swimlanes to group related components by team, role, or domain.
15. Ensure consistent node shapes within each swimlane/subgraph.`;
export const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram.
- Focus on the main data flow (max 5-7 nodes).
- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like <br/>.
- Use diamond {Decision?} for approval/review steps.
- Minimal metadata (role only).`;
export const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram.
- Include all subsystems, queues, workers, and external services.
- Use swimlanes (subgraphs) to group components by role/domain.
- NEVER use HTML tags like <br/> in labels. Keep labels clean and concise.
- Use diamond {Decision?} for approval/review steps.
- Use cylinder [(DB)] for databases, rounded (Task) for human tasks.
- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C.
- Include feedback loops connecting outputs back to inputs where logical.
- Detailed metadata (techStack, role, description).`;
export const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert.
Your task is to analyze the provided Mermaid flowchart code and either:
1. Fix any syntax errors that prevent it from rendering.
2. Improve the logical flow or visual clarity if the syntax is already correct.
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
EXPECTED JSON FORMAT:
{
"mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]",
"explanation": "Briefly explain what was fixed or improved."
}
RULES:
- Maintain the original intent of the diagram.
- Use best practices for Mermaid layout and labeling.
- If the code is already perfect, return it as is but provide a positive explanation.`;
export const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams.
Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity.
Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json.
EXPECTED JSON FORMAT:
{
"analysis": {
"suggestions": [
{
"id": "unique-id",
"title": "Short title",
"description": "Detailed explanation",
"type": "spacing" | "grouping" | "routing" | "hierarchy" | "style",
"impact": "high" | "medium" | "low",
"fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown"
}
],
"summary": {
"critique": "Overall analysis",
"score": 0-100
}
}
}
EXAMPLE RESPONSE:
{
"analysis": {
"suggestions": [
{
"id": "sug-1",
"title": "Group Database Nodes",
"description": "Several database nodes are scattered. Group them for better logical separation.",
"type": "grouping",
"impact": "high",
"fix_strategy": "group_nodes"
}
],
"summary": {
"critique": "The flow is generally good but lacks logical grouping for backend services.",
"score": 75
}
}
}
RULES:
- Focus on readability, flow, and logical grouping.
- Identify if nodes are too cluttered or if the flow is confusing.
- Suggest grouping for nodes that appear related based on their labels.`;

View file

@ -1,61 +1,61 @@
// SVG Paths for Mermaid Shapes
// Based on Mermaid v11.3.0+ syntax
// Syntax: node@{ shape: shapeName }
export interface ShapeDefinition {
id: string;
label: string;
path: string;
category: 'basic' | 'logic' | 'data' | 'other';
}
export const SHAPE_PATHS: Record<string, string> = {
// Basic
rect: 'M0 4 h100 v60 h-100 z',
rounded: 'M10 4 h80 a10 10 0 0 1 10 10 v40 a10 10 0 0 1 -10 10 h-80 a10 10 0 0 1 -10 -10 v-40 a10 10 0 0 1 10 -10 z',
stadium: 'M20 4 h60 a20 20 0 0 1 20 20 v20 a20 20 0 0 1 -20 20 h-60 a20 20 0 0 1 -20 -20 v-20 a20 20 0 0 1 20 -20 z',
subroutine: 'M10 4 h80 v60 h-80 z M20 4 v60 M80 4 v60',
cyl: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
cylinder: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
// Logic
diamond: 'M50 0 L100 50 L50 100 L0 50 Z',
rhombus: 'M50 0 L100 50 L50 100 L0 50 Z',
decision: 'M50 0 L100 50 L50 100 L0 50 Z',
hexagon: 'M15 4 L85 4 L100 34 L85 64 L15 64 L0 34 Z',
parallelogram: 'M15 4 h85 l-15 60 h-85 z',
trapezoid: 'M15 4 h70 l15 60 h-100 z',
// Documents/Data
doc: 'M0 0 h80 l20 20 v80 h-100 z M80 0 v20 h20',
document: 'M10 4 h80 v45 q0 15 -15 15 h-60 q-15 0 -15 -15 v-45 z M10 4 h80 v45 q-10 10 -40 10 q -30 0 -40 -10',
// Cloud (Approximation)
cloud: 'M20 50 a20 20 0 0 1 20 -30 a25 25 0 0 1 40 10 a20 20 0 0 1 15 35 h-70 z',
// Circles
circle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60',
doublecircle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M50 9 a25 25 0 0 1 0 50 a25 25 0 0 1 0 -50',
// New additions based on research
note: 'M10 4 h80 v60 h-80 z M70 4 v20 h20', // rough note
summary: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M30 24 l40 40 M30 64 l40 -40', // Crossed circle
};
export const SHAPE_DEFINITIONS: ShapeDefinition[] = [
{ id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' },
{ id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' },
{ id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' },
{ id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' },
{ id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' },
{ id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' },
{ id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' },
{ id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' },
{ id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' },
{ id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' },
{ id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' },
{ id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' },
];
// SVG Paths for Mermaid Shapes
// Based on Mermaid v11.3.0+ syntax
// Syntax: node@{ shape: shapeName }
export interface ShapeDefinition {
id: string;
label: string;
path: string;
category: 'basic' | 'logic' | 'data' | 'other';
}
export const SHAPE_PATHS: Record<string, string> = {
// Basic
rect: 'M0 4 h100 v60 h-100 z',
rounded: 'M10 4 h80 a10 10 0 0 1 10 10 v40 a10 10 0 0 1 -10 10 h-80 a10 10 0 0 1 -10 -10 v-40 a10 10 0 0 1 10 -10 z',
stadium: 'M20 4 h60 a20 20 0 0 1 20 20 v20 a20 20 0 0 1 -20 20 h-60 a20 20 0 0 1 -20 -20 v-20 a20 20 0 0 1 20 -20 z',
subroutine: 'M10 4 h80 v60 h-80 z M20 4 v60 M80 4 v60',
cyl: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
cylinder: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
// Logic
diamond: 'M50 0 L100 50 L50 100 L0 50 Z',
rhombus: 'M50 0 L100 50 L50 100 L0 50 Z',
decision: 'M50 0 L100 50 L50 100 L0 50 Z',
hexagon: 'M15 4 L85 4 L100 34 L85 64 L15 64 L0 34 Z',
parallelogram: 'M15 4 h85 l-15 60 h-85 z',
trapezoid: 'M15 4 h70 l15 60 h-100 z',
// Documents/Data
doc: 'M0 0 h80 l20 20 v80 h-100 z M80 0 v20 h20',
document: 'M10 4 h80 v45 q0 15 -15 15 h-60 q-15 0 -15 -15 v-45 z M10 4 h80 v45 q-10 10 -40 10 q -30 0 -40 -10',
// Cloud (Approximation)
cloud: 'M20 50 a20 20 0 0 1 20 -30 a25 25 0 0 1 40 10 a20 20 0 0 1 15 35 h-70 z',
// Circles
circle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60',
doublecircle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M50 9 a25 25 0 0 1 0 50 a25 25 0 0 1 0 -50',
// New additions based on research
note: 'M10 4 h80 v60 h-80 z M70 4 v20 h20', // rough note
summary: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M30 24 l40 40 M30 64 l40 -40', // Crossed circle
};
export const SHAPE_DEFINITIONS: ShapeDefinition[] = [
{ id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' },
{ id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' },
{ id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' },
{ id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' },
{ id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' },
{ id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' },
{ id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' },
{ id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' },
{ id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' },
{ id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' },
{ id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' },
{ id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' },
];

View file

@ -1,135 +1,124 @@
import { env, pipeline, RawImage } from '@huggingface/transformers';
// Configure transformers.js
env.allowLocalModels = false;
env.useBrowserCache = true;
export type VisionProgress = {
status: string;
progress?: number;
file?: string;
};
// ViT-GPT2 is the ONLY working model for browser-based image captioning
// Other models (BLIP, Florence-2, LLaVA) are not supported by transformers.js
const MODEL_ID = 'Xenova/vit-gpt2-image-captioning';
export class VisionService {
private captioner: any = null;
private isLoading = false;
private isReady = false;
// Singleton instance
private static instance: VisionService;
public static getInstance(): VisionService {
if (!VisionService.instance) {
VisionService.instance = new VisionService();
}
return VisionService.instance;
}
getStatus() {
return {
isReady: this.isReady,
isLoading: this.isLoading,
model: MODEL_ID
};
}
async initialize(onProgress?: (progress: VisionProgress) => void): Promise<void> {
if (this.isReady || this.isLoading) return;
this.isLoading = true;
try {
console.log('Loading Vision Model...');
if (onProgress) onProgress({ status: 'Loading Vision Model...' });
// Use the pipeline API - much simpler and faster
this.captioner = await pipeline('image-to-text', MODEL_ID, {
progress_callback: (progress: any) => {
if (onProgress && progress.status === 'progress') {
onProgress({
status: `Downloading ${progress.file}`,
progress: progress.progress,
file: progress.file
});
}
}
});
this.isReady = true;
console.log('Vision Model Ready');
} catch (error) {
console.error('Failed to load Vision Model:', error);
throw error;
} finally {
this.isLoading = false;
}
}
/**
* Analyzes an image (Base64 or URL) and returns a description.
* Uses vit-gpt2 for fast captioning.
*/
async analyzeImage(imageBase64: string): Promise<string> {
if (!this.isReady) {
throw new Error('Vision model not loaded. Please initialize it first.');
}
try {
// Handle data URL prefix if present
const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`;
let image = await RawImage.fromURL(cleanBase64);
// Keep higher resolution for better detail detection
if (image.width > 512 || image.height > 512) {
image = await image.resize(512, 512);
}
console.log('Starting enhanced image analysis...');
const startTime = performance.now();
// Run multiple passes for more comprehensive description
const results = await Promise.all([
// Pass 1: Detailed description
this.captioner(image, {
max_new_tokens: 150,
num_beams: 4, // Beam search for better quality
}),
// Pass 2: Alternative perspective
this.captioner(image, {
max_new_tokens: 100,
do_sample: true,
temperature: 0.7,
}),
]);
const endTime = performance.now();
console.log(`Vision analysis completed in ${((endTime - startTime) / 1000).toFixed(1)}s`);
// Combine descriptions for richer output
const caption1 = results[0]?.[0]?.generated_text || '';
const caption2 = results[1]?.[0]?.generated_text || '';
// If both are similar, use just one; otherwise combine
if (caption1.toLowerCase().includes(caption2.toLowerCase().substring(0, 20)) ||
caption2.toLowerCase().includes(caption1.toLowerCase().substring(0, 20))) {
return caption1.length > caption2.length ? caption1 : caption2;
}
const combined = `${caption1}. Additionally: ${caption2}`;
console.log('Enhanced description:', combined);
return combined;
} catch (error) {
console.error('Vision analysis failed:', error);
throw new Error('Failed to analyze image with local vision model');
}
}
}
export const visionService = VisionService.getInstance();
import { env, AutoProcessor, AutoModel, RawImage } from '@huggingface/transformers';
// Configure transformers.js
env.allowLocalModels = false;
env.useBrowserCache = true;
export type VisionProgress = {
status: string;
progress?: number;
file?: string;
};
// We use Florence-2-base for a good balance of speed and accuracy (~200MB - 400MB)
// 'onnx-community/Florence-2-base-ft' is the modern standard for Transformers.js v3.
const MODEL_ID = 'onnx-community/Florence-2-base-ft';
export class VisionService {
private model: any = null;
private processor: any = null;
private isLoading = false;
private isReady = false;
// Singleton instance
private static instance: VisionService;
public static getInstance(): VisionService {
if (!VisionService.instance) {
VisionService.instance = new VisionService();
}
return VisionService.instance;
}
getStatus() {
return {
isReady: this.isReady,
isLoading: this.isLoading,
model: MODEL_ID
};
}
async initialize(onProgress?: (progress: VisionProgress) => void): Promise<void> {
if (this.isReady || this.isLoading) return;
this.isLoading = true;
try {
console.log('Loading Vision Model...');
if (onProgress) onProgress({ status: 'Loading Processor...' });
this.processor = await AutoProcessor.from_pretrained(MODEL_ID);
if (onProgress) onProgress({ status: 'Loading Model (this may take a while)...' });
this.model = await AutoModel.from_pretrained(MODEL_ID, {
progress_callback: (progress: any) => {
if (onProgress && progress.status === 'progress') {
onProgress({
status: `Downloading ${progress.file}`,
progress: progress.progress,
file: progress.file
});
}
}
});
this.isReady = true;
console.log('Vision Model Ready');
} catch (error) {
console.error('Failed to load Vision Model:', error);
throw error;
} finally {
this.isLoading = false;
}
}
/**
* Analyzes an image (Base64 or URL) and returns a detailed description.
* We use the '<MORE_DETAILED_CAPTION>' task for Florence-2.
*/
async analyzeImage(imageBase64: string): Promise<string> {
if (!this.isReady) {
throw new Error('Vision model not loaded. Please initialize it first.');
}
try {
// Handle data URL prefix if present
const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`;
const image = await RawImage.fromURL(cleanBase64);
// Task: Detailed Captioning is best for understanding diagrams
const text = '<MORE_DETAILED_CAPTION>';
const inputs = await this.processor(image, text);
const generatedIds = await this.model.generate({
...inputs,
max_new_tokens: 512, // Sufficient for a description
});
const generatedText = this.processor.batch_decode(generatedIds, {
skip_special_tokens: false,
})[0];
// Post-process to extract the caption
// Florence-2 output format usually includes the task token
const parsedAnswer = this.processor.post_process_generation(
generatedText,
text,
image.size
);
// Access the dictionary result. For CAPTION tasks, it's usually under '<MORE_DETAILED_CAPTION>' or similar key
// Ideally post_process_generation returns { '<MORE_DETAILED_CAPTION>': "Description..." }
return parsedAnswer['<MORE_DETAILED_CAPTION>'] || typeof parsedAnswer === 'string' ? parsedAnswer : JSON.stringify(parsedAnswer);
} catch (error) {
console.error('Vision analysis failed:', error);
throw new Error('Failed to analyze image with local vision model');
}
}
}
export const visionService = VisionService.getInstance();

File diff suppressed because it is too large Load diff

View file

@ -1,116 +1,106 @@
import { CreateMLCEngine, MLCEngine } from "@mlc-ai/web-llm";
import type { InitProgressCallback } from "@mlc-ai/web-llm";
export type WebLlmProgress = {
progress: number;
text: string;
timeElapsed: number;
};
// Qwen3-0.6B is fast and works well with simple Mermaid generation prompts
const DEFAULT_MODEL = "Qwen3-0.6B-q4f32_1-MLC";
export class WebLlmService {
private engine: MLCEngine | null = null;
private isLoading = false;
private isReady = false;
// Track GPU Availability
public static async isSystemSupported(): Promise<boolean | null> {
// @ts-ignore
if (!navigator.gpu) {
console.warn('WebGPU not supported in this environment');
return null;
}
try {
// @ts-ignore
const adapter = await navigator.gpu.requestAdapter();
return !!adapter;
} catch (e) {
return false;
}
}
async initialize(onProgress?: (progress: WebLlmProgress) => void): Promise<void> {
if (this.engine || this.isLoading) return;
this.isLoading = true;
const initProgressCallback: InitProgressCallback = (report) => {
if (onProgress) {
// Parse the native report which is basically just text and percentage
// Example: "Loading model 10% [ cached ]" or "Fetching param shard 1/4"
const progressMatch = report.text.match(/(\d+)%/);
const progress = progressMatch ? parseInt(progressMatch[1], 10) : 0;
onProgress({
progress,
text: report.text,
timeElapsed: report.timeElapsed
});
}
};
try {
console.log('Initializing WebLLM Engine...');
this.engine = await CreateMLCEngine(
DEFAULT_MODEL,
{ initProgressCallback }
);
this.isReady = true;
console.log('WebLLM Engine Ready');
} catch (error) {
console.error('Failed to initialize WebLLM:', error);
this.engine = null;
throw error;
} finally {
this.isLoading = false;
}
}
async chat(messages: { role: 'system' | 'user' | 'assistant', content: string }[]): Promise<AsyncGenerator<string>> {
if (!this.engine || !this.isReady) {
throw new Error("WebLLM Engine not initialized. Please load the model first.");
}
console.log('WebLLM: Creating completion...');
const startTime = performance.now();
const completion = await this.engine.chat.completions.create({
messages,
stream: true,
temperature: 0, // Deterministic output for code
max_tokens: 512, // Mermaid code is compact
top_p: 0.9, // Faster sampling
repetition_penalty: 1.1, // Avoid repetitive output
});
console.log('WebLLM: Completion created, streaming...');
// Create a generator to stream chunks easily
async function* streamGenerator() {
let tokenCount = 0;
for await (const chunk of completion) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
tokenCount++;
if (tokenCount === 1) console.log('WebLLM: First token received');
yield content;
}
}
const endTime = performance.now();
console.log(`WebLLM: Generation complete (${tokenCount} tokens, ${((endTime - startTime) / 1000).toFixed(1)}s)`);
}
return streamGenerator();
}
getStatus(): { isReady: boolean; isLoading: boolean; model: string } {
return {
isReady: this.isReady,
isLoading: this.isLoading,
model: DEFAULT_MODEL
};
}
}
// Singleton instance
export const webLlmService = new WebLlmService();
import { CreateMLCEngine, MLCEngine } from "@mlc-ai/web-llm";
import type { InitProgressCallback } from "@mlc-ai/web-llm";
export type WebLlmProgress = {
progress: number;
text: string;
timeElapsed: number;
};
// Latest "Tiny" model with high instruction adherence
const DEFAULT_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC";
export class WebLlmService {
private engine: MLCEngine | null = null;
private isLoading = false;
private isReady = false;
// Track GPU Availability
public static async isSystemSupported(): Promise<boolean | null> {
// @ts-ignore
if (!navigator.gpu) {
console.warn('WebGPU not supported in this environment');
return null;
}
try {
// @ts-ignore
const adapter = await navigator.gpu.requestAdapter();
return !!adapter;
} catch (e) {
return false;
}
}
async initialize(onProgress?: (progress: WebLlmProgress) => void): Promise<void> {
if (this.engine || this.isLoading) return;
this.isLoading = true;
const initProgressCallback: InitProgressCallback = (report) => {
if (onProgress) {
// Parse the native report which is basically just text and percentage
// Example: "Loading model 10% [ cached ]" or "Fetching param shard 1/4"
const progressMatch = report.text.match(/(\d+)%/);
const progress = progressMatch ? parseInt(progressMatch[1], 10) : 0;
onProgress({
progress,
text: report.text,
timeElapsed: report.timeElapsed
});
}
};
try {
console.log('Initializing WebLLM Engine...');
this.engine = await CreateMLCEngine(
DEFAULT_MODEL,
{ initProgressCallback }
);
this.isReady = true;
console.log('WebLLM Engine Ready');
} catch (error) {
console.error('Failed to initialize WebLLM:', error);
this.engine = null;
throw error;
} finally {
this.isLoading = false;
}
}
async chat(messages: { role: 'system' | 'user' | 'assistant', content: string }[]): Promise<AsyncGenerator<string>> {
if (!this.engine || !this.isReady) {
throw new Error("WebLLM Engine not initialized. Please load the model first.");
}
const completion = await this.engine.chat.completions.create({
messages,
stream: true,
temperature: 0.1, // Low temp for code/logic generation
max_tokens: 4096, // Sufficient for diagrams
});
// Create a generator to stream chunks easily
async function* streamGenerator() {
for await (const chunk of completion) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
yield content;
}
}
}
return streamGenerator();
}
getStatus(): { isReady: boolean; isLoading: boolean; model: string } {
return {
isReady: this.isReady,
isLoading: this.isLoading,
model: DEFAULT_MODEL
};
}
}
// Singleton instance
export const webLlmService = new WebLlmService();

View file

@ -165,6 +165,42 @@ export function Editor() {
</div>
</div>
)}
{/* Mobile Empty State - Big Get Started Prompt */}
{nodes.length === 0 && !isLoading && isMobile && !mobileEditorOpen && (
<div className="absolute inset-0 flex items-center justify-center z-10 p-8">
<div className="text-center w-full max-w-xs">
{/* Icon */}
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center mx-auto mb-6 shadow-2xl shadow-blue-600/30">
<Sparkles className="w-10 h-10 text-white" />
</div>
{/* Title */}
<h2 className="text-2xl font-black tracking-tight text-slate-800 dark:text-white mb-3">
Create Your Diagram
</h2>
{/* Description */}
<p className="text-slate-500 dark:text-slate-400 text-sm leading-relaxed mb-8">
Describe your system in plain English, upload an image, or write Mermaid code.
</p>
{/* Get Started Button */}
<button
onClick={() => setMobileEditorOpen(true)}
className="w-full min-h-[56px] flex items-center justify-center gap-3 px-6 py-4 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-600 text-white font-bold text-base shadow-xl shadow-blue-600/30 active:scale-[0.98] transition-transform"
>
<Sparkles className="w-5 h-5" />
Get Started
</button>
{/* Hint */}
<p className="mt-6 text-xs text-slate-400 dark:text-slate-500">
Or tap the <span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-blue-600 text-white align-middle mx-0.5"><Sparkles className="w-3 h-3" /></span> button anytime
</p>
</div>
</div>
)}
</main>
{/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */}

View file

@ -1,132 +1,132 @@
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft, Search, Trash2, Activity, Clock,
ArrowRight, Zap, Filter
} from 'lucide-react';
import { useFlowStore } from '../store';
export function History() {
const navigate = useNavigate();
const { savedDiagrams, deleteDiagram } = useFlowStore();
const [searchQuery, setSearchQuery] = useState('');
const filteredDiagrams = useMemo(() => {
return [...savedDiagrams]
.filter(d => d.name.toLowerCase().includes(searchQuery.toLowerCase()))
.reverse();
}, [savedDiagrams, searchQuery]);
const handleDelete = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (confirm('Are you sure you want to delete this intelligence draft?')) {
deleteDiagram(id);
}
};
return (
<div className="h-screen bg-void text-primary overflow-hidden font-sans relative flex flex-col">
{/* Ambient Background */}
<div className="absolute inset-0 z-0 pointer-events-none opacity-20">
<div className="absolute top-0 right-0 w-[50vw] h-[50vh] bg-blue-500/5 blur-[120px] rounded-full" />
<div className="absolute bottom-0 left-0 w-[40vw] h-[40vh] bg-indigo-500/5 blur-[120px] rounded-full" />
</div>
{/* Header */}
<header className="h-20 px-12 flex items-center justify-between z-10 border-b titanium-border bg-surface/50 backdrop-blur-md">
<div className="flex items-center gap-8">
<button
onClick={() => navigate('/')}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-surface border titanium-border text-secondary hover:text-primary transition-all hover:scale-105 active:scale-95"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-600/20">
<Zap className="w-4 h-4 text-white fill-white/20" />
</div>
<h1 className="text-lg font-display font-black tracking-tight">Intelligence Archive</h1>
</div>
</div>
<div className="flex items-center gap-4 flex-1 max-w-xl mx-12">
<div className="relative flex-1 group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-tertiary group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Find specific logic draft..."
className="w-full bg-void/30 border titanium-border rounded-2xl py-2.5 pl-12 pr-4 text-sm outline-none focus:border-blue-500/50 transition-all text-primary"
/>
</div>
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-surface border titanium-border text-[9px] font-black uppercase tracking-widest text-tertiary">
<Filter className="w-3 h-3" />
<span>{filteredDiagrams.length} Results</span>
</div>
</div>
</header>
{/* Content List */}
<main className="flex-1 overflow-y-auto p-12 relative z-10 hide-scrollbar">
{filteredDiagrams.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 max-w-7xl mx-auto animate-slide-up">
{filteredDiagrams.map((diagram) => (
<div
key={diagram.id}
onClick={() => navigate(`/diagram?id=${diagram.id}`)}
className="group relative glass-panel rounded-3xl p-6 border titanium-border hover:border-blue-500/30 transition-all duration-300 cursor-pointer shadow-sm hover:shadow-2xl hover:-translate-y-1"
>
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center shrink-0">
<Activity className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
</div>
<button
onClick={(e) => handleDelete(e, diagram.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-tertiary hover:text-red-500 hover:bg-red-500/10 transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-1 mb-6">
<h4 className="font-bold text-base truncate text-primary pr-4">{diagram.name}</h4>
<div className="flex items-center gap-2 text-[10px] text-tertiary font-bold uppercase tracking-widest">
<Clock className="w-3 h-3" />
<span>{new Date(diagram.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-500/5">
<span className="text-[9px] font-black text-blue-500 uppercase tracking-widest">
{diagram.nodes.filter(n => n.type !== 'group').length} Entities
</span>
<div className="flex items-center gap-1 text-[9px] font-black uppercase text-tertiary group-hover:text-blue-500 transition-colors">
<span>Restore</span>
<ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</div>
))}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center opacity-40">
<div className="w-20 h-20 rounded-full border-2 border-dashed border-slate-500 flex items-center justify-center mb-6">
<Search className="w-8 h-8 text-slate-500" />
</div>
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-center leading-relaxed">
{searchQuery ? 'No intelligence matching your query' : 'Your intelligence archive is empty'}
</p>
</div>
)}
</main>
{/* Footer Statistics */}
<footer className="h-12 border-t titanium-border bg-surface/50 backdrop-blur-md flex items-center justify-center px-12 z-10">
<p className="text-[9px] font-black text-tertiary uppercase tracking-[0.5em]">
Active Persistence Engine v3.1.2 Total Capacity: {savedDiagrams.length}/100
</p>
</footer>
</div>
);
}
import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ArrowLeft, Search, Trash2, Activity, Clock,
ArrowRight, Zap, Filter
} from 'lucide-react';
import { useFlowStore } from '../store';
export function History() {
const navigate = useNavigate();
const { savedDiagrams, deleteDiagram } = useFlowStore();
const [searchQuery, setSearchQuery] = useState('');
const filteredDiagrams = useMemo(() => {
return [...savedDiagrams]
.filter(d => d.name.toLowerCase().includes(searchQuery.toLowerCase()))
.reverse();
}, [savedDiagrams, searchQuery]);
const handleDelete = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (confirm('Are you sure you want to delete this intelligence draft?')) {
deleteDiagram(id);
}
};
return (
<div className="h-screen bg-void text-primary overflow-hidden font-sans relative flex flex-col">
{/* Ambient Background */}
<div className="absolute inset-0 z-0 pointer-events-none opacity-20">
<div className="absolute top-0 right-0 w-[50vw] h-[50vh] bg-blue-500/5 blur-[120px] rounded-full" />
<div className="absolute bottom-0 left-0 w-[40vw] h-[40vh] bg-indigo-500/5 blur-[120px] rounded-full" />
</div>
{/* Header */}
<header className="h-20 px-12 flex items-center justify-between z-10 border-b titanium-border bg-surface/50 backdrop-blur-md">
<div className="flex items-center gap-8">
<button
onClick={() => navigate('/')}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-surface border titanium-border text-secondary hover:text-primary transition-all hover:scale-105 active:scale-95"
>
<ArrowLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-600/20">
<Zap className="w-4 h-4 text-white fill-white/20" />
</div>
<h1 className="text-lg font-display font-black tracking-tight">Intelligence Archive</h1>
</div>
</div>
<div className="flex items-center gap-4 flex-1 max-w-xl mx-12">
<div className="relative flex-1 group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-tertiary group-focus-within:text-blue-500 transition-colors" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Find specific logic draft..."
className="w-full bg-void/30 border titanium-border rounded-2xl py-2.5 pl-12 pr-4 text-sm outline-none focus:border-blue-500/50 transition-all text-primary"
/>
</div>
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-surface border titanium-border text-[9px] font-black uppercase tracking-widest text-tertiary">
<Filter className="w-3 h-3" />
<span>{filteredDiagrams.length} Results</span>
</div>
</div>
</header>
{/* Content List */}
<main className="flex-1 overflow-y-auto p-12 relative z-10 hide-scrollbar">
{filteredDiagrams.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 max-w-7xl mx-auto animate-slide-up">
{filteredDiagrams.map((diagram) => (
<div
key={diagram.id}
onClick={() => navigate(`/diagram?id=${diagram.id}`)}
className="group relative glass-panel rounded-3xl p-6 border titanium-border hover:border-blue-500/30 transition-all duration-300 cursor-pointer shadow-sm hover:shadow-2xl hover:-translate-y-1"
>
<div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center shrink-0">
<Activity className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
</div>
<button
onClick={(e) => handleDelete(e, diagram.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-tertiary hover:text-red-500 hover:bg-red-500/10 transition-all opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-1 mb-6">
<h4 className="font-bold text-base truncate text-primary pr-4">{diagram.name}</h4>
<div className="flex items-center gap-2 text-[10px] text-tertiary font-bold uppercase tracking-widest">
<Clock className="w-3 h-3" />
<span>{new Date(diagram.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-500/5">
<span className="text-[9px] font-black text-blue-500 uppercase tracking-widest">
{diagram.nodes.filter(n => n.type !== 'group').length} Entities
</span>
<div className="flex items-center gap-1 text-[9px] font-black uppercase text-tertiary group-hover:text-blue-500 transition-colors">
<span>Restore</span>
<ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</div>
))}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center opacity-40">
<div className="w-20 h-20 rounded-full border-2 border-dashed border-slate-500 flex items-center justify-center mb-6">
<Search className="w-8 h-8 text-slate-500" />
</div>
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-center leading-relaxed">
{searchQuery ? 'No intelligence matching your query' : 'Your intelligence archive is empty'}
</p>
</div>
)}
</main>
{/* Footer Statistics */}
<footer className="h-12 border-t titanium-border bg-surface/50 backdrop-blur-md flex items-center justify-center px-12 z-10">
<p className="text-[9px] font-black text-tertiary uppercase tracking-[0.5em]">
Active Persistence Engine v3.1.2 Total Capacity: {savedDiagrams.length}/100
</p>
</footer>
</div>
);
}

View file

@ -1,26 +1,26 @@
// Basic types needed for plugins
export interface ToolbarItem {
id: string;
label: string;
icon: any; // Lucide icon component
onClick: () => void;
position?: 'left' | 'center' | 'right'; // Default 'right' (actions)
tooltip?: string;
}
export interface PluginContext {
registerToolbarItem: (item: ToolbarItem) => void;
// Future extensions:
// registerSidebarTab: (tab: SidebarTab) => void;
// registerNodeAction: (action: NodeAction) => void;
}
export interface Plugin {
id: string;
name: string;
version: string;
init: (context: PluginContext) => void;
cleanup?: () => void;
}
// Basic types needed for plugins
export interface ToolbarItem {
id: string;
label: string;
icon: any; // Lucide icon component
onClick: () => void;
position?: 'left' | 'center' | 'right'; // Default 'right' (actions)
tooltip?: string;
}
export interface PluginContext {
registerToolbarItem: (item: ToolbarItem) => void;
// Future extensions:
// registerSidebarTab: (tab: SidebarTab) => void;
// registerNodeAction: (action: NodeAction) => void;
}
export interface Plugin {
id: string;
name: string;
version: string;
init: (context: PluginContext) => void;
cleanup?: () => void;
}

View file

@ -1,63 +1,63 @@
import { create } from 'zustand';
import type { Plugin, PluginContext, ToolbarItem } from '../plugins/types';
interface PluginState {
plugins: Record<string, Plugin>;
toolbarItems: ToolbarItem[];
// Actions
registerPlugin: (plugin: Plugin) => void;
unregisterPlugin: (pluginId: string) => void;
// Internal API for Plugins
_registerToolbarItem: (item: ToolbarItem) => void;
}
export const usePluginStore = create<PluginState>((set, get) => ({
plugins: {},
toolbarItems: [],
registerPlugin: (plugin) => {
if (get().plugins[plugin.id]) {
console.warn(`Plugin ${plugin.id} is already registered.`);
return;
}
// Create Context
const context: PluginContext = {
registerToolbarItem: (item) => get()._registerToolbarItem(item),
};
// Init Plugin
try {
plugin.init(context);
set((state) => ({
plugins: { ...state.plugins, [plugin.id]: plugin }
}));
console.log(`Plugin ${plugin.name} loaded.`);
} catch (e) {
console.error(`Failed to initialize plugin ${plugin.id}:`, e);
}
},
unregisterPlugin: (pluginId) => {
const plugin = get().plugins[pluginId];
if (!plugin) return;
if (plugin.cleanup) plugin.cleanup();
set((state) => {
const { [pluginId]: removed, ...rest } = state.plugins;
// Note: complex cleanup of UI items registered by this plugin would ideally happen here.
// For MVP, we might just clear all or need items to be tagged with pluginId.
// Improvement: ToolbarItem should have pluginId.
return { plugins: rest };
});
},
_registerToolbarItem: (item) => {
set((state) => ({
toolbarItems: [...state.toolbarItems, item]
}));
}
}));
import { create } from 'zustand';
import type { Plugin, PluginContext, ToolbarItem } from '../plugins/types';
interface PluginState {
plugins: Record<string, Plugin>;
toolbarItems: ToolbarItem[];
// Actions
registerPlugin: (plugin: Plugin) => void;
unregisterPlugin: (pluginId: string) => void;
// Internal API for Plugins
_registerToolbarItem: (item: ToolbarItem) => void;
}
export const usePluginStore = create<PluginState>((set, get) => ({
plugins: {},
toolbarItems: [],
registerPlugin: (plugin) => {
if (get().plugins[plugin.id]) {
console.warn(`Plugin ${plugin.id} is already registered.`);
return;
}
// Create Context
const context: PluginContext = {
registerToolbarItem: (item) => get()._registerToolbarItem(item),
};
// Init Plugin
try {
plugin.init(context);
set((state) => ({
plugins: { ...state.plugins, [plugin.id]: plugin }
}));
console.log(`Plugin ${plugin.name} loaded.`);
} catch (e) {
console.error(`Failed to initialize plugin ${plugin.id}:`, e);
}
},
unregisterPlugin: (pluginId) => {
const plugin = get().plugins[pluginId];
if (!plugin) return;
if (plugin.cleanup) plugin.cleanup();
set((state) => {
const { [pluginId]: removed, ...rest } = state.plugins;
// Note: complex cleanup of UI items registered by this plugin would ideally happen here.
// For MVP, we might just clear all or need items to be tagged with pluginId.
// Improvement: ToolbarItem should have pluginId.
return { plugins: rest };
});
},
_registerToolbarItem: (item) => {
set((state) => ({
toolbarItems: [...state.toolbarItems, item]
}));
}
}));

View file

@ -416,4 +416,36 @@
.animate-fade-in {
animation: fade-in 0.2s ease-out forwards;
}
/* Slide in from right animation - for mobile menu */
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Slide out to right animation */
@keyframes slide-out-right {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}
.animate-slide-out-right {
animation: slide-out-right 0.25s ease-out forwards;
}

View file

@ -1,42 +1,42 @@
import '@testing-library/jest-dom';
import * as matchers from '@testing-library/jest-dom/matchers';
import { expect } from 'vitest';
expect.extend(matchers);
// Mock localStorage
const localStorageMock = (function () {
let store: Record<string, string> = {};
return {
getItem: function (key: string) {
return store[key] || null;
},
setItem: function (key: string, value: string) {
store[key] = value.toString();
},
clear: function () {
store = {};
},
removeItem: function (key: string) {
delete store[key];
}
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
});
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => { },
removeListener: () => { },
addEventListener: () => { },
removeEventListener: () => { },
dispatchEvent: () => false,
}),
});
import '@testing-library/jest-dom';
import * as matchers from '@testing-library/jest-dom/matchers';
import { expect } from 'vitest';
expect.extend(matchers);
// Mock localStorage
const localStorageMock = (function () {
let store: Record<string, string> = {};
return {
getItem: function (key: string) {
return store[key] || null;
},
setItem: function (key: string, value: string) {
store[key] = value.toString();
},
clear: function () {
store = {};
},
removeItem: function (key: string) {
delete store[key];
}
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
});
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => { },
removeListener: () => { },
addEventListener: () => { },
removeEventListener: () => { },
dispatchEvent: () => false,
}),
});

View file

@ -1,105 +1,105 @@
export interface NodePosition {
x: number;
y: number;
width?: number;
height?: number;
}
export interface EdgeConnection {
source: string;
target: string;
type?: string;
}
export interface LayoutMetrics {
nodeCount: number;
edgeCount: number;
edgeCrossings: number;
nodeDensity: number;
averageNodeSpacing: number;
visualComplexity: number; // 0-100 scale
aspectRatio: number;
}
export interface VisualIssue {
type: 'edge-crossing' | 'overlap' | 'poor-spacing' | 'unclear-flow' | 'inefficient-layout' | 'style-consistency';
severity: 'low' | 'medium' | 'high';
description: string;
affectedNodes?: string[];
affectedEdges?: string[];
suggestedFix?: string;
}
export interface LayoutSuggestion {
id: string;
title: string;
description: string;
type: 'spacing' | 'grouping' | 'routing' | 'hierarchy' | 'style' | 'node-shape' | 'node-color-semantic';
impact: 'low' | 'medium' | 'high';
estimatedImprovement: number; // percentage
beforeState: {
metrics: LayoutMetrics;
issues: VisualIssue[];
};
afterState: {
metrics: LayoutMetrics;
estimatedIssues: VisualIssue[];
};
implementation: {
nodePositions: Record<string, NodePosition>;
edgeRouting?: Record<string, any>;
styleChanges?: Record<string, any>;
description?: string;
};
}
export interface VisualOrganizationRequest {
nodes: Array<{
id: string;
label: string;
type: string;
position: NodePosition;
metadata?: any;
}>;
edges: EdgeConnection[];
currentLayout?: {
direction: 'TB' | 'LR' | 'BT' | 'RL';
spacing: number;
};
preferences?: {
priority: 'readability' | 'compactness' | 'flow-clarity';
groupSimilarNodes: boolean;
minimizeCrossings: boolean;
preserveUserLayout: boolean;
};
}
export interface VisualOrganizationResponse {
success: boolean;
suggestions: LayoutSuggestion[];
summary: {
totalIssues: number;
criticalIssues: number;
potentialImprovement: number;
recommendedAction: string;
};
error?: string;
}
export interface VisualAnalysisResult {
metrics: LayoutMetrics;
issues: VisualIssue[];
strengths: string[];
recommendations: string[];
}
export interface AISuggestionPrompt {
systemPrompt: string;
userPrompt: string;
context: {
currentMetrics: LayoutMetrics;
identifiedIssues: VisualIssue[];
nodeTypes: string[];
edgeTypes: string[];
};
}
export interface NodePosition {
x: number;
y: number;
width?: number;
height?: number;
}
export interface EdgeConnection {
source: string;
target: string;
type?: string;
}
export interface LayoutMetrics {
nodeCount: number;
edgeCount: number;
edgeCrossings: number;
nodeDensity: number;
averageNodeSpacing: number;
visualComplexity: number; // 0-100 scale
aspectRatio: number;
}
export interface VisualIssue {
type: 'edge-crossing' | 'overlap' | 'poor-spacing' | 'unclear-flow' | 'inefficient-layout' | 'style-consistency';
severity: 'low' | 'medium' | 'high';
description: string;
affectedNodes?: string[];
affectedEdges?: string[];
suggestedFix?: string;
}
export interface LayoutSuggestion {
id: string;
title: string;
description: string;
type: 'spacing' | 'grouping' | 'routing' | 'hierarchy' | 'style' | 'node-shape' | 'node-color-semantic';
impact: 'low' | 'medium' | 'high';
estimatedImprovement: number; // percentage
beforeState: {
metrics: LayoutMetrics;
issues: VisualIssue[];
};
afterState: {
metrics: LayoutMetrics;
estimatedIssues: VisualIssue[];
};
implementation: {
nodePositions: Record<string, NodePosition>;
edgeRouting?: Record<string, any>;
styleChanges?: Record<string, any>;
description?: string;
};
}
export interface VisualOrganizationRequest {
nodes: Array<{
id: string;
label: string;
type: string;
position: NodePosition;
metadata?: any;
}>;
edges: EdgeConnection[];
currentLayout?: {
direction: 'TB' | 'LR' | 'BT' | 'RL';
spacing: number;
};
preferences?: {
priority: 'readability' | 'compactness' | 'flow-clarity';
groupSimilarNodes: boolean;
minimizeCrossings: boolean;
preserveUserLayout: boolean;
};
}
export interface VisualOrganizationResponse {
success: boolean;
suggestions: LayoutSuggestion[];
summary: {
totalIssues: number;
criticalIssues: number;
potentialImprovement: number;
recommendedAction: string;
};
error?: string;
}
export interface VisualAnalysisResult {
metrics: LayoutMetrics;
issues: VisualIssue[];
strengths: string[];
recommendations: string[];
}
export interface AISuggestionPrompt {
systemPrompt: string;
userPrompt: string;
context: {
currentMetrics: LayoutMetrics;
identifiedIssues: VisualIssue[];
nodeTypes: string[];
edgeTypes: string[];
};
}

50
todo.md
View file

@ -1,50 +0,0 @@
# AI Visual Organization Agent Implementation
## Phase 1: Enhanced AI Service Extension
- [x] Create visual organization types and interfaces
- [x] Add visual analysis AI methods to aiService.ts
- [x] Create specialized prompts for layout suggestions
- [x] Implement visual complexity scoring
## Phase 2: Visual Organization Engine
- [x] Create visualOrganizer.ts module
- [x] Implement layout analysis algorithms
- [x] Add edge crossing detection and optimization
- [x] Create node grouping and clustering logic
- [x] Integrate with existing layout engine
## Phase 3: UI Integration
- [x] Add Smart Layout panel to FlowCanvas (via NodeDetailsPanel)
- [x] Create suggestion cards component
- [x] Implement before/after preview functionality
- [x] Add AI organization control button
- [x] Create suggestion acceptance workflow
## Phase 4: Advanced Features
- [x] Multi-layout comparison system
- [x] Auto-organization presets by diagram type
- [x] Visual hierarchy optimization
- [x] Style consistency recommendations
## Phase 5: Smart Node Optimization (New)
- [x] AI Node Content Analysis (Shape/Icon/Label)
- [x] Intelligent Color Coding
- [x] Design System Refinement
## Phase 6: Critical Functionality Fixes
- [x] Enable 'Save Draft' button
- [x] Enable 'Export' button
## Phase 7: UI Redesign
- [x] Redesign Left Sidebar (Tabs, Editor, Bottom Actions)
- [x] Redesign Canvas Controls (Vertical Segmented Toolbar)
- [x] Implement Multi-Node Selection Mode (Pan vs Select toggle)
- [x] Redesign Sidebars (Mood & Tone Unification)
- [x] Refine Light Theme (Contrast and Visibility Fixes)
- [x] Theme Audit (Unify EditorHeader, Buttons, Cards)
## Testing & Polish
- [x] Test visual organization suggestions
- [x] Validate AI integration
- [x] Performance optimization
- [x] UI/UX refinements

View file

@ -1,11 +1,11 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
},
});
/// <reference types="vitest" />
import { defineConfig } from 'vite';
export default defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
},
});