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

@ -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
View file

@ -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"
}, },

View file

@ -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",

View file

@ -231,7 +231,8 @@ export function FlowCanvas() {
size={1} size={1}
/> />
{/* Control Panel - Top Right (Unified Toolkit) */} {/* 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' : ''}`}> <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"> <div className="relative">
<button <button
@ -324,6 +325,62 @@ export function FlowCanvas() {
</div> </div>
</Panel> </Panel>
)}
{/* 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>
{/* 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>
{/* 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>
{/* Divider */}
<div className="w-px h-6 bg-slate-200 dark:bg-white/10 mx-1" />
{/* 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>
{/* 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) */} {/* 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">

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 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

View file

@ -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,8 +409,6 @@ 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="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="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 <button
@ -132,13 +435,10 @@ export function EditorHeader() {
<span className="hidden sm:inline">Code</span> <span className="hidden sm:inline">Code</span>
</button> </button>
</div> </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' ? (
<> <>
@ -157,7 +457,6 @@ export function EditorHeader() {
</> </>
)} )}
</div> </div>
)}
<button <button
onClick={() => setFocusMode(!focusMode)} onClick={() => setFocusMode(!focusMode)}

View file

@ -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';

View file

@ -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 });
} }

View file

@ -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);

View file

@ -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();

View file

@ -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 */}

View file

@ -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
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