mirror of
https://github.com/vndangkhoa/Sys-Arc-Visl.git
synced 2026-04-05 01:17:57 +07:00
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:
parent
a4793bf996
commit
5c4b83203c
41 changed files with 4150 additions and 3720 deletions
27
nginx.conf
27
nginx.conf
|
|
@ -12,10 +12,25 @@ server {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# NOTE: Ollama proxy is NOT included by default to allow standalone operation.
|
# Proxy Ollama API requests
|
||||||
# The app works with Browser AI (WebLLM/Transformers.js) without any external services.
|
# This solves Mixed Content (HTTPS -> HTTP) and CORS issues
|
||||||
#
|
location /api/ {
|
||||||
# If you need to proxy requests to Ollama, either:
|
proxy_pass http://ollama:11434/api/;
|
||||||
# 1. Set the Ollama URL directly in the app settings (e.g., http://your-nas-ip:11434)
|
proxy_set_header Host $host;
|
||||||
# 2. Or mount a custom nginx.conf with your proxy configuration
|
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
108
package-lock.json
generated
|
|
@ -13,10 +13,12 @@
|
||||||
"@mlc-ai/web-llm": "^0.2.80",
|
"@mlc-ai/web-llm": "^0.2.80",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/randomcolor": "^0.5.9",
|
"@types/randomcolor": "^0.5.9",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
|
|
@ -170,7 +172,6 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -577,7 +578,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
|
|
@ -621,7 +621,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -1236,9 +1235,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@exodus/bytes": {
|
"node_modules/@exodus/bytes": {
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.7.0.tgz",
|
||||||
"integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==",
|
"integrity": "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -2736,7 +2735,8 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
|
|
@ -3067,6 +3067,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/geojson": {
|
||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
|
@ -3174,7 +3180,6 @@
|
||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -3185,7 +3190,6 @@
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -3266,7 +3270,6 @@
|
||||||
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
|
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.50.1",
|
"@typescript-eslint/scope-manager": "8.50.1",
|
||||||
"@typescript-eslint/types": "8.50.1",
|
"@typescript-eslint/types": "8.50.1",
|
||||||
|
|
@ -3688,7 +3691,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -3739,6 +3741,7 @@
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
|
@ -3884,7 +3887,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -4187,7 +4189,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
||||||
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
|
|
@ -4588,7 +4589,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
|
@ -4817,12 +4817,13 @@
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.2.7",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
|
@ -4969,7 +4970,6 @@
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -5238,6 +5238,12 @@
|
||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
|
@ -5911,7 +5917,6 @@
|
||||||
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.28",
|
"@acemir/cssom": "^0.9.28",
|
||||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||||
|
|
@ -6436,6 +6441,7 @@
|
||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
|
|
@ -6451,15 +6457,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "14.0.0",
|
"version": "16.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
||||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/matcher": {
|
"node_modules/matcher": {
|
||||||
|
|
@ -6509,18 +6515,6 @@
|
||||||
"uuid": "^11.1.0"
|
"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": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
|
@ -6615,6 +6609,29 @@
|
||||||
"marked": "14.0.0"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
@ -6929,7 +6946,6 @@
|
||||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
|
|
@ -7033,6 +7049,7 @@
|
||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
|
|
@ -7048,6 +7065,7 @@
|
||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
|
|
@ -7129,7 +7147,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -7139,7 +7156,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -7152,7 +7168,8 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
|
|
@ -7809,9 +7826,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.1.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz",
|
||||||
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
|
"integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -7868,7 +7885,6 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -7988,7 +8004,6 @@
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -8393,7 +8408,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz",
|
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz",
|
||||||
"integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==",
|
"integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lib0": "^0.2.99"
|
"lib0": "^0.2.99"
|
||||||
},
|
},
|
||||||
|
|
@ -8425,7 +8439,6 @@
|
||||||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
@ -8466,7 +8479,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||||
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.20.0"
|
"node": ">=12.20.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,12 @@
|
||||||
"@mlc-ai/web-llm": "^0.2.80",
|
"@mlc-ai/web-llm": "^0.2.80",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@types/file-saver": "^2.0.7",
|
||||||
"@types/randomcolor": "^0.5.9",
|
"@types/randomcolor": "^0.5.9",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mermaid": "^11.12.2",
|
"mermaid": "^11.12.2",
|
||||||
|
|
|
||||||
|
|
@ -231,99 +231,156 @@ export function FlowCanvas() {
|
||||||
size={1}
|
size={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Control Panel - Top Right (Unified Toolkit) */}
|
{/* Control Panel - Top Right (Unified Toolkit) - Desktop Only */}
|
||||||
<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' : ''}`}>
|
{!isMobile && (
|
||||||
<div className="relative">
|
<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' : ''}`}>
|
||||||
<button
|
<div className="relative">
|
||||||
onClick={() => setShowToolkit(!showToolkit)}
|
<button
|
||||||
className={`
|
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
|
h-10 px-4 flex items-center gap-2 rounded-xl transition-all shadow-lg backdrop-blur-md border outline-none
|
||||||
${showToolkit
|
${showToolkit
|
||||||
? 'bg-blue-600 text-white border-blue-500 ring-2 ring-blue-500/20'
|
? '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-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" />
|
<Settings2 className="w-4 h-4" />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span>
|
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span>
|
||||||
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
{/* Dropdown Menu */}
|
||||||
{showToolkit && (
|
{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">
|
<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 */}
|
{/* Section: Interaction Mode */}
|
||||||
<div className="p-1">
|
<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>
|
<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">
|
<div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-white/5 p-1 rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSelectionMode(false)}
|
onClick={() => setIsSelectionMode(false)}
|
||||||
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${!isSelectionMode
|
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'
|
? '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'
|
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Hand className="w-4 h-4 mb-1" />
|
<Hand className="w-4 h-4 mb-1" />
|
||||||
<span className="text-[9px] font-bold">Pan</span>
|
<span className="text-[9px] font-bold">Pan</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsSelectionMode(true)}
|
onClick={() => setIsSelectionMode(true)}
|
||||||
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${isSelectionMode
|
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'
|
? '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'
|
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<MousePointer2 className="w-4 h-4 mb-1" />
|
<MousePointer2 className="w-4 h-4 mb-1" />
|
||||||
<span className="text-[9px] font-bold">Select</span>
|
<span className="text-[9px] font-bold">Select</span>
|
||||||
</button>
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Section: View Controls */}
|
{/* Mobile Bottom Action Bar */}
|
||||||
<div className="p-1">
|
{isMobile && nodes.length > 0 && !focusMode && (
|
||||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">View</span>
|
<Panel position="bottom-center" className="!mb-24 z-50">
|
||||||
<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">
|
<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">
|
||||||
<ToolkitButton icon={Minus} onClick={() => zoomOut()} label="Out" />
|
{/* Zoom Out */}
|
||||||
<ToolkitButton icon={Plus} onClick={() => zoomIn()} label="In" />
|
<button
|
||||||
<ToolkitButton icon={Maximize} onClick={handleResetView} label="Fit" />
|
onClick={() => zoomOut()}
|
||||||
</div>
|
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"
|
||||||
</div>
|
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 */}
|
{/* Fit View */}
|
||||||
<div className="p-1 space-y-1">
|
<button
|
||||||
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Actions</span>
|
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
|
{/* Divider */}
|
||||||
icon={Wand2}
|
<div className="w-px h-6 bg-slate-200 dark:bg-white/10 mx-1" />
|
||||||
label="Auto Layout"
|
|
||||||
active={false}
|
|
||||||
onClick={handleAutoLayout}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MenuButton
|
{/* Auto Layout */}
|
||||||
icon={edgeStyle === 'curved' ? Spline : Minus}
|
<button
|
||||||
label={edgeStyle === 'curved' ? 'Edge Style: Curved' : 'Edge Style: Straight'}
|
onClick={handleAutoLayout}
|
||||||
iconClass={edgeStyle === 'straight' ? 'rotate-45' : ''}
|
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"
|
||||||
active={false}
|
title="Auto Layout"
|
||||||
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
|
>
|
||||||
/>
|
<Wand2 className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<MenuButton
|
{/* Toggle Edge Style */}
|
||||||
icon={Map}
|
<button
|
||||||
label="MiniMap Overlay"
|
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
|
||||||
active={showMiniMap}
|
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"
|
||||||
onClick={() => setShowMiniMap(!showMiniMap)}
|
title={edgeStyle === 'curved' ? 'Switch to Straight Edges' : 'Switch to Curved Edges'}
|
||||||
/>
|
>
|
||||||
</div>
|
{edgeStyle === 'curved' ? <Spline className="w-5 h-5" /> : <Minus className="w-5 h-5 rotate-45" />}
|
||||||
</div>
|
</button>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</Panel>
|
||||||
|
)}
|
||||||
</Panel>
|
|
||||||
|
|
||||||
{/* MiniMap Container - Bottom Right (Hidden on Mobile) */}
|
{/* MiniMap Container - Bottom Right (Hidden on Mobile) */}
|
||||||
<Panel position="bottom-right" className="!m-4 z-40">
|
<Panel position="bottom-right" className="!m-4 z-40">
|
||||||
|
|
@ -342,8 +399,8 @@ export function FlowCanvas() {
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Status Indicator - Bottom Left */}
|
{/* Status Indicator - Bottom Left (Hidden on Mobile - shown in header) */}
|
||||||
{nodes.length > 0 && (
|
{nodes.length > 0 && !isMobile && (
|
||||||
<Panel position="bottom-left" className="!m-6">
|
<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-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">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
<div className={`fixed z-[9999] flex flex-col gap-4
|
||||||
${isMobile
|
${isMobile
|
||||||
? 'left-4 right-4 top-16 max-h-[80vh] rounded-[2rem]'
|
? 'inset-0 rounded-none pt-safe'
|
||||||
: 'top-24 right-12 w-96 rounded-[2rem]'
|
: 'top-24 right-12 w-96 max-h-[80vh] rounded-[2rem]'
|
||||||
} floating-glass p-6 titanium-border shadow-2xl overflow-hidden`}
|
} 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="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">
|
<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="w-5 h-5 text-blue-500" />
|
<Cpu className={`${isMobile ? 'w-6 h-6' : 'w-5 h-5'} text-blue-500`} />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-[10px] text-tertiary font-medium uppercase tracking-wider">Configuration</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<span className="text-[9px] font-bold text-amber-400 uppercase tracking-wider">Lite</span>
|
||||||
</div>
|
</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" />
|
<X className="w-5 h-5 text-tertiary group-hover:text-primary transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content Area */}
|
{/* 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 */}
|
{/* Mode Selection */}
|
||||||
<div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5">
|
<div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save,
|
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';
|
} from 'lucide-react';
|
||||||
import { useStore } from 'zustand';
|
import { useStore } from 'zustand';
|
||||||
import { useFlowStore, useDiagramStore } from '../../store';
|
import { useFlowStore, useDiagramStore } from '../../store';
|
||||||
|
|
@ -13,6 +14,49 @@ import {
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { SettingsModal } from '../Settings';
|
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() {
|
export function EditorHeader() {
|
||||||
const {
|
const {
|
||||||
nodes, edges, leftPanelOpen, setLeftPanelOpen,
|
nodes, edges, leftPanelOpen, setLeftPanelOpen,
|
||||||
|
|
@ -24,11 +68,17 @@ export function EditorHeader() {
|
||||||
} = useFlowStore();
|
} = useFlowStore();
|
||||||
const { isMobile } = useMobileDetect();
|
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 [showSettings, setShowSettings] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||||
|
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
|
setShowMobileMenu(false);
|
||||||
const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`);
|
const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`);
|
||||||
if (name) {
|
if (name) {
|
||||||
setSaveStatus('saving');
|
setSaveStatus('saving');
|
||||||
|
|
@ -44,6 +94,7 @@ export function EditorHeader() {
|
||||||
|
|
||||||
const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code' | 'json') => {
|
const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code' | 'json') => {
|
||||||
setShowExportMenu(false);
|
setShowExportMenu(false);
|
||||||
|
setShowMobileMenu(false);
|
||||||
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
|
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
|
||||||
|
|
||||||
try {
|
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 (
|
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">
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -88,7 +393,7 @@ export function EditorHeader() {
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => useDiagramStore.temporal.getState().undo()}
|
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"
|
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"
|
title="Undo"
|
||||||
>
|
>
|
||||||
|
|
@ -96,7 +401,7 @@ export function EditorHeader() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => useDiagramStore.temporal.getState().redo()}
|
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"
|
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"
|
title="Redo"
|
||||||
>
|
>
|
||||||
|
|
@ -104,60 +409,54 @@ export function EditorHeader() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
||||||
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
|
<button
|
||||||
<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">
|
onClick={() => setLeftPanelOpen(!leftPanelOpen)}
|
||||||
<button
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
|
||||||
onClick={() => setLeftPanelOpen(!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'
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
|
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`}
|
||||||
? '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'
|
title="Toggle Input Panel"
|
||||||
: '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>
|
||||||
<Edit3 className="w-3.5 h-3.5" />
|
</button>
|
||||||
<span className="hidden sm:inline">Input</span>
|
<button
|
||||||
</button>
|
onClick={() => setRightPanelOpen(!rightPanelOpen)}
|
||||||
<button
|
disabled={nodes.length === 0}
|
||||||
onClick={() => setRightPanelOpen(!rightPanelOpen)}
|
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
|
||||||
disabled={nodes.length === 0}
|
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
|
||||||
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
|
: (rightPanelOpen
|
||||||
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
|
? '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'
|
||||||
: (rightPanelOpen
|
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
|
||||||
? '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'
|
title="Toggle Code Panel"
|
||||||
: '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>
|
||||||
<Code className="w-3.5 h-3.5" />
|
</button>
|
||||||
<span className="hidden sm:inline">Code</span>
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* AI Mode Status - Desktop Only */}
|
{/* 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">
|
||||||
<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' ? (
|
||||||
{aiMode === 'offline' ? (
|
<>
|
||||||
<>
|
<Server className="w-3 h-3 text-blue-500" />
|
||||||
<Server className="w-3 h-3 text-blue-500" />
|
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span>
|
||||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span>
|
</>
|
||||||
</>
|
) : aiMode === 'browser' ? (
|
||||||
) : aiMode === 'browser' ? (
|
<>
|
||||||
<>
|
<Cpu className="w-3 h-3 text-purple-500" />
|
||||||
<Cpu className="w-3 h-3 text-purple-500" />
|
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span>
|
||||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span>
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<Cloud className="w-3 h-3 text-emerald-500" />
|
||||||
<Cloud className="w-3 h-3 text-emerald-500" />
|
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span>
|
||||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setFocusMode(!focusMode)}
|
onClick={() => setFocusMode(!focusMode)}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ interface EditorToolbarProps {
|
||||||
handleGenerate: () => void;
|
handleGenerate: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
hasCode: boolean;
|
hasCode: boolean;
|
||||||
|
hasCode: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { usePluginStore } from '../../store/pluginStore';
|
import { usePluginStore } from '../../store/pluginStore';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,54 @@
|
||||||
import { toPng, toJpeg, toSvg } from 'html-to-image';
|
import { toPng, toJpeg, toSvg } from 'html-to-image';
|
||||||
import { type Node, type Edge } from '../store';
|
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> {
|
export async function exportToPng(element: HTMLElement): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const backgroundColor = getThemeBackgroundColor();
|
||||||
const dataUrl = await toPng(element, {
|
const dataUrl = await toPng(element, {
|
||||||
backgroundColor: '#020617',
|
backgroundColor,
|
||||||
quality: 1,
|
quality: 1,
|
||||||
pixelRatio: 3,
|
pixelRatio: 3,
|
||||||
filter: (node) => {
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to export PNG:', error);
|
console.error('Failed to export PNG:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -24,9 +69,9 @@ export async function exportToPng(element: HTMLElement): Promise<void> {
|
||||||
|
|
||||||
export async function exportToJpg(element: HTMLElement): Promise<void> {
|
export async function exportToJpg(element: HTMLElement): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const backgroundColor = getThemeBackgroundColor();
|
||||||
const dataUrl = await toJpeg(element, {
|
const dataUrl = await toJpeg(element, {
|
||||||
backgroundColor: '#020617',
|
backgroundColor,
|
||||||
quality: 0.95,
|
quality: 0.95,
|
||||||
pixelRatio: 2,
|
pixelRatio: 2,
|
||||||
filter: (node) => {
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to export JPG:', error);
|
console.error('Failed to export JPG:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -46,8 +92,9 @@ export async function exportToJpg(element: HTMLElement): Promise<void> {
|
||||||
|
|
||||||
export async function exportToSvg(element: HTMLElement): Promise<void> {
|
export async function exportToSvg(element: HTMLElement): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const backgroundColor = getThemeBackgroundColor();
|
||||||
const dataUrl = await toSvg(element, {
|
const dataUrl = await toSvg(element, {
|
||||||
backgroundColor: '#020617',
|
backgroundColor,
|
||||||
filter: (node) => {
|
filter: (node) => {
|
||||||
const className = node.className?.toString() || '';
|
const className = node.className?.toString() || '';
|
||||||
return !className.includes('react-flow__controls') &&
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to export SVG:', error);
|
console.error('Failed to export SVG:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -80,10 +128,8 @@ export function exportToTxt(nodes: Node[], edges: Edge[]): void {
|
||||||
txt += `- ${sourceLabel} -> ${targetLabel} ${e.label ? `(${e.label})` : ''}\n`;
|
txt += `- ${sourceLabel} -> ${targetLabel} ${e.label ? `(${e.label})` : ''}\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = new Blob([txt], { type: 'text/plain' });
|
const blob = new Blob([txt], { type: 'text/plain;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
saveFile(blob, `summary-${getTimestamp()}.txt`);
|
||||||
downloadFile(url, `summary-${getTimestamp()}.txt`);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportToJson(nodes: Node[], edges: Edge[]): void {
|
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 jsonString = JSON.stringify(data, null, 2);
|
||||||
const blob = new Blob([jsonString], { type: 'application/json' });
|
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
saveFile(blob, `diagram-${getTimestamp()}.json`);
|
||||||
|
|
||||||
downloadFile(url, `diagram-${getTimestamp()}.json`);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportToMermaid(nodes: Node[], edges: Edge[]): string {
|
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 {
|
export function downloadMermaid(nodes: Node[], edges: Edge[]): void {
|
||||||
const mermaid = exportToMermaid(nodes, edges);
|
const mermaid = exportToMermaid(nodes, edges);
|
||||||
const blob = new Blob([mermaid], { type: 'text/plain' });
|
const blob = new Blob([mermaid], { type: 'text/plain;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
saveFile(blob, `diagram-${getTimestamp()}.mmd`);
|
||||||
|
|
||||||
downloadFile(url, `diagram-${getTimestamp()}.mmd`);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTimestamp(): string {
|
function getTimestamp(): string {
|
||||||
return new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
|
return new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadFile(url: string, filename: string): void {
|
// Convert data URL to Blob for proper file download
|
||||||
const link = document.createElement('a');
|
function dataURLtoBlob(dataUrl: string): Blob {
|
||||||
link.href = url;
|
const arr = dataUrl.split(',');
|
||||||
link.download = filename;
|
const mimeMatch = arr[0].match(/:(.*?);/);
|
||||||
document.body.appendChild(link);
|
const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
|
||||||
link.click();
|
const bstr = atob(arr[1]);
|
||||||
document.body.removeChild(link);
|
let n = bstr.length;
|
||||||
|
const u8arr = new Uint8Array(n);
|
||||||
|
while (n--) {
|
||||||
|
u8arr[n] = bstr.charCodeAt(n);
|
||||||
|
}
|
||||||
|
return new Blob([u8arr], { type: mime });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
|
||||||
import { env, pipeline, RawImage } from '@huggingface/transformers';
|
import { env, AutoProcessor, AutoModel, RawImage } from '@huggingface/transformers';
|
||||||
|
|
||||||
// Configure transformers.js
|
// Configure transformers.js
|
||||||
env.allowLocalModels = false;
|
env.allowLocalModels = false;
|
||||||
|
|
@ -11,12 +11,13 @@ export type VisionProgress = {
|
||||||
file?: string;
|
file?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ViT-GPT2 is the ONLY working model for browser-based image captioning
|
// We use Florence-2-base for a good balance of speed and accuracy (~200MB - 400MB)
|
||||||
// Other models (BLIP, Florence-2, LLaVA) are not supported by transformers.js
|
// 'onnx-community/Florence-2-base-ft' is the modern standard for Transformers.js v3.
|
||||||
const MODEL_ID = 'Xenova/vit-gpt2-image-captioning';
|
const MODEL_ID = 'onnx-community/Florence-2-base-ft';
|
||||||
|
|
||||||
export class VisionService {
|
export class VisionService {
|
||||||
private captioner: any = null;
|
private model: any = null;
|
||||||
|
private processor: any = null;
|
||||||
private isLoading = false;
|
private isLoading = false;
|
||||||
private isReady = false;
|
private isReady = false;
|
||||||
|
|
||||||
|
|
@ -45,10 +46,13 @@ export class VisionService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Loading Vision Model...');
|
console.log('Loading Vision Model...');
|
||||||
if (onProgress) onProgress({ status: 'Loading Vision Model...' });
|
if (onProgress) onProgress({ status: 'Loading Processor...' });
|
||||||
|
|
||||||
// Use the pipeline API - much simpler and faster
|
this.processor = await AutoProcessor.from_pretrained(MODEL_ID);
|
||||||
this.captioner = await pipeline('image-to-text', 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) => {
|
progress_callback: (progress: any) => {
|
||||||
if (onProgress && progress.status === 'progress') {
|
if (onProgress && progress.status === 'progress') {
|
||||||
onProgress({
|
onProgress({
|
||||||
|
|
@ -71,8 +75,8 @@ export class VisionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analyzes an image (Base64 or URL) and returns a description.
|
* Analyzes an image (Base64 or URL) and returns a detailed description.
|
||||||
* Uses vit-gpt2 for fast captioning.
|
* We use the '<MORE_DETAILED_CAPTION>' task for Florence-2.
|
||||||
*/
|
*/
|
||||||
async analyzeImage(imageBase64: string): Promise<string> {
|
async analyzeImage(imageBase64: string): Promise<string> {
|
||||||
if (!this.isReady) {
|
if (!this.isReady) {
|
||||||
|
|
@ -83,47 +87,32 @@ export class VisionService {
|
||||||
// Handle data URL prefix if present
|
// Handle data URL prefix if present
|
||||||
const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`;
|
const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`;
|
||||||
|
|
||||||
let image = await RawImage.fromURL(cleanBase64);
|
const image = await RawImage.fromURL(cleanBase64);
|
||||||
|
|
||||||
// Keep higher resolution for better detail detection
|
// Task: Detailed Captioning is best for understanding diagrams
|
||||||
if (image.width > 512 || image.height > 512) {
|
const text = '<MORE_DETAILED_CAPTION>';
|
||||||
image = await image.resize(512, 512);
|
const inputs = await this.processor(image, text);
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Starting enhanced image analysis...');
|
const generatedIds = await this.model.generate({
|
||||||
const startTime = performance.now();
|
...inputs,
|
||||||
|
max_new_tokens: 512, // Sufficient for a description
|
||||||
|
});
|
||||||
|
|
||||||
// Run multiple passes for more comprehensive description
|
const generatedText = this.processor.batch_decode(generatedIds, {
|
||||||
const results = await Promise.all([
|
skip_special_tokens: false,
|
||||||
// Pass 1: Detailed description
|
})[0];
|
||||||
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();
|
// Post-process to extract the caption
|
||||||
console.log(`Vision analysis completed in ${((endTime - startTime) / 1000).toFixed(1)}s`);
|
// Florence-2 output format usually includes the task token
|
||||||
|
const parsedAnswer = this.processor.post_process_generation(
|
||||||
|
generatedText,
|
||||||
|
text,
|
||||||
|
image.size
|
||||||
|
);
|
||||||
|
|
||||||
// Combine descriptions for richer output
|
// Access the dictionary result. For CAPTION tasks, it's usually under '<MORE_DETAILED_CAPTION>' or similar key
|
||||||
const caption1 = results[0]?.[0]?.generated_text || '';
|
// Ideally post_process_generation returns { '<MORE_DETAILED_CAPTION>': "Description..." }
|
||||||
const caption2 = results[1]?.[0]?.generated_text || '';
|
return parsedAnswer['<MORE_DETAILED_CAPTION>'] || typeof parsedAnswer === 'string' ? parsedAnswer : JSON.stringify(parsedAnswer);
|
||||||
|
|
||||||
// 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) {
|
} catch (error) {
|
||||||
console.error('Vision analysis failed:', error);
|
console.error('Vision analysis failed:', error);
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ export type WebLlmProgress = {
|
||||||
timeElapsed: number;
|
timeElapsed: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Qwen3-0.6B is fast and works well with simple Mermaid generation prompts
|
// Latest "Tiny" model with high instruction adherence
|
||||||
const DEFAULT_MODEL = "Qwen3-0.6B-q4f32_1-MLC";
|
const DEFAULT_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC";
|
||||||
|
|
||||||
export class WebLlmService {
|
export class WebLlmService {
|
||||||
private engine: MLCEngine | null = null;
|
private engine: MLCEngine | null = null;
|
||||||
|
|
@ -73,31 +73,21 @@ export class WebLlmService {
|
||||||
throw new Error("WebLLM Engine not initialized. Please load the model first.");
|
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({
|
const completion = await this.engine.chat.completions.create({
|
||||||
messages,
|
messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
temperature: 0, // Deterministic output for code
|
temperature: 0.1, // Low temp for code/logic generation
|
||||||
max_tokens: 512, // Mermaid code is compact
|
max_tokens: 4096, // Sufficient for diagrams
|
||||||
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
|
// Create a generator to stream chunks easily
|
||||||
async function* streamGenerator() {
|
async function* streamGenerator() {
|
||||||
let tokenCount = 0;
|
|
||||||
for await (const chunk of completion) {
|
for await (const chunk of completion) {
|
||||||
const content = chunk.choices[0]?.delta?.content || "";
|
const content = chunk.choices[0]?.delta?.content || "";
|
||||||
if (content) {
|
if (content) {
|
||||||
tokenCount++;
|
|
||||||
if (tokenCount === 1) console.log('WebLLM: First token received');
|
|
||||||
yield content;
|
yield content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const endTime = performance.now();
|
|
||||||
console.log(`WebLLM: Generation complete (${tokenCount} tokens, ${((endTime - startTime) / 1000).toFixed(1)}s)`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return streamGenerator();
|
return streamGenerator();
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,42 @@ export function Editor() {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</main>
|
||||||
|
|
||||||
{/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */}
|
{/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */}
|
||||||
|
|
|
||||||
|
|
@ -417,3 +417,35 @@
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
animation: fade-in 0.2s ease-out forwards;
|
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;
|
||||||
|
}
|
||||||
50
todo.md
50
todo.md
|
|
@ -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
|
|
||||||
Loading…
Reference in a new issue