Update index.html
Browse files- index.html +1332 -18
index.html
CHANGED
|
@@ -1,19 +1,1333 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
| 6 |
+
<title>Node Image Processor</title>
|
| 7 |
+
<script src="https://cdn.jsdelivr.net/pyodide/v0.29.3/full/pyodide.js"></script>
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
|
| 9 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/theme/material-darker.min.css">
|
| 10 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
|
| 11 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/python/python.min.js"></script>
|
| 12 |
+
<style>
|
| 13 |
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
| 14 |
+
:root{
|
| 15 |
+
--bg:#0d1117;--surface:#161b22;--surface2:#1c2333;--surface3:#222d3d;
|
| 16 |
+
--border:#30363d;--accent:#ff6b6b;--accent2:#4ecdc4;--text:#e6edf3;
|
| 17 |
+
--muted:#8b949e;--success:#3fb950;--warning:#d29922;--danger:#f85149;
|
| 18 |
+
--sidebar-w:clamp(160px,14vw,230px);--toolbar-h:2.5rem;--radius:.5rem;
|
| 19 |
+
--font:.8125rem;--font-sm:.6875rem;--font-xs:.5625rem
|
| 20 |
+
}
|
| 21 |
+
html,body{width:100%;height:100%;overflow:hidden;font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);font-size:16px}
|
| 22 |
+
#loader{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.875rem;background:var(--bg);color:var(--text);z-index:9999;transition:opacity .5s}
|
| 23 |
+
#loader.done{opacity:0;pointer-events:none}
|
| 24 |
+
.spinner{width:2.25rem;height:2.25rem;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}
|
| 25 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 26 |
+
#ldMsg{font-size:var(--font);font-weight:700}
|
| 27 |
+
#ldSub{font-size:var(--font-sm);color:var(--muted);max-width:25rem;text-align:center;line-height:1.4}
|
| 28 |
+
#toast{position:fixed;bottom:1.5rem;left:50%;transform:translateX(-50%) translateY(4rem);padding:.5rem 1.375rem;border-radius:var(--radius);font-size:var(--font-sm);font-weight:700;z-index:10000;opacity:0;transition:transform .3s cubic-bezier(.4,0,.2,1),opacity .3s;pointer-events:none;white-space:nowrap}
|
| 29 |
+
#toast.show{transform:translateX(-50%) translateY(0);opacity:1}
|
| 30 |
+
#toast.ok{background:#0f2a1a;color:var(--success);border:1px solid #1a4028}
|
| 31 |
+
#toast.err{background:#2a0f0f;color:var(--danger);border:1px solid #401a1a}
|
| 32 |
+
#toast.inf{background:#0f1a2a;color:#58a6ff;border:1px solid #1a2840}
|
| 33 |
+
#app{display:flex;width:100%;height:100%}
|
| 34 |
+
/* SIDEBAR */
|
| 35 |
+
#sidebar{width:var(--sidebar-w);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;transition:width .2s}
|
| 36 |
+
.sb-tabs{display:flex;border-bottom:1px solid var(--border);height:var(--toolbar-h);flex-shrink:0}
|
| 37 |
+
.sb-tab{flex:1;display:flex;align-items:center;justify-content:center;font-size:var(--font-xs);font-weight:800;cursor:pointer;color:var(--muted);border-bottom:2px solid transparent;transition:all .15s;text-transform:uppercase;letter-spacing:.5px;padding:0 .5rem}
|
| 38 |
+
.sb-tab.on{color:var(--accent);border-color:var(--accent)}
|
| 39 |
+
.sb-tab:hover:not(.on){color:var(--text)}
|
| 40 |
+
.sb-content{flex:1;overflow-y:auto;display:none}
|
| 41 |
+
.sb-content.on{display:flex;flex-direction:column}
|
| 42 |
+
.sb-title{padding:.625rem .875rem .25rem;font-size:var(--font-xs);text-transform:uppercase;color:var(--muted);letter-spacing:1px;font-weight:800}
|
| 43 |
+
.sb-item{padding:.375rem .75rem;cursor:grab;border-radius:4px;margin:1px .375rem;display:flex;align-items:center;gap:.5rem;font-size:var(--font-sm);transition:background .1s;user-select:none}
|
| 44 |
+
.sb-item:hover{background:rgba(255,255,255,.05)}
|
| 45 |
+
.sb-item:active{cursor:grabbing;opacity:.7}
|
| 46 |
+
.sb-dot{width:.5rem;height:.5rem;border-radius:2px;flex-shrink:0}
|
| 47 |
+
.sb-wf-item{padding:.4375rem .75rem;cursor:pointer;border-radius:4px;margin:2px .375rem;display:flex;justify-content:space-between;align-items:center;font-size:var(--font-sm);transition:background .1s}
|
| 48 |
+
.sb-wf-item:hover{background:rgba(255,255,255,.05)}
|
| 49 |
+
.sb-wf-del{color:var(--muted);cursor:pointer;font-weight:700;font-size:.8125rem;line-height:1}
|
| 50 |
+
.sb-wf-del:hover{color:var(--danger)}
|
| 51 |
+
.sb-empty{padding:1rem .75rem;font-size:var(--font-xs);color:#2a3040;text-align:center;font-style:italic}
|
| 52 |
+
/* MAIN */
|
| 53 |
+
#main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
| 54 |
+
#toolbar{height:var(--toolbar-h);background:var(--surface);border-bottom:1px solid var(--border);display:grid;grid-template-columns:1fr auto;align-items:center;flex-shrink:0;position:relative}
|
| 55 |
+
#tabsScroll{overflow-x:auto;overflow-y:hidden;display:flex;align-items:center;gap:1px;padding:0 .5rem;height:100%;scrollbar-width:none}
|
| 56 |
+
#tabsScroll::-webkit-scrollbar{display:none}
|
| 57 |
+
.tab{padding:.3125rem .875rem;cursor:pointer;font-size:var(--font-sm);font-weight:700;border-radius:4px 4px 0 0;background:0;border:0;color:var(--muted);transition:all .12s;display:flex;align-items:center;gap:.375rem;white-space:nowrap;flex-shrink:0;height:100%;align-items:center}
|
| 58 |
+
.tab.on{background:var(--surface2);color:var(--text)}
|
| 59 |
+
.tab:hover:not(.on){color:var(--text)}
|
| 60 |
+
.tab-close{font-size:.8125rem;line-height:1;opacity:.4;margin-left:.125rem;transition:opacity .1s}
|
| 61 |
+
.tab-close:hover{opacity:1;color:var(--danger)}
|
| 62 |
+
.tab-name{outline:none;border-bottom:1px dashed transparent;padding:0 .125rem;min-width:1.875rem}
|
| 63 |
+
.tab-name:focus{border-color:var(--accent2)}
|
| 64 |
+
.tb-right{display:flex;align-items:center;gap:.3125rem;padding:0 .625rem;height:100%;border-left:1px solid var(--border);flex-shrink:0}
|
| 65 |
+
.tbtn{padding:.25rem .625rem;border-radius:4px;font-size:var(--font-xs);cursor:pointer;border:1px solid var(--border);font-weight:700;background:var(--surface2);color:var(--muted);transition:all .12s;white-space:nowrap}
|
| 66 |
+
.tbtn:hover{color:var(--text);border-color:#484f58;background:var(--surface3)}
|
| 67 |
+
.tbtn.run{background:var(--accent);color:#fff;border-color:var(--accent)}
|
| 68 |
+
.tbtn.run:hover{background:#e55a5a}
|
| 69 |
+
.tbtn.run:disabled{background:#1a1a1a;color:#444;border-color:#2a2a2a;cursor:not-allowed}
|
| 70 |
+
#statusWrap{display:flex;align-items:center;gap:.3125rem;font-size:var(--font-xs);color:var(--muted);margin-left:.25rem}
|
| 71 |
+
.sdot{width:.375rem;height:.375rem;border-radius:50%;background:var(--muted);flex-shrink:0}
|
| 72 |
+
.sdot.ok{background:var(--success);box-shadow:0 0 6px rgba(63,185,80,.5)}
|
| 73 |
+
.sdot.load{background:var(--warning);animation:bk 1s infinite}
|
| 74 |
+
@keyframes bk{0%,100%{opacity:1}50%{opacity:.2}}
|
| 75 |
+
/* SETTINGS */
|
| 76 |
+
#settingsPanel{position:absolute;top:var(--toolbar-h);right:.625rem;background:var(--surface2);border:1px solid var(--border);border-radius:8px;z-index:200;min-width:13.75rem;box-shadow:0 .5rem 2rem rgba(0,0,0,.5);display:none;overflow:hidden}
|
| 77 |
+
#settingsPanel.open{display:block}
|
| 78 |
+
.sp-head{padding:.625rem .875rem;font-size:var(--font-sm);font-weight:800;color:var(--accent);border-bottom:1px solid var(--border);background:rgba(255,107,107,.04)}
|
| 79 |
+
.sp-body{padding:.5rem .875rem .75rem}
|
| 80 |
+
.sp-row{display:flex;justify-content:space-between;align-items:center;font-size:var(--font-sm);padding:.375rem 0;border-bottom:1px solid rgba(48,54,61,.4)}
|
| 81 |
+
.sp-row:last-child{border-bottom:none}
|
| 82 |
+
.sp-row label{color:var(--text);font-size:var(--font-xs)}
|
| 83 |
+
.sp-row input[type=checkbox]{accent-color:var(--accent);width:.9375rem;height:.9375rem;cursor:pointer}
|
| 84 |
+
.sp-row select,.sp-row input[type=text],.sp-row input[type=number]{background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:.1875rem .375rem;font-size:var(--font-xs);outline:none;cursor:pointer;max-width:8rem}
|
| 85 |
+
.sp-row .tbtn{padding:.1875rem .625rem}
|
| 86 |
+
.sp-row .zoom-row{display:flex;align-items:center;gap:.25rem}
|
| 87 |
+
.sp-row .zoom-row .zbtn{width:1.5rem;height:1.5rem;display:flex;align-items:center;justify-content:center;border-radius:3px;cursor:pointer;color:var(--muted);font-size:.875rem;font-weight:700;background:var(--bg);border:1px solid var(--border);transition:all .1s}
|
| 88 |
+
.sp-row .zoom-row .zbtn:hover{color:var(--text);background:var(--surface3)}
|
| 89 |
+
.sp-row .zoom-row .zoom-val{font-size:var(--font-xs);color:var(--text);font-weight:700;min-width:2.5rem;text-align:center;background:var(--bg);border:1px solid var(--border);border-radius:3px;padding:.1875rem .375rem;cursor:pointer}
|
| 90 |
+
/* WORKSPACE */
|
| 91 |
+
#wfPanel{flex:1;position:relative;overflow:hidden}
|
| 92 |
+
#wfC{width:100%;height:100%;position:relative;overflow:hidden;outline:none;cursor:default}
|
| 93 |
+
#wfInner{position:absolute;top:0;left:0;width:0;height:0;transform-origin:0 0}
|
| 94 |
+
#connCvs{position:absolute;inset:0;z-index:5;pointer-events:none}
|
| 95 |
+
.wf-hint{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#161e2e;font-size:.8125rem;font-weight:800;letter-spacing:1.5px;text-transform:uppercase;pointer-events:none;z-index:0;user-select:none}
|
| 96 |
+
/* MINIMAP */
|
| 97 |
+
#minimap{position:absolute;bottom:.75rem;right:.75rem;z-index:50;width:10.625rem;height:7.1875rem;background:rgba(22,27,34,.85);border:1px solid var(--border);border-radius:5px;overflow:hidden;cursor:pointer;backdrop-filter:blur(4px)}
|
| 98 |
+
#minimap canvas{width:100%;height:100%;display:block}
|
| 99 |
+
/* POPUPS */
|
| 100 |
+
.popup{position:absolute;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:.25rem;z-index:300;min-width:10.625rem;box-shadow:0 .5rem 2rem rgba(0,0,0,.55);display:none}
|
| 101 |
+
.ctx-item{padding:.375rem .625rem;font-size:var(--font-sm);cursor:pointer;border-radius:4px;display:flex;justify-content:space-between;align-items:center;transition:background .08s}
|
| 102 |
+
.ctx-item:hover{background:rgba(255,255,255,.06)}
|
| 103 |
+
.ctx-item.danger{color:var(--danger)}
|
| 104 |
+
.ctx-sep{height:1px;background:var(--border);margin:.25rem .5rem}
|
| 105 |
+
.ctx-label{padding:.25rem .625rem;font-size:var(--font-xs);color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:.5px}
|
| 106 |
+
.ctx-sub{font-size:var(--font-xs);color:var(--muted)}
|
| 107 |
+
#searchMenu{width:13.75rem;padding:.375rem}
|
| 108 |
+
#searchInput{width:100%;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:.4375rem .625rem;border-radius:4px;font-size:var(--font-sm);outline:none}
|
| 109 |
+
#searchInput:focus{border-color:var(--accent2)}
|
| 110 |
+
.search-item{padding:.375rem .5rem;font-size:var(--font-sm);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:.4375rem;transition:background .08s}
|
| 111 |
+
.search-item:hover{background:rgba(255,255,255,.06)}
|
| 112 |
+
/* NODES */
|
| 113 |
+
.node{position:absolute;min-width:11.25rem;max-width:20rem;background:var(--surface2);border:2px solid var(--border);border-radius:8px;user-select:none;z-index:10;box-shadow:0 .25rem 1.5rem rgba(0,0,0,.35);display:flex;flex-direction:column;overflow:visible}
|
| 114 |
+
.node.sel{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent),0 .25rem 1.5rem rgba(0,0,0,.4)}
|
| 115 |
+
.node.running{border-color:var(--warning);animation:npulse 1s ease-in-out infinite}
|
| 116 |
+
.node.done{border-color:var(--success);box-shadow:0 0 12px rgba(63,185,80,.2)}
|
| 117 |
+
.node.err{border-color:var(--danger);box-shadow:0 0 12px rgba(248,81,73,.2)}
|
| 118 |
+
.node.disabled{opacity:.45;filter:grayscale(.7)}
|
| 119 |
+
@keyframes npulse{0%,100%{box-shadow:0 0 6px rgba(210,153,34,.1)}50%{box-shadow:0 0 24px rgba(210,153,34,.4)}}
|
| 120 |
+
.nhdr{padding:.375rem .625rem;border-radius:6px 6px 0 0;font-size:var(--font-xs);font-weight:800;display:flex;align-items:center;justify-content:space-between;color:#fff;cursor:move;letter-spacing:.3px;flex-shrink:0;gap:.375rem}
|
| 121 |
+
.nhdr-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
| 122 |
+
.ndel{cursor:pointer;opacity:0;font-size:.9375rem;line-height:1;transition:opacity .1s;flex-shrink:0;width:1rem;text-align:center}
|
| 123 |
+
.node:hover .ndel{opacity:.5}
|
| 124 |
+
.ndel:hover{opacity:1!important}
|
| 125 |
+
.nbody{padding:.3125rem 0 .4375rem;flex:1;overflow:visible}
|
| 126 |
+
.npr{display:flex;align-items:center;padding:.125rem 0;font-size:var(--font-xs);color:var(--muted);position:relative}
|
| 127 |
+
.npr.inp{justify-content:flex-start;padding-left:0}
|
| 128 |
+
.npr.outp{justify-content:flex-end;padding-right:0}
|
| 129 |
+
.port{width:.8125rem;height:.8125rem;border-radius:50%;border:2px solid var(--border);background:var(--bg);cursor:crosshair;transition:all .12s;flex-shrink:0;position:relative;z-index:20}
|
| 130 |
+
.port:hover{transform:scale(1.5);border-color:var(--accent);background:rgba(255,107,107,.25);box-shadow:0 0 8px rgba(255,107,107,.3)}
|
| 131 |
+
.port.conn{border-color:#5a6a8a;background:#3a4a6a}
|
| 132 |
+
.npr.inp .port{margin-left:-.4375rem;margin-right:.375rem}
|
| 133 |
+
.npr.outp .port{margin-right:-.4375rem;margin-left:.375rem}
|
| 134 |
+
.plbl{font-weight:600;font-size:var(--font-xs)}
|
| 135 |
+
.nfile{padding:.1875rem .625rem;font-size:var(--font-xs);display:flex;gap:.3125rem;align-items:center;flex-wrap:wrap}
|
| 136 |
+
.nfile input[type=file]{display:none}
|
| 137 |
+
.fbtn,.sbtn{display:inline-block;padding:.1875rem .5625rem;border-radius:3px;cursor:pointer;font-size:var(--font-xs);font-weight:700;transition:all .1s}
|
| 138 |
+
.fbtn{background:var(--border);color:var(--text)}.fbtn:hover{background:#484f58}
|
| 139 |
+
.sbtn{background:#0f2a2a;color:var(--accent2);border:1px solid rgba(78,205,196,.2)}.sbtn:hover{background:#1a3a3a}
|
| 140 |
+
.nparam{padding:.125rem .625rem}
|
| 141 |
+
.nparam label{font-size:var(--font-xs);color:var(--muted);display:flex;justify-content:space-between;margin-bottom:1px}
|
| 142 |
+
.nparam input[type=range]{width:100%;accent-color:var(--accent);height:.25rem;cursor:pointer}
|
| 143 |
+
.pv{font-size:var(--font-xs);color:var(--accent);font-weight:700;min-width:1.875rem;text-align:right}
|
| 144 |
+
.nprev{margin:.25rem .5rem;border-radius:4px;overflow:hidden;background:#060a10;min-height:2rem;max-height:6.25rem;display:flex;align-items:center;justify-content:center}
|
| 145 |
+
.nprev img{max-width:100%;max-height:5.75rem;display:block;object-fit:contain}
|
| 146 |
+
.nprev .ph{color:#1a2030;font-size:var(--font-xs);padding:.625rem;text-align:center;font-style:italic}
|
| 147 |
+
.nval-display{margin:.25rem .5rem;padding:.5rem;border-radius:4px;background:rgba(33,150,243,.1);border:1px solid rgba(33,150,243,.2);text-align:center;font-size:1.25rem;font-weight:800;color:#2196F3}
|
| 148 |
+
.nres{position:absolute;bottom:0;right:0;width:.875rem;height:.875rem;cursor:nwse-resize;z-index:30;opacity:0;transition:opacity .15s}
|
| 149 |
+
.node:hover .nres{opacity:.3}
|
| 150 |
+
.nres:hover{opacity:.8!important}
|
| 151 |
+
.nres::after{content:"";position:absolute;bottom:3px;right:3px;width:7px;height:7px;border-right:2px solid var(--muted);border-bottom:2px solid var(--muted)}
|
| 152 |
+
/* RESPONSIVE */
|
| 153 |
+
@media(max-width:768px){
|
| 154 |
+
:root{--sidebar-w:0px}
|
| 155 |
+
#sidebar{position:absolute;left:0;top:0;bottom:0;z-index:150;width:0;overflow:hidden;transition:width .2s}
|
| 156 |
+
#sidebar.open{width:clamp(180px,60vw,260px)}
|
| 157 |
+
#sidebarToggle{display:flex!important}
|
| 158 |
+
#minimap{width:7rem;height:4.75rem}
|
| 159 |
+
}
|
| 160 |
+
#sidebarToggle{display:none;align-items:center;justify-content:center;width:2rem;height:2rem;cursor:pointer;color:var(--muted);font-size:1.125rem;border:0;background:0;border-radius:4px;transition:all .1s}
|
| 161 |
+
#sidebarToggle:hover{color:var(--text);background:rgba(255,255,255,.05)}
|
| 162 |
+
::-webkit-scrollbar{width:5px}
|
| 163 |
+
::-webkit-scrollbar-track{background:transparent}
|
| 164 |
+
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
| 165 |
+
::-webkit-scrollbar-thumb:hover{background:#484f58}
|
| 166 |
+
</style>
|
| 167 |
+
</head>
|
| 168 |
+
<body>
|
| 169 |
+
<div id="loader"><div class="spinner"></div><div id="ldMsg">Loading Pyodide runtime...</div><div id="ldSub"></div></div>
|
| 170 |
+
<div id="toast"></div>
|
| 171 |
+
<div id="app">
|
| 172 |
+
<div id="sidebar">
|
| 173 |
+
<div class="sb-tabs">
|
| 174 |
+
<div class="sb-tab on" data-sb="nodes">Nodes</div>
|
| 175 |
+
<div class="sb-tab" data-sb="library">Library</div>
|
| 176 |
+
</div>
|
| 177 |
+
<div class="sb-content on" id="sbNodes"></div>
|
| 178 |
+
<div class="sb-content" id="sbLibrary">
|
| 179 |
+
<div class="sb-title">Saved Workflows</div>
|
| 180 |
+
<div id="wfList"><div class="sb-empty">No saved workflows yet</div></div>
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
<div id="main">
|
| 184 |
+
<div id="toolbar">
|
| 185 |
+
<div style="display:flex;align-items:center">
|
| 186 |
+
<div id="sidebarToggle" onclick="document.getElementById('sidebar').classList.toggle('open')">☰</div>
|
| 187 |
+
<div id="tabsScroll"></div>
|
| 188 |
+
</div>
|
| 189 |
+
<div class="tb-right">
|
| 190 |
+
<button class="tbtn" id="btnAddTab" title="New tab">+</button>
|
| 191 |
+
<div style="width:1px;height:1.125rem;background:var(--border)"></div>
|
| 192 |
+
<button class="tbtn" id="btnSettings" title="Settings">⚙</button>
|
| 193 |
+
<button class="tbtn" id="btnSave" title="Save">Save</button>
|
| 194 |
+
<button class="tbtn" id="btnClear" title="Clear">Clear</button>
|
| 195 |
+
<button class="tbtn run" id="btnRun" disabled>Run</button>
|
| 196 |
+
<div id="statusWrap"><span class="sdot load" id="sDot"></span><span id="sTxt">Loading...</span></div>
|
| 197 |
+
</div>
|
| 198 |
+
<div id="settingsPanel">
|
| 199 |
+
<div class="sp-head">Settings</div>
|
| 200 |
+
<div class="sp-body">
|
| 201 |
+
<div class="sp-row"><label>Force run all</label><input type="checkbox" id="forceRunCheck"></div>
|
| 202 |
+
<div class="sp-row"><label>Grid size</label><select id="gridSizeSel"><option value="15">Tiny</option><option value="25" selected>Small</option><option value="40">Medium</option><option value="60">Large</option></select></div>
|
| 203 |
+
<div class="sp-row"><label>Snap to grid</label><input type="checkbox" id="snapCheck"></div>
|
| 204 |
+
<div class="sp-row"><label>Zoom</label>
|
| 205 |
+
<div class="zoom-row">
|
| 206 |
+
<button class="zbtn" onclick="zoomStep(-1)" title="Zoom out">−</button>
|
| 207 |
+
<span class="zoom-val" id="zoomPct" onclick="this.focus()" onkeydown="if(event.key==='Enter'){setZoomPct(this);event.preventDefault()}" onblur="setZoomPct(this)">100%</span>
|
| 208 |
+
<button class="zbtn" onclick="zoomStep(1)" title="Zoom in">+</button>
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
<div class="sp-row"><label>Reset view</label><button class="tbtn" onclick="resetZoom()">Reset</button></div>
|
| 212 |
+
<div class="sp-row"><label>Fit all nodes</label><button class="tbtn" onclick="fitAll()">Fit</button></div>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
</div>
|
| 216 |
+
<div id="wfPanel">
|
| 217 |
+
<div id="wfC" tabindex="0">
|
| 218 |
+
<div id="wfInner"></div>
|
| 219 |
+
<canvas id="connCvs"></canvas>
|
| 220 |
+
<div class="wf-hint" id="wfHint">Drag nodes here or double-click to add</div>
|
| 221 |
+
</div>
|
| 222 |
+
<div id="minimap" title="Click to navigate"><canvas id="minimapCvs"></canvas></div>
|
| 223 |
+
<div class="popup" id="ctxMenu"></div>
|
| 224 |
+
<div class="popup" id="searchMenu"><input type="text" id="searchInput" placeholder="Search nodes..."><div id="searchResults"></div></div>
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
<script>
|
| 229 |
+
/* ═══════════════════════════════════════════
|
| 230 |
+
PYTHON: Graph + Image Processing
|
| 231 |
+
═══════════════════════════════════════════ */
|
| 232 |
+
const PY_GRAPH = `
|
| 233 |
+
import json
|
| 234 |
+
NODE_DEFS = {
|
| 235 |
+
"image_input": {"name": "Image Input", "color": "#4CAF50", "ins": [], "outs": ["image"], "params": [], "file": True, "value_node": False},
|
| 236 |
+
"image_output": {"name": "Image Output", "color": "#FF5722", "ins": ["image"], "outs": [], "params": [], "file": False, "value_node": False},
|
| 237 |
+
"grayscale": {"name": "Grayscale", "color": "#607D8B", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False},
|
| 238 |
+
"gaussian_blur": {"name": "Gaussian Blur", "color": "#FF9800", "ins": ["image"], "outs": ["image"], "params": [{"id":"sigma","label":"Sigma","min":0.1,"max":10,"step":0.1,"def":1.5}], "file": False, "value_node": False},
|
| 239 |
+
"sobel": {"name": "Sobel Edge", "color": "#009688", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False},
|
| 240 |
+
"canny": {"name": "Canny Edge", "color": "#3F51B5", "ins": ["image"], "outs": ["image"], "params": [{"id":"sigma","label":"Sigma","min":0.1,"max":5,"step":0.1,"def":1.0},{"id":"low_t","label":"Low Thresh","min":0,"max":1,"step":0.01,"def":0.1},{"id":"high_t","label":"High Thresh","min":0,"max":1,"step":0.01,"def":0.3}], "file": False, "value_node": False},
|
| 241 |
+
"threshold": {"name": "Otsu Threshold","color": "#795548", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False},
|
| 242 |
+
"invert": {"name": "Invert", "color": "#E91E63", "ins": ["image"], "outs": ["image"], "params": [], "file": False, "value_node": False},
|
| 243 |
+
"contour": {"name": "Contour Detect","color": "#9C27B0", "ins": ["image"], "outs": ["image"], "params": [{"id":"level","label":"Level","min":0,"max":1,"step":0.01,"def":0.5}], "file": False, "value_node": False},
|
| 244 |
+
"rotate": {"name": "Rotate", "color": "#00BCD4", "ins": ["image"], "outs": ["image"], "params": [{"id":"angle","label":"Angle","min":-180,"max":180,"step":1,"def":90}], "file": False, "value_node": False},
|
| 245 |
+
"resize": {"name": "Resize", "color": "#8BC34A", "ins": ["image"], "outs": ["image"], "params": [{"id":"scale","label":"Scale","min":0.1,"max":3,"step":0.1,"def":0.5}], "file": False, "value_node": False},
|
| 246 |
+
"int_value": {"name": "Int Value", "color": "#2196F3", "ins": [], "outs": ["value"], "params": [{"id":"value","label":"Value","min":-9999,"max":9999,"step":1,"def":0}], "file": False, "value_node": True},
|
| 247 |
+
"float_value": {"name": "Float Value", "color": "#03A9F4", "ins": [], "outs": ["value"], "params": [{"id":"value","label":"Value","min":-9999,"max":9999,"step":0.01,"def":0.0}], "file": False, "value_node": True}
|
| 248 |
+
}
|
| 249 |
+
class WorkflowGraph:
|
| 250 |
+
def __init__(self):
|
| 251 |
+
self.nodes = {}
|
| 252 |
+
self.edges = []
|
| 253 |
+
def add_node(self, nid, ntype):
|
| 254 |
+
nid = int(nid)
|
| 255 |
+
params = {p["id"]: p["def"] for p in NODE_DEFS.get(ntype, {}).get("params", [])}
|
| 256 |
+
self.nodes[nid] = {"type": ntype, "params": params, "promoted": [], "disabled": False}
|
| 257 |
+
return True
|
| 258 |
+
def remove_node(self, nid):
|
| 259 |
+
nid = int(nid)
|
| 260 |
+
self.nodes.pop(nid, None)
|
| 261 |
+
self.edges = [e for e in self.edges if int(e["fn"]) != nid and int(e["tn"]) != nid]
|
| 262 |
+
def update_param(self, nid, pid, val):
|
| 263 |
+
nid = int(nid)
|
| 264 |
+
if nid in self.nodes: self.nodes[nid]["params"][pid] = val
|
| 265 |
+
def toggle_disable(self, nid):
|
| 266 |
+
nid = int(nid)
|
| 267 |
+
if nid in self.nodes:
|
| 268 |
+
self.nodes[nid]["disabled"] = not self.nodes[nid]["disabled"]
|
| 269 |
+
return self.nodes[nid]["disabled"]
|
| 270 |
+
return False
|
| 271 |
+
def promote_param(self, nid, pid):
|
| 272 |
+
nid = int(nid)
|
| 273 |
+
node = self.nodes.get(nid)
|
| 274 |
+
if node and pid in node.get("params",{}) and pid not in node.get("promoted",[]):
|
| 275 |
+
node.setdefault("promoted",[]).append(pid)
|
| 276 |
+
return True
|
| 277 |
+
return False
|
| 278 |
+
def demote_param(self, nid, pid):
|
| 279 |
+
nid = int(nid)
|
| 280 |
+
node = self.nodes.get(nid)
|
| 281 |
+
if node and pid in node.get("promoted",[]):
|
| 282 |
+
node["promoted"].remove(pid)
|
| 283 |
+
return True
|
| 284 |
+
return False
|
| 285 |
+
def get_inputs(self, nid):
|
| 286 |
+
nid = int(nid)
|
| 287 |
+
node = self.nodes.get(nid)
|
| 288 |
+
if not node: return []
|
| 289 |
+
base = list(NODE_DEFS.get(node["type"], {}).get("ins", []))
|
| 290 |
+
for p in node.get("promoted", []): base.append(p)
|
| 291 |
+
return base
|
| 292 |
+
def get_outs(self, nid):
|
| 293 |
+
nid = int(nid)
|
| 294 |
+
node = self.nodes.get(nid)
|
| 295 |
+
if not node: return []
|
| 296 |
+
return list(NODE_DEFS.get(node["type"], {}).get("outs", []))
|
| 297 |
+
def is_value_node(self, nid):
|
| 298 |
+
nid = int(nid)
|
| 299 |
+
node = self.nodes.get(nid)
|
| 300 |
+
if not node: return False
|
| 301 |
+
return NODE_DEFS.get(node["type"], {}).get("value_node", False)
|
| 302 |
+
def add_edge(self, fn, fp, tn, tp):
|
| 303 |
+
fn, fp, tn, tp = int(fn), int(fp), int(tn), int(tp)
|
| 304 |
+
if fn == tn: return False
|
| 305 |
+
if self._would_cycle(fn, tn): return False
|
| 306 |
+
self.edges = [e for e in self.edges if not (int(e["tn"]) == tn and int(e["tp"]) == tp)]
|
| 307 |
+
self.edges.append({"fn": fn, "fp": fp, "tn": tn, "tp": tp})
|
| 308 |
+
return True
|
| 309 |
+
def remove_edge(self, fn, fp, tn, tp):
|
| 310 |
+
fn, fp, tn, tp = int(fn), int(fp), int(tn), int(tp)
|
| 311 |
+
self.edges = [e for e in self.edges if not (int(e["fn"]) == fn and int(e["fp"]) == fp and int(e["tn"]) == tn and int(e["tp"]) == tp)]
|
| 312 |
+
def _would_cycle(self, fn, tn):
|
| 313 |
+
visited, stack = set(), [tn]
|
| 314 |
+
while stack:
|
| 315 |
+
cur = stack.pop()
|
| 316 |
+
if cur == fn: return True
|
| 317 |
+
if cur in visited: continue
|
| 318 |
+
visited.add(cur)
|
| 319 |
+
for e in self.edges:
|
| 320 |
+
if int(e["fn"]) == cur: stack.append(int(e["tn"]))
|
| 321 |
+
return False
|
| 322 |
+
def topological_sort(self):
|
| 323 |
+
int_nodes = {int(k) for k in self.nodes}
|
| 324 |
+
in_deg = {nid: 0 for nid in int_nodes}
|
| 325 |
+
adj = {nid: [] for nid in int_nodes}
|
| 326 |
+
for e in self.edges:
|
| 327 |
+
fn, tn = int(e["fn"]), int(e["tn"])
|
| 328 |
+
if fn in in_deg and tn in in_deg:
|
| 329 |
+
in_deg[tn] += 1
|
| 330 |
+
adj[fn].append(tn)
|
| 331 |
+
queue = [nid for nid, d in in_deg.items() if d == 0]
|
| 332 |
+
order = []
|
| 333 |
+
while queue:
|
| 334 |
+
nid = queue.pop(0)
|
| 335 |
+
order.append(nid)
|
| 336 |
+
for nb in adj.get(nid, []):
|
| 337 |
+
in_deg[nb] -= 1
|
| 338 |
+
if in_deg[nb] == 0: queue.append(nb)
|
| 339 |
+
return order if len(order) == len(int_nodes) else None
|
| 340 |
+
def get_state(self):
|
| 341 |
+
return {"nodes": self.nodes, "edges": self.edges}
|
| 342 |
+
def load_state(self, state):
|
| 343 |
+
self.nodes = {int(k): v for k, v in state.get("nodes", {}).items()}
|
| 344 |
+
for v in self.nodes.values():
|
| 345 |
+
v.setdefault("promoted", [])
|
| 346 |
+
v.setdefault("disabled", False)
|
| 347 |
+
self.edges = state.get("edges", [])
|
| 348 |
+
def clear(self):
|
| 349 |
+
self.nodes = {}
|
| 350 |
+
self.edges = []
|
| 351 |
+
graph = WorkflowGraph()
|
| 352 |
+
`;
|
| 353 |
+
const PY_PROCESSOR = `
|
| 354 |
+
import numpy as np
|
| 355 |
+
import base64, io, json
|
| 356 |
+
from PIL import Image
|
| 357 |
+
def process_image(input_b64, operation, params_json):
|
| 358 |
+
params = json.loads(params_json)
|
| 359 |
+
img_data = base64.b64decode(input_b64)
|
| 360 |
+
img = Image.open(io.BytesIO(img_data)).convert("RGBA")
|
| 361 |
+
arr = np.array(img, dtype=np.float64) / 255.0
|
| 362 |
+
rgb = arr[:, :, :3]
|
| 363 |
+
if operation == "grayscale":
|
| 364 |
+
from skimage.color import rgb2gray
|
| 365 |
+
result = rgb2gray(rgb)
|
| 366 |
+
elif operation == "gaussian_blur":
|
| 367 |
+
from skimage.filters import gaussian
|
| 368 |
+
result = gaussian(rgb, sigma=params.get("sigma", 1.5), channel_axis=-1)
|
| 369 |
+
elif operation == "sobel":
|
| 370 |
+
from skimage.filters import sobel; from skimage.color import rgb2gray
|
| 371 |
+
result = sobel(rgb2gray(rgb))
|
| 372 |
+
elif operation == "canny":
|
| 373 |
+
from skimage.feature import canny; from skimage.color import rgb2gray
|
| 374 |
+
g = rgb2gray(rgb)
|
| 375 |
+
result = canny(g, sigma=params.get("sigma",1.0), low_threshold=params.get("low_t",0.1), high_threshold=params.get("high_t",0.3)).astype(np.float64)
|
| 376 |
+
elif operation == "threshold":
|
| 377 |
+
from skimage.filters import threshold_otsu; from skimage.color import rgb2gray
|
| 378 |
+
g = rgb2gray(rgb)
|
| 379 |
+
result = (g > threshold_otsu(g)).astype(np.float64)
|
| 380 |
+
elif operation == "invert":
|
| 381 |
+
result = 1.0 - rgb
|
| 382 |
+
elif operation == "contour":
|
| 383 |
+
from skimage.measure import find_contours; from skimage.color import rgb2gray
|
| 384 |
+
g = rgb2gray(rgb)
|
| 385 |
+
contours = find_contours(g, level=params.get("level", 0.5))
|
| 386 |
+
result = np.zeros_like(g)
|
| 387 |
+
for c in contours:
|
| 388 |
+
rr = np.clip(np.round(c[:,0]).astype(int), 0, result.shape[0]-1)
|
| 389 |
+
cc = np.clip(np.round(c[:,1]).astype(int), 0, result.shape[1]-1)
|
| 390 |
+
result[rr, cc] = 1.0
|
| 391 |
+
elif operation == "rotate":
|
| 392 |
+
from skimage.transform import rotate as sk_rotate
|
| 393 |
+
result = sk_rotate(rgb, angle=params.get("angle", 90), resize=True, channel_axis=-1)
|
| 394 |
+
elif operation == "resize":
|
| 395 |
+
from skimage.transform import resize as sk_resize
|
| 396 |
+
s = params.get("scale", 0.5)
|
| 397 |
+
nh, nw = max(1, int(rgb.shape[0]*s)), max(1, int(rgb.shape[1]*s))
|
| 398 |
+
result = sk_resize(rgb, (nh, nw), channel_axis=-1, anti_aliasing=True)
|
| 399 |
+
else:
|
| 400 |
+
result = rgb
|
| 401 |
+
if result.ndim == 2:
|
| 402 |
+
result = np.stack([result]*3, axis=-1)
|
| 403 |
+
out = (np.clip(result, 0, 1) * 255).astype(np.uint8)
|
| 404 |
+
alpha = np.full((*out.shape[:2], 1), 255, dtype=np.uint8)
|
| 405 |
+
rgba = np.concatenate([out, alpha], axis=-1)
|
| 406 |
+
buf = io.BytesIO()
|
| 407 |
+
Image.fromarray(rgba, "RGBA").save(buf, format="PNG")
|
| 408 |
+
return base64.b64encode(buf.getvalue()).decode("ascii")
|
| 409 |
+
`;
|
| 410 |
+
/* ═══════════════════════════════════════════
|
| 411 |
+
STATE & CONFIG
|
| 412 |
+
═══════════════════════════════════════════ */
|
| 413 |
+
let pyodide = null, pyReady = false;
|
| 414 |
+
let nextNid = 1, mxZ = 10;
|
| 415 |
+
let zoom = 1, panX = 0, panY = 0, gridSize = 25;
|
| 416 |
+
const ZOOM_STEPS = [0.1,0.15,0.25,0.33,0.5,0.67,0.75,1,1.25,1.5,2,2.5,3,4];
|
| 417 |
+
let dragInfo = null, connInfo = null, tempEnd = null, panInfo = null;
|
| 418 |
+
let workflows = [], activeWfId = null, wfCounter = 1;
|
| 419 |
+
let nodeMeta = {};
|
| 420 |
+
const $ = s => document.querySelector(s);
|
| 421 |
+
const $$ = s => document.querySelectorAll(s);
|
| 422 |
+
const wfC = $('#wfC'), wfInner = $('#wfInner');
|
| 423 |
+
const cvs = $('#connCvs'), cx = cvs.getContext('2d');
|
| 424 |
+
const mmCvs = $('#minimapCvs'), mmCx = mmCvs.getContext('2d');
|
| 425 |
+
/* ═══════════════════════════════════════════
|
| 426 |
+
UTILITIES
|
| 427 |
+
═══════════════════════════════════════════ */
|
| 428 |
+
function toast(msg, type='inf') {
|
| 429 |
+
const t = $('#toast'); t.textContent = msg;
|
| 430 |
+
t.className = type + ' show';
|
| 431 |
+
clearTimeout(t._t); t._t = setTimeout(() => t.className = '', 2800);
|
| 432 |
+
}
|
| 433 |
+
function py(code) { return pyReady ? pyodide.runPython(code) : null; }
|
| 434 |
+
function pyJson(code) { try { return JSON.parse(py(code)); } catch(e) { return null; } }
|
| 435 |
+
function hashStr(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i) | 0; return h; }
|
| 436 |
+
function portCenter(el) {
|
| 437 |
+
const cr = wfC.getBoundingClientRect(), pr = el.getBoundingClientRect();
|
| 438 |
+
return { x: pr.left + pr.width/2 - cr.left, y: pr.top + pr.height/2 - cr.top };
|
| 439 |
+
}
|
| 440 |
+
function viewportCenter() {
|
| 441 |
+
const r = wfC.getBoundingClientRect();
|
| 442 |
+
return { x: (r.width/2 - panX) / zoom, y: (r.height/2 - panY) / zoom };
|
| 443 |
+
}
|
| 444 |
+
/* ═══════════════════════════════════════════
|
| 445 |
+
CANVAS / GRID / ZOOM
|
| 446 |
+
═══════════════════════════════════════════ */
|
| 447 |
+
function resizeCvs() {
|
| 448 |
+
cvs.width = wfC.clientWidth; cvs.height = wfC.clientHeight;
|
| 449 |
+
mmCvs.width = 170; mmCvs.height = 115;
|
| 450 |
+
updateGrid(); drawConns(); drawMinimap();
|
| 451 |
+
}
|
| 452 |
+
new ResizeObserver(resizeCvs).observe(wfC);
|
| 453 |
+
function updateGrid() {
|
| 454 |
+
const s = gridSize * zoom;
|
| 455 |
+
const ox = ((panX % s) + s) % s, oy = ((panY % s) + s) % s;
|
| 456 |
+
wfC.style.backgroundImage = 'radial-gradient(circle, rgba(48,54,61,0.55) 1px, transparent 1px)';
|
| 457 |
+
wfC.style.backgroundSize = s + 'px ' + s + 'px';
|
| 458 |
+
wfC.style.backgroundPosition = ox + 'px ' + oy + 'px';
|
| 459 |
+
}
|
| 460 |
+
function applyTransform() {
|
| 461 |
+
wfInner.style.transform = `translate(${panX}px,${panY}px) scale(${zoom})`;
|
| 462 |
+
updateGrid(); drawConns(); drawMinimap(); updateZoomPct();
|
| 463 |
+
}
|
| 464 |
+
function resetZoom() { zoom = 1; panX = 0; panY = 0; applyTransform(); }
|
| 465 |
+
function zoomStep(dir) {
|
| 466 |
+
const idx = ZOOM_STEPS.reduce((best, v, i) => Math.abs(v - zoom) < Math.abs(ZOOM_STEPS[best] - zoom) ? i : best, 0);
|
| 467 |
+
const ni = Math.max(0, Math.min(ZOOM_STEPS.length - 1, idx + dir));
|
| 468 |
+
const nz = ZOOM_STEPS[ni];
|
| 469 |
+
const r = wfC.getBoundingClientRect();
|
| 470 |
+
const cx = r.width / 2, cy = r.height / 2;
|
| 471 |
+
panX = cx - (cx - panX) * (nz / zoom);
|
| 472 |
+
panY = cy - (cy - panY) * (nz / zoom);
|
| 473 |
+
zoom = nz; applyTransform();
|
| 474 |
+
}
|
| 475 |
+
function updateZoomPct() { $('#zoomPct').textContent = Math.round(zoom * 100) + '%'; }
|
| 476 |
+
function setZoomPct(el) {
|
| 477 |
+
const v = parseInt(el.textContent);
|
| 478 |
+
if (!isNaN(v) && v > 0) {
|
| 479 |
+
const nz = Math.max(0.1, Math.min(4, v / 100));
|
| 480 |
+
const r = wfC.getBoundingClientRect();
|
| 481 |
+
const cx = r.width/2, cy = r.height/2;
|
| 482 |
+
panX = cx - (cx - panX) * (nz / zoom);
|
| 483 |
+
panY = cy - (cy - panY) * (nz / zoom);
|
| 484 |
+
zoom = nz; applyTransform();
|
| 485 |
+
} else updateZoomPct();
|
| 486 |
+
}
|
| 487 |
+
function fitAll() {
|
| 488 |
+
const keys = Object.keys(nodeMeta);
|
| 489 |
+
if (!keys.length) { resetZoom(); return; }
|
| 490 |
+
let x1=Infinity, y1=Infinity, x2=-Infinity, y2=-Infinity;
|
| 491 |
+
keys.forEach(k => { const m = nodeMeta[k]; x1=Math.min(x1,m.x); y1=Math.min(y1,m.y); x2=Math.max(x2,m.x+m.w); y2=Math.max(y2,m.y+m.h); });
|
| 492 |
+
const pad = 60, cw = x2-x1+pad*2, ch = y2-y1+pad*2;
|
| 493 |
+
const vw = wfC.clientWidth, vh = wfC.clientHeight;
|
| 494 |
+
zoom = Math.max(0.15, Math.min(vw/cw, vh/ch, 2));
|
| 495 |
+
panX = (vw - cw*zoom)/2 - (x1-pad)*zoom;
|
| 496 |
+
panY = (vh - ch*zoom)/2 - (y1-pad)*zoom;
|
| 497 |
+
applyTransform();
|
| 498 |
+
}
|
| 499 |
+
/* ═══════════════════════════════════════════
|
| 500 |
+
CONNECTION DRAWING
|
| 501 |
+
═══════════════════════════════════════════ */
|
| 502 |
+
function bezier(x1, y1, x2, y2, col, alpha) {
|
| 503 |
+
const cp = Math.max(40, Math.abs(x2-x1) * 0.45);
|
| 504 |
+
cx.save();
|
| 505 |
+
cx.globalAlpha = alpha * 0.12;
|
| 506 |
+
cx.beginPath(); cx.moveTo(x1,y1); cx.bezierCurveTo(x1+cp,y1, x2-cp,y2, x2,y2);
|
| 507 |
+
cx.strokeStyle = col; cx.lineWidth = 8; cx.stroke();
|
| 508 |
+
cx.globalAlpha = alpha * 0.85;
|
| 509 |
+
cx.beginPath(); cx.moveTo(x1,y1); cx.bezierCurveTo(x1+cp,y1, x2-cp,y2, x2,y2);
|
| 510 |
+
cx.strokeStyle = col; cx.lineWidth = 2.5; cx.lineCap = 'round'; cx.stroke();
|
| 511 |
+
cx.restore();
|
| 512 |
+
}
|
| 513 |
+
function drawConns() {
|
| 514 |
+
cx.clearRect(0, 0, cvs.width, cvs.height);
|
| 515 |
+
if (!pyReady) return;
|
| 516 |
+
const edges = pyJson('json.dumps(graph.edges)');
|
| 517 |
+
if (edges) edges.forEach(c => {
|
| 518 |
+
const fp = wfInner.querySelector(`.port[data-nid="${c.fn}"][data-pt="out"][data-pi="${c.fp}"]`);
|
| 519 |
+
const tp = wfInner.querySelector(`.port[data-nid="${c.tn}"][data-pt="in"][data-pi="${c.tp}"]`);
|
| 520 |
+
if (fp && tp) { const a = portCenter(fp), b = portCenter(tp); bezier(a.x, a.y, b.x, b.y, '#ff6b6b', 1); }
|
| 521 |
+
});
|
| 522 |
+
if (connInfo && tempEnd) {
|
| 523 |
+
const p = portCenter(connInfo.el);
|
| 524 |
+
if (connInfo.ptype === 'out') bezier(p.x, p.y, tempEnd.x, tempEnd.y, '#ff6b6b', 0.45);
|
| 525 |
+
else bezier(tempEnd.x, tempEnd.y, p.x, p.y, '#4ecdc4', 0.45);
|
| 526 |
+
}
|
| 527 |
+
}
|
| 528 |
+
function updPortStyles() {
|
| 529 |
+
if (!pyReady) return;
|
| 530 |
+
wfInner.querySelectorAll('.port').forEach(p => p.classList.remove('conn'));
|
| 531 |
+
const edges = pyJson('json.dumps(graph.edges)');
|
| 532 |
+
if (edges) edges.forEach(c => {
|
| 533 |
+
const fp = wfInner.querySelector(`.port[data-nid="${c.fn}"][data-pt="out"][data-pi="${c.fp}"]`);
|
| 534 |
+
const tp = wfInner.querySelector(`.port[data-nid="${c.tn}"][data-pt="in"][data-pi="${c.tp}"]`);
|
| 535 |
+
if (fp) fp.classList.add('conn');
|
| 536 |
+
if (tp) tp.classList.add('conn');
|
| 537 |
+
});
|
| 538 |
+
}
|
| 539 |
+
function updHint() {
|
| 540 |
+
const n = pyReady ? pyJson('len(graph.nodes)') : 0;
|
| 541 |
+
$('#wfHint').style.display = n ? 'none' : '';
|
| 542 |
+
}
|
| 543 |
+
/* ═══════════════════════════════════════════
|
| 544 |
+
MINIMAP
|
| 545 |
+
═══════════════════════════════════════════ */
|
| 546 |
+
let mmData = null;
|
| 547 |
+
function drawMinimap() {
|
| 548 |
+
mmCx.fillStyle = '#0d1117'; mmCx.fillRect(0, 0, 170, 115);
|
| 549 |
+
if (!pyReady) return;
|
| 550 |
+
const nids = pyJson('list(graph.nodes.keys())');
|
| 551 |
+
if (!nids || !nids.length) { mmData = null; return; }
|
| 552 |
+
let x1=Infinity, y1=Infinity, x2=-Infinity, y2=-Infinity;
|
| 553 |
+
nids.forEach(nid => { const m = nodeMeta[nid]; if(m){x1=Math.min(x1,m.x);y1=Math.min(y1,m.y);x2=Math.max(x2,m.x+m.w);y2=Math.max(y2,m.y+m.h);} });
|
| 554 |
+
const pad = 80; x1-=pad; y1-=pad; x2+=pad; y2+=pad;
|
| 555 |
+
const w=x2-x1, h=y2-y1, sc=Math.min(170/w, 115/h);
|
| 556 |
+
const ox=(170-w*sc)/2, oy=(115-h*sc)/2;
|
| 557 |
+
mmData = {minX:x1, minY:y1, scale:sc, ox, oy};
|
| 558 |
+
nids.forEach(nid => {
|
| 559 |
+
const m = nodeMeta[nid]; if(!m) return;
|
| 560 |
+
const st = pyJson(`json.dumps(graph.nodes[${nid}])`);
|
| 561 |
+
const td = pyJson(`json.dumps(NODE_DEFS["${st.type}"])`);
|
| 562 |
+
mmCx.fillStyle = (td.color||'#666') + '88';
|
| 563 |
+
mmCx.fillRect(ox+(m.x-x1)*sc, oy+(m.y-y1)*sc, Math.max(2,m.w*sc), Math.max(2,m.h*sc));
|
| 564 |
+
});
|
| 565 |
+
const vx=-panX/zoom, vy=-panY/zoom, vw=wfC.clientWidth/zoom, vh=wfC.clientHeight/zoom;
|
| 566 |
+
mmCx.strokeStyle='#ff6b6baa'; mmCx.lineWidth=1.5;
|
| 567 |
+
mmCx.strokeRect(ox+(vx-x1)*sc, oy+(vy-y1)*sc, vw*sc, vh*sc);
|
| 568 |
+
}
|
| 569 |
+
$('#minimap').addEventListener('click', e => {
|
| 570 |
+
if (!mmData) return;
|
| 571 |
+
const rect = e.currentTarget.getBoundingClientRect();
|
| 572 |
+
const wx = (e.clientX-rect.left-mmData.ox)/mmData.scale + mmData.minX;
|
| 573 |
+
const wy = (e.clientY-rect.top-mmData.oy)/mmData.scale + mmData.minY;
|
| 574 |
+
panX = wfC.clientWidth/2 - wx*zoom; panY = wfC.clientHeight/2 - wy*zoom;
|
| 575 |
+
applyTransform();
|
| 576 |
+
});
|
| 577 |
+
/* ═══════════════════════════════════════════
|
| 578 |
+
ZOOM (wheel)
|
| 579 |
+
═══════════════════════════════════════════ */
|
| 580 |
+
wfC.addEventListener('wheel', e => {
|
| 581 |
+
e.preventDefault();
|
| 582 |
+
const nz = Math.max(0.1, Math.min(4, zoom * (e.deltaY > 0 ? 0.92 : 1.08)));
|
| 583 |
+
const r = wfC.getBoundingClientRect();
|
| 584 |
+
const mx = e.clientX-r.left, my = e.clientY-r.top;
|
| 585 |
+
panX = mx-(mx-panX)*(nz/zoom); panY = my-(my-panY)*(nz/zoom);
|
| 586 |
+
zoom = nz; applyTransform();
|
| 587 |
+
}, { passive: false });
|
| 588 |
+
/* ═══════════════════════════════════════════
|
| 589 |
+
NODE CREATION
|
| 590 |
+
═══════════════════════════════════════════ */
|
| 591 |
+
function addNode(type, x, y, nidOverride, nodeState) {
|
| 592 |
+
const nid = nidOverride != null ? nidOverride : nextNid++;
|
| 593 |
+
if (nodeState) py(`graph.nodes[${nid}] = ${JSON.stringify(nodeState)}`);
|
| 594 |
+
else py(`graph.add_node(${nid}, "${type}")`);
|
| 595 |
+
const td = pyJson(`json.dumps(NODE_DEFS["${type}"])`);
|
| 596 |
+
const isValue = td.value_node;
|
| 597 |
+
nodeMeta[nid] = { x, y, w: isValue ? 160 : 190, h: isValue ? 100 : 120 };
|
| 598 |
+
const el = document.createElement('div');
|
| 599 |
+
el.className = 'node' + ((nodeState && nodeState.disabled) ? ' disabled' : '');
|
| 600 |
+
el.dataset.nid = nid;
|
| 601 |
+
el.style.left = x + 'px'; el.style.top = y + 'px';
|
| 602 |
+
if (nodeMeta[nid].w) el.style.width = nodeMeta[nid].w + 'px';
|
| 603 |
+
const hdr = document.createElement('div');
|
| 604 |
+
hdr.className = 'nhdr'; hdr.style.background = td.color;
|
| 605 |
+
hdr.innerHTML = `<span class="nhdr-title">${td.name}</span><span class="ndel" title="Delete node">×</span>`;
|
| 606 |
+
el.appendChild(hdr);
|
| 607 |
+
const body = document.createElement('div'); body.className = 'nbody'; el.appendChild(body);
|
| 608 |
+
const res = document.createElement('div'); res.className = 'nres'; el.appendChild(res);
|
| 609 |
+
wfInner.appendChild(el);
|
| 610 |
+
renderNodeBody(nid);
|
| 611 |
+
updHint(); drawMinimap();
|
| 612 |
+
return nid;
|
| 613 |
+
}
|
| 614 |
+
function renderNodeBody(nid) {
|
| 615 |
+
const el = wfInner.querySelector(`.node[data-nid="${nid}"]`);
|
| 616 |
+
if (!el) return;
|
| 617 |
+
const body = el.querySelector('.nbody');
|
| 618 |
+
body.innerHTML = '';
|
| 619 |
+
const state = pyJson(`json.dumps(graph.nodes[${nid}])`);
|
| 620 |
+
const td = pyJson(`json.dumps(NODE_DEFS["${state.type}"])`);
|
| 621 |
+
const ins = pyJson(`json.dumps(graph.get_inputs(${nid}))`);
|
| 622 |
+
const outs = pyJson(`json.dumps(graph.get_outs(${nid}))`);
|
| 623 |
+
const isValue = td.value_node;
|
| 624 |
+
// Preserve classes
|
| 625 |
+
const wasSel = el.classList.contains('sel');
|
| 626 |
+
el.className = 'node' + (state.disabled ? ' disabled' : '') + (wasSel ? ' sel' : '');
|
| 627 |
+
// File controls
|
| 628 |
+
if (td.file) {
|
| 629 |
+
const fd = document.createElement('div'); fd.className = 'nfile';
|
| 630 |
+
const fi = document.createElement('input'); fi.type = 'file'; fi.accept = 'image/*';
|
| 631 |
+
fi.addEventListener('change', e => handleUpload(nid, e));
|
| 632 |
+
const fb = document.createElement('span'); fb.className = 'fbtn'; fb.textContent = 'Upload';
|
| 633 |
+
fb.addEventListener('click', () => fi.click());
|
| 634 |
+
const sb = document.createElement('span'); sb.className = 'sbtn'; sb.textContent = 'Sample';
|
| 635 |
+
sb.addEventListener('click', () => loadSample(nid));
|
| 636 |
+
fd.append(fi, fb, sb); body.appendChild(fd);
|
| 637 |
+
}
|
| 638 |
+
// Input ports
|
| 639 |
+
ins.forEach((nm, i) => {
|
| 640 |
+
const row = document.createElement('div'); row.className = 'npr inp';
|
| 641 |
+
const pt = document.createElement('div'); pt.className = 'port';
|
| 642 |
+
pt.dataset.nid = nid; pt.dataset.pt = 'in'; pt.dataset.pi = i; pt.dataset.pn = nm;
|
| 643 |
+
const lb = document.createElement('span'); lb.className = 'plbl'; lb.textContent = nm;
|
| 644 |
+
row.append(pt, lb); body.appendChild(row);
|
| 645 |
+
});
|
| 646 |
+
// Params (non-promoted)
|
| 647 |
+
(td.params || []).forEach(p => {
|
| 648 |
+
if (state.promoted && state.promoted.includes(p.id)) return;
|
| 649 |
+
const pd = document.createElement('div'); pd.className = 'nparam';
|
| 650 |
+
const lb = document.createElement('label');
|
| 651 |
+
const lt = document.createElement('span'); lt.textContent = p.label;
|
| 652 |
+
const pv = document.createElement('span'); pv.className = 'pv';
|
| 653 |
+
const val = state.params[p.id];
|
| 654 |
+
pv.textContent = Number.isInteger(val) ? val : parseFloat(val).toFixed(2);
|
| 655 |
+
lb.append(lt, pv);
|
| 656 |
+
const inp = document.createElement('input'); inp.type = 'range';
|
| 657 |
+
inp.min = p.min; inp.max = p.max; inp.step = p.step; inp.value = val;
|
| 658 |
+
inp.addEventListener('input', e => {
|
| 659 |
+
const v = parseFloat(e.target.value);
|
| 660 |
+
py(`graph.update_param(${nid}, "${p.id}", ${v})`);
|
| 661 |
+
pv.textContent = Number.isInteger(v) ? v : v.toFixed(2);
|
| 662 |
+
});
|
| 663 |
+
pd.append(lb, inp); body.appendChild(pd);
|
| 664 |
+
});
|
| 665 |
+
// Preview (image nodes) or value display (value nodes)
|
| 666 |
+
if (isValue) {
|
| 667 |
+
const vd = document.createElement('div'); vd.className = 'nval-display'; vd.id = 'prev-' + nid;
|
| 668 |
+
const val = state.params.value;
|
| 669 |
+
vd.textContent = val != null ? (Number.isInteger(val) ? val : parseFloat(val).toFixed(2)) : '0';
|
| 670 |
+
body.appendChild(vd);
|
| 671 |
+
} else {
|
| 672 |
+
const prev = document.createElement('div'); prev.className = 'nprev'; prev.id = 'prev-' + nid;
|
| 673 |
+
const m = nodeMeta[nid];
|
| 674 |
+
if (m && m.outB64) {
|
| 675 |
+
const img = document.createElement('img'); img.src = 'data:image/png;base64,' + m.outB64;
|
| 676 |
+
prev.appendChild(img);
|
| 677 |
+
} else if (m && m.imgB64) {
|
| 678 |
+
const img = document.createElement('img'); img.src = 'data:image/png;base64,' + m.imgB64;
|
| 679 |
+
prev.appendChild(img);
|
| 680 |
+
} else {
|
| 681 |
+
prev.innerHTML = '<div class="ph">No image</div>';
|
| 682 |
+
}
|
| 683 |
+
body.appendChild(prev);
|
| 684 |
+
}
|
| 685 |
+
// Output ports
|
| 686 |
+
outs.forEach((nm, i) => {
|
| 687 |
+
const row = document.createElement('div'); row.className = 'npr outp';
|
| 688 |
+
const lb = document.createElement('span'); lb.className = 'plbl'; lb.textContent = nm;
|
| 689 |
+
const pt = document.createElement('div'); pt.className = 'port';
|
| 690 |
+
pt.dataset.nid = nid; pt.dataset.pt = 'out'; pt.dataset.pi = i; pt.dataset.pn = nm;
|
| 691 |
+
row.append(lb, pt); body.appendChild(row);
|
| 692 |
+
});
|
| 693 |
+
// Port listeners
|
| 694 |
+
body.querySelectorAll('.port').forEach(pt => {
|
| 695 |
+
pt.addEventListener('mousedown', e => {
|
| 696 |
+
e.preventDefault(); e.stopPropagation();
|
| 697 |
+
connInfo = { nid: parseInt(pt.dataset.nid), ptype: pt.dataset.pt, pidx: parseInt(pt.dataset.pi), el: pt };
|
| 698 |
+
});
|
| 699 |
+
});
|
| 700 |
+
}
|
| 701 |
+
/* ═══════════════════════════════════════════
|
| 702 |
+
NODE DELETE (event delegation for cross btn)
|
| 703 |
+
═══════════════════════════════════════════ */
|
| 704 |
+
wfInner.addEventListener('click', e => {
|
| 705 |
+
if (e.target.classList.contains('ndel')) {
|
| 706 |
+
const nid = parseInt(e.target.closest('.node').dataset.nid);
|
| 707 |
+
delNode(nid);
|
| 708 |
+
}
|
| 709 |
+
});
|
| 710 |
+
function delNode(nid) {
|
| 711 |
+
py(`graph.remove_node(${nid})`);
|
| 712 |
+
const el = wfInner.querySelector(`.node[data-nid="${nid}"]`);
|
| 713 |
+
if (el) el.remove();
|
| 714 |
+
delete nodeMeta[nid];
|
| 715 |
+
updPortStyles(); drawConns(); updHint(); drawMinimap();
|
| 716 |
+
}
|
| 717 |
+
/* ═══════════════════════════════════════════
|
| 718 |
+
GLOBAL MOUSE HANDLERS
|
| 719 |
+
═══════════════════════════════════════════ */
|
| 720 |
+
const PAN_THRESHOLD = 4;
|
| 721 |
+
document.addEventListener('mousemove', e => {
|
| 722 |
+
if (panInfo) {
|
| 723 |
+
const dx = e.clientX - panInfo.startX, dy = e.clientY - panInfo.startY;
|
| 724 |
+
if (!panInfo.moved && Math.abs(dx) + Math.abs(dy) > PAN_THRESHOLD) { panInfo.moved = true; wfC.style.cursor = 'grabbing'; }
|
| 725 |
+
if (panInfo.moved) { panX = panInfo.startPanX + dx; panY = panInfo.startPanY + dy; applyTransform(); }
|
| 726 |
+
return;
|
| 727 |
+
}
|
| 728 |
+
if (dragInfo) {
|
| 729 |
+
const dx = (e.clientX - dragInfo.lastX) / zoom, dy = (e.clientY - dragInfo.lastY) / zoom;
|
| 730 |
+
const m = nodeMeta[dragInfo.nid];
|
| 731 |
+
const el = wfInner.querySelector(`.node[data-nid="${dragInfo.nid}"]`);
|
| 732 |
+
if (dragInfo.resizing) {
|
| 733 |
+
m.w = Math.max(160, m.w + dx);
|
| 734 |
+
m.h = Math.max(80, m.h + dy);
|
| 735 |
+
if (el) { el.style.width = m.w + 'px'; }
|
| 736 |
+
} else {
|
| 737 |
+
let nx = m.x + dx, ny = m.y + dy;
|
| 738 |
+
if ($('#snapCheck').checked) { const gs = gridSize; nx = Math.round(nx/gs)*gs; ny = Math.round(ny/gs)*gs; }
|
| 739 |
+
m.x = nx; m.y = ny;
|
| 740 |
+
if (el) { el.style.left = nx + 'px'; el.style.top = ny + 'px'; }
|
| 741 |
+
}
|
| 742 |
+
dragInfo.lastX = e.clientX; dragInfo.lastY = e.clientY;
|
| 743 |
+
drawConns(); drawMinimap();
|
| 744 |
+
return;
|
| 745 |
+
}
|
| 746 |
+
if (connInfo) {
|
| 747 |
+
const cr = wfC.getBoundingClientRect();
|
| 748 |
+
tempEnd = { x: e.clientX-cr.left, y: e.clientY-cr.top };
|
| 749 |
+
drawConns();
|
| 750 |
+
}
|
| 751 |
+
});
|
| 752 |
+
document.addEventListener('mouseup', e => {
|
| 753 |
+
if (panInfo) {
|
| 754 |
+
wfC.style.cursor = '';
|
| 755 |
+
if (!panInfo.moved) { wfInner.querySelectorAll('.node.sel').forEach(n => n.classList.remove('sel')); closeCtxMenu(); closeSearchMenu(); }
|
| 756 |
+
panInfo = null; return;
|
| 757 |
+
}
|
| 758 |
+
if (connInfo) {
|
| 759 |
+
const target = e.target.closest('.port');
|
| 760 |
+
if (target) {
|
| 761 |
+
const tNid = parseInt(target.dataset.nid), tPt = target.dataset.pt, tPi = parseInt(target.dataset.pi);
|
| 762 |
+
if (tPt !== connInfo.ptype && tNid !== connInfo.nid) {
|
| 763 |
+
let fn, fp, tn, tp;
|
| 764 |
+
if (connInfo.ptype === 'out') { fn=connInfo.nid; fp=connInfo.pidx; tn=tNid; tp=tPi; }
|
| 765 |
+
else { fn=tNid; fp=tPi; tn=connInfo.nid; tp=connInfo.pidx; }
|
| 766 |
+
const ok = py(`graph.add_edge(${fn}, ${fp}, ${tn}, ${tp})`);
|
| 767 |
+
if (ok) updPortStyles();
|
| 768 |
+
else toast('Cannot connect: would create a cycle', 'err');
|
| 769 |
+
}
|
| 770 |
+
}
|
| 771 |
+
connInfo = null; tempEnd = null; drawConns();
|
| 772 |
+
}
|
| 773 |
+
dragInfo = null;
|
| 774 |
+
});
|
| 775 |
+
wfInner.addEventListener('mousedown', e => {
|
| 776 |
+
const nodeEl = e.target.closest('.node');
|
| 777 |
+
if (!nodeEl || e.target.classList.contains('ndel')) return;
|
| 778 |
+
const nid = parseInt(nodeEl.dataset.nid);
|
| 779 |
+
mxZ++; nodeEl.style.zIndex = mxZ;
|
| 780 |
+
if (!e.shiftKey) wfInner.querySelectorAll('.node.sel').forEach(n => n.classList.remove('sel'));
|
| 781 |
+
nodeEl.classList.add('sel');
|
| 782 |
+
if (e.target.classList.contains('nres')) {
|
| 783 |
+
dragInfo = { nid, resizing: true, lastX: e.clientX, lastY: e.clientY };
|
| 784 |
+
e.preventDefault(); return;
|
| 785 |
+
}
|
| 786 |
+
if (e.target.closest('.nhdr') && !e.target.classList.contains('ndel')) {
|
| 787 |
+
dragInfo = { nid, resizing: false, lastX: e.clientX, lastY: e.clientY };
|
| 788 |
+
e.preventDefault(); return;
|
| 789 |
+
}
|
| 790 |
+
});
|
| 791 |
+
wfC.addEventListener('mousedown', e => {
|
| 792 |
+
if (e.target.closest('.node') || e.target.closest('#minimap') || e.target.closest('.popup')) return;
|
| 793 |
+
if (e.button === 2) return;
|
| 794 |
+
panInfo = { startPanX: panX, startPanY: panY, startX: e.clientX, startY: e.clientY, moved: false };
|
| 795 |
+
e.preventDefault();
|
| 796 |
+
});
|
| 797 |
+
wfInner.addEventListener('contextmenu', e => {
|
| 798 |
+
e.preventDefault();
|
| 799 |
+
const nodeEl = e.target.closest('.node');
|
| 800 |
+
if (nodeEl) showCtxMenu(parseInt(nodeEl.dataset.nid), e);
|
| 801 |
+
});
|
| 802 |
+
/* ═══════════════════════════════════════════
|
| 803 |
+
CONTEXT MENU
|
| 804 |
+
═══════════════════════════════════════════ */
|
| 805 |
+
function showCtxMenu(nid, e) {
|
| 806 |
+
const panel = $('#wfPanel'), pr = panel.getBoundingClientRect(), menu = $('#ctxMenu');
|
| 807 |
+
const state = pyJson(`json.dumps(graph.nodes[${nid}])`);
|
| 808 |
+
const td = pyJson(`json.dumps(NODE_DEFS["${state.type}"])`);
|
| 809 |
+
let html = `<div class="ctx-item danger" data-act="del">Delete Node</div>`;
|
| 810 |
+
html += `<div class="ctx-item" data-act="toggle">${state.disabled ? 'Enable' : 'Disable'} Node</div>`;
|
| 811 |
+
if (td.params && td.params.length) {
|
| 812 |
+
html += '<div class="ctx-sep"></div><div class="ctx-label">Parameters</div>';
|
| 813 |
+
td.params.forEach(p => {
|
| 814 |
+
const isProm = state.promoted && state.promoted.includes(p.id);
|
| 815 |
+
html += `<div class="ctx-item" data-act="${isProm?'demote':'promote'}" data-pid="${p.id}">${p.label} <span class="ctx-sub">${isProm?'Demote':'Promote to input'}</span></div>`;
|
| 816 |
+
});
|
| 817 |
+
}
|
| 818 |
+
menu.innerHTML = html;
|
| 819 |
+
menu.style.display = 'block';
|
| 820 |
+
let left = e.clientX - pr.left, top = e.clientY - pr.top;
|
| 821 |
+
if (left + 180 > pr.width) left = pr.width - 185;
|
| 822 |
+
if (top + menu.offsetHeight > pr.height) top = pr.height - menu.offsetHeight - 5;
|
| 823 |
+
menu.style.left = Math.max(0, left) + 'px'; menu.style.top = Math.max(0, top) + 'px';
|
| 824 |
+
menu.querySelectorAll('.ctx-item').forEach(item => {
|
| 825 |
+
item.addEventListener('click', () => {
|
| 826 |
+
const act = item.dataset.act, pid = item.dataset.pid;
|
| 827 |
+
if (act === 'del') delNode(nid);
|
| 828 |
+
else if (act === 'toggle') { py(`graph.toggle_disable(${nid})`); renderNodeBody(nid); }
|
| 829 |
+
else if (act === 'promote') { py(`graph.promote_param(${nid}, "${pid}")`); renderNodeBody(nid); updPortStyles(); drawConns(); }
|
| 830 |
+
else if (act === 'demote') {
|
| 831 |
+
const ins = pyJson(`json.dumps(graph.get_inputs(${nid}))`);
|
| 832 |
+
const pidx = ins.indexOf(pid);
|
| 833 |
+
if (pidx !== -1) py(`graph.edges = [e for e in graph.edges if not (int(e["tn"]) == ${nid} and int(e["tp"]) == ${pidx})]`);
|
| 834 |
+
py(`graph.demote_param(${nid}, "${pid}")`);
|
| 835 |
+
renderNodeBody(nid); updPortStyles(); drawConns();
|
| 836 |
+
}
|
| 837 |
+
closeCtxMenu();
|
| 838 |
+
});
|
| 839 |
+
});
|
| 840 |
+
}
|
| 841 |
+
function closeCtxMenu() { $('#ctxMenu').style.display = 'none'; }
|
| 842 |
+
/* ═══════════════════════════════════════════
|
| 843 |
+
SEARCH MENU (double-click canvas)
|
| 844 |
+
═══════════════════════════════════════════ */
|
| 845 |
+
wfC.addEventListener('dblclick', e => {
|
| 846 |
+
if (e.target.closest('.node') || e.target.closest('#minimap') || e.target.closest('.popup')) return;
|
| 847 |
+
const pr = $('#wfPanel').getBoundingClientRect();
|
| 848 |
+
showSearchMenu(e.clientX - pr.left, e.clientY - pr.top, (e.clientX - pr.left - panX)/zoom - 90, (e.clientY - pr.top - panY)/zoom - 20);
|
| 849 |
+
});
|
| 850 |
+
function showSearchMenu(sx, sy, wx, wy) {
|
| 851 |
+
const menu = $('#searchMenu'), pr = $('#wfPanel').getBoundingClientRect();
|
| 852 |
+
let left = sx, top = sy;
|
| 853 |
+
if (left + 220 > pr.width) left = pr.width - 225;
|
| 854 |
+
if (top + 200 > pr.height) top = pr.height - 205;
|
| 855 |
+
menu.style.left = Math.max(0, left) + 'px'; menu.style.top = Math.max(0, top) + 'px';
|
| 856 |
+
menu.style.display = 'block'; menu._wx = wx; menu._wy = wy;
|
| 857 |
+
const inp = $('#searchInput'); inp.value = ''; inp.focus(); filterSearch();
|
| 858 |
+
}
|
| 859 |
+
function closeSearchMenu() { $('#searchMenu').style.display = 'none'; }
|
| 860 |
+
$('#searchInput').addEventListener('input', filterSearch);
|
| 861 |
+
$('#searchInput').addEventListener('keydown', e => { if (e.key === 'Escape') closeSearchMenu(); });
|
| 862 |
+
function filterSearch() {
|
| 863 |
+
const q = $('#searchInput').value.toLowerCase();
|
| 864 |
+
const defs = pyReady ? pyJson('json.dumps(NODE_DEFS)') : {};
|
| 865 |
+
const res = $('#searchResults');
|
| 866 |
+
res.innerHTML = '';
|
| 867 |
+
let count = 0;
|
| 868 |
+
for (const [type, d] of Object.entries(defs)) {
|
| 869 |
+
if (count >= 8) break;
|
| 870 |
+
if (d.name.toLowerCase().includes(q)) {
|
| 871 |
+
const div = document.createElement('div'); div.className = 'search-item';
|
| 872 |
+
div.innerHTML = `<div class="sb-dot" style="background:${d.color}"></div>${d.name}`;
|
| 873 |
+
div.addEventListener('click', () => {
|
| 874 |
+
const m = $('#searchMenu');
|
| 875 |
+
addNode(type, m._wx, m._wy);
|
| 876 |
+
closeSearchMenu();
|
| 877 |
+
});
|
| 878 |
+
res.appendChild(div); count++;
|
| 879 |
+
}
|
| 880 |
+
}
|
| 881 |
+
if (!count) res.innerHTML = '<div style="padding:8px;font-size:10px;color:var(--muted)">No results</div>';
|
| 882 |
+
}
|
| 883 |
+
document.addEventListener('mousedown', e => {
|
| 884 |
+
if (!e.target.closest('#ctxMenu')) closeCtxMenu();
|
| 885 |
+
if (!e.target.closest('#searchMenu') && !e.target.closest('#wfC')) closeSearchMenu();
|
| 886 |
+
});
|
| 887 |
+
/* ═══════════════════════════════════════════
|
| 888 |
+
SIDEBAR
|
| 889 |
+
═══════════════════════════════════════════ */
|
| 890 |
+
const NODE_CATEGORIES = [
|
| 891 |
+
{ title: 'Input / Output', items: [
|
| 892 |
+
{ type: 'image_input', color: '#4CAF50', name: 'Image Input' },
|
| 893 |
+
{ type: 'image_output', color: '#FF5722', name: 'Image Output' }
|
| 894 |
+
]},
|
| 895 |
+
{ title: 'Values', items: [
|
| 896 |
+
{ type: 'int_value', color: '#2196F3', name: 'Int Value' },
|
| 897 |
+
{ type: 'float_value', color: '#03A9F4', name: 'Float Value' }
|
| 898 |
+
]},
|
| 899 |
+
{ title: 'Color', items: [
|
| 900 |
+
{ type: 'grayscale', color: '#607D8B', name: 'Grayscale' },
|
| 901 |
+
{ type: 'invert', color: '#E91E63', name: 'Invert Colors' }
|
| 902 |
+
]},
|
| 903 |
+
{ title: 'Filters', items: [
|
| 904 |
+
{ type: 'gaussian_blur', color: '#FF9800', name: 'Gaussian Blur' },
|
| 905 |
+
{ type: 'threshold', color: '#795548', name: 'Otsu Threshold' }
|
| 906 |
+
]},
|
| 907 |
+
{ title: 'Edge Detection', items: [
|
| 908 |
+
{ type: 'sobel', color: '#009688', name: 'Sobel Edge' },
|
| 909 |
+
{ type: 'canny', color: '#3F51B5', name: 'Canny Edge' }
|
| 910 |
+
]},
|
| 911 |
+
{ title: 'Transform', items: [
|
| 912 |
+
{ type: 'contour', color: '#9C27B0', name: 'Contour Detect' },
|
| 913 |
+
{ type: 'rotate', color: '#00BCD4', name: 'Rotate' },
|
| 914 |
+
{ type: 'resize', color: '#8BC34A', name: 'Resize' }
|
| 915 |
+
]}
|
| 916 |
+
];
|
| 917 |
+
function buildSidebar() {
|
| 918 |
+
const sb = $('#sbNodes');
|
| 919 |
+
sb.innerHTML = '';
|
| 920 |
+
NODE_CATEGORIES.forEach(cat => {
|
| 921 |
+
const title = document.createElement('div'); title.className = 'sb-title'; title.textContent = cat.title;
|
| 922 |
+
sb.appendChild(title);
|
| 923 |
+
cat.items.forEach(item => {
|
| 924 |
+
const div = document.createElement('div'); div.className = 'sb-item'; div.draggable = true;
|
| 925 |
+
div.dataset.type = item.type;
|
| 926 |
+
div.innerHTML = `<div class="sb-dot" style="background:${item.color}"></div>${item.name}`;
|
| 927 |
+
div.addEventListener('dragstart', e => { e.dataTransfer.setData('nodeType', item.type); e.dataTransfer.effectAllowed = 'copy'; });
|
| 928 |
+
div.addEventListener('dblclick', () => {
|
| 929 |
+
const c = viewportCenter();
|
| 930 |
+
addNode(item.type, c.x - 90, c.y - 20);
|
| 931 |
+
});
|
| 932 |
+
sb.appendChild(div);
|
| 933 |
+
});
|
| 934 |
+
});
|
| 935 |
+
}
|
| 936 |
+
// Sidebar tab switching
|
| 937 |
+
$$('.sb-tab').forEach(btn => btn.addEventListener('click', () => {
|
| 938 |
+
$$('.sb-tab').forEach(b => b.classList.remove('on'));
|
| 939 |
+
$$('.sb-content').forEach(b => b.classList.remove('on'));
|
| 940 |
+
btn.classList.add('on');
|
| 941 |
+
$(btn.dataset.sb === 'nodes' ? '#sbNodes' : '#sbLibrary').classList.add('on');
|
| 942 |
+
}));
|
| 943 |
+
// Drag & drop on canvas
|
| 944 |
+
wfC.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
|
| 945 |
+
wfC.addEventListener('drop', e => {
|
| 946 |
+
e.preventDefault();
|
| 947 |
+
const type = e.dataTransfer.getData('nodeType');
|
| 948 |
+
if (!type) return;
|
| 949 |
+
const cr = wfC.getBoundingClientRect();
|
| 950 |
+
addNode(type, (e.clientX-cr.left-panX)/zoom - 90, (e.clientY-cr.top-panY)/zoom - 20);
|
| 951 |
+
});
|
| 952 |
+
/* ═══════════════════════════════════════════
|
| 953 |
+
IMAGE HANDLING
|
| 954 |
+
═══════════════════════════════════════════ */
|
| 955 |
+
function imgToB64(img) {
|
| 956 |
+
const maxD = 512;
|
| 957 |
+
let w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
|
| 958 |
+
if (w > maxD || h > maxD) { const s = Math.min(maxD/w, maxD/h); w = Math.round(w*s); h = Math.round(h*s); }
|
| 959 |
+
const c = document.createElement('canvas'); c.width = w; c.height = h;
|
| 960 |
+
c.getContext('2d').drawImage(img, 0, 0, w, h);
|
| 961 |
+
return c.toDataURL('image/png').split(',')[1];
|
| 962 |
+
}
|
| 963 |
+
function setPreview(nid, b64, val) {
|
| 964 |
+
const prev = $(`#prev-${nid}`);
|
| 965 |
+
if (!prev) return;
|
| 966 |
+
if (prev.classList.contains('nval-display')) {
|
| 967 |
+
prev.textContent = val != null ? (Number.isInteger(val) ? val : parseFloat(val).toFixed(2)) : '0';
|
| 968 |
+
return;
|
| 969 |
+
}
|
| 970 |
+
if (b64) { prev.innerHTML = ''; const img = document.createElement('img'); img.src = 'data:image/png;base64,' + b64; prev.appendChild(img); }
|
| 971 |
+
else prev.innerHTML = '<div class="ph">No image</div>';
|
| 972 |
+
}
|
| 973 |
+
function handleUpload(nid, e) {
|
| 974 |
+
const file = e.target.files[0]; if (!file) return;
|
| 975 |
+
const reader = new FileReader();
|
| 976 |
+
reader.onload = ev => {
|
| 977 |
+
const img = new Image();
|
| 978 |
+
img.onload = () => {
|
| 979 |
+
const b64 = imgToB64(img);
|
| 980 |
+
const m = nodeMeta[nid];
|
| 981 |
+
if (m) { m.imgB64 = b64; m.outB64 = b64; m.lastHash = null; }
|
| 982 |
+
setPreview(nid, b64);
|
| 983 |
+
};
|
| 984 |
+
img.src = ev.target.result;
|
| 985 |
+
};
|
| 986 |
+
reader.readAsDataURL(file);
|
| 987 |
+
}
|
| 988 |
+
function loadSample(nid) {
|
| 989 |
+
const c = document.createElement('canvas'); c.width = 320; c.height = 240;
|
| 990 |
+
const ctx = c.getContext('2d');
|
| 991 |
+
const g = ctx.createLinearGradient(0,0,320,240);
|
| 992 |
+
g.addColorStop(0,'#1a2a3a'); g.addColorStop(1,'#2a4a3a');
|
| 993 |
+
ctx.fillStyle = g; ctx.fillRect(0,0,320,240);
|
| 994 |
+
ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.arc(80,100,50,0,Math.PI*2); ctx.fill();
|
| 995 |
+
ctx.fillStyle = '#2ecc71'; ctx.fillRect(160,60,80,80);
|
| 996 |
+
ctx.fillStyle = '#f39c12'; ctx.beginPath(); ctx.moveTo(280,40); ctx.lineTo(310,120); ctx.lineTo(250,120); ctx.closePath(); ctx.fill();
|
| 997 |
+
ctx.fillStyle = '#9b59b6'; ctx.beginPath(); ctx.ellipse(160,180,60,30,0,0,Math.PI*2); ctx.fill();
|
| 998 |
+
ctx.strokeStyle = '#ecf0f1'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(10,200); ctx.lineTo(310,200); ctx.stroke();
|
| 999 |
+
const b64 = c.toDataURL('image/png').split(',')[1];
|
| 1000 |
+
const m = nodeMeta[nid];
|
| 1001 |
+
if (m) { m.imgB64 = b64; m.outB64 = b64; m.lastHash = null; }
|
| 1002 |
+
setPreview(nid, b64);
|
| 1003 |
+
}
|
| 1004 |
+
/* ═══════════════════════════════════════════
|
| 1005 |
+
WORKFLOW TABS
|
| 1006 |
+
═══════════════════════════════════════════ */
|
| 1007 |
+
function initTabs() {
|
| 1008 |
+
const saved = localStorage.getItem('nodeWfTabs');
|
| 1009 |
+
if (saved) { const s = JSON.parse(saved); workflows = s.workflows || []; wfCounter = s.counter || 1; }
|
| 1010 |
+
if (!workflows.length) workflows.push({ id: wfCounter++, name: 'Workflow 1' });
|
| 1011 |
+
activeWfId = workflows[0].id;
|
| 1012 |
+
renderTabs(); loadWfLibrary();
|
| 1013 |
+
loadCurrentWf();
|
| 1014 |
+
}
|
| 1015 |
+
function renderTabs() {
|
| 1016 |
+
const c = $('#tabsScroll'); c.innerHTML = '';
|
| 1017 |
+
workflows.forEach(wf => {
|
| 1018 |
+
const tab = document.createElement('div'); tab.className = 'tab' + (wf.id === activeWfId ? ' on' : '');
|
| 1019 |
+
const nameSpan = document.createElement('span'); nameSpan.className = 'tab-name';
|
| 1020 |
+
nameSpan.contentEditable = 'true'; nameSpan.spellcheck = false; nameSpan.textContent = wf.name;
|
| 1021 |
+
nameSpan.addEventListener('blur', () => { wf.name = nameSpan.textContent.trim() || 'Untitled'; loadWfLibrary(); });
|
| 1022 |
+
nameSpan.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); nameSpan.blur(); } });
|
| 1023 |
+
nameSpan.addEventListener('mousedown', e => e.stopPropagation());
|
| 1024 |
+
tab.appendChild(nameSpan);
|
| 1025 |
+
if (workflows.length > 1) {
|
| 1026 |
+
const cls = document.createElement('span'); cls.className = 'tab-close'; cls.textContent = '\u00d7';
|
| 1027 |
+
cls.addEventListener('click', e => { e.stopPropagation(); removeTab(wf.id); });
|
| 1028 |
+
tab.appendChild(cls);
|
| 1029 |
+
}
|
| 1030 |
+
tab.addEventListener('click', e => { if (!e.target.classList.contains('tab-close')) switchTab(wf.id); });
|
| 1031 |
+
c.appendChild(tab);
|
| 1032 |
+
});
|
| 1033 |
+
}
|
| 1034 |
+
function switchTab(id) {
|
| 1035 |
+
saveCurrentWf(); activeWfId = id;
|
| 1036 |
+
clearWfDOM(); py('graph.clear()'); loadCurrentWf(); renderTabs();
|
| 1037 |
+
}
|
| 1038 |
+
function addTab() {
|
| 1039 |
+
const id = wfCounter++; workflows.push({ id, name: 'Workflow ' + id }); switchTab(id);
|
| 1040 |
+
}
|
| 1041 |
+
function removeTab(id) {
|
| 1042 |
+
saveCurrentWf(); workflows = workflows.filter(w => w.id !== id);
|
| 1043 |
+
if (activeWfId === id) { activeWfId = workflows[0].id; clearWfDOM(); py('graph.clear()'); loadCurrentWf(); }
|
| 1044 |
+
renderTabs(); persistTabs();
|
| 1045 |
+
}
|
| 1046 |
+
$('#btnAddTab').addEventListener('click', addTab);
|
| 1047 |
+
function clearWfDOM() {
|
| 1048 |
+
wfInner.querySelectorAll('.node').forEach(n => n.remove());
|
| 1049 |
+
nodeMeta = {}; updHint(); drawConns(); drawMinimap();
|
| 1050 |
+
}
|
| 1051 |
+
function saveCurrentWf() {
|
| 1052 |
+
if (!pyReady) return;
|
| 1053 |
+
const state = py('json.dumps(graph.get_state())');
|
| 1054 |
+
const wf = workflows.find(w => w.id === activeWfId);
|
| 1055 |
+
if (wf) {
|
| 1056 |
+
wf.state = state;
|
| 1057 |
+
wf.meta = {};
|
| 1058 |
+
for (const [k, v] of Object.entries(nodeMeta)) {
|
| 1059 |
+
wf.meta[k] = { x: v.x, y: v.y, w: v.w, h: v.h, outVal: v.outVal };
|
| 1060 |
+
}
|
| 1061 |
+
}
|
| 1062 |
+
persistTabs();
|
| 1063 |
+
}
|
| 1064 |
+
function persistTabs() {
|
| 1065 |
+
const lite = workflows.map(w => ({ id: w.id, name: w.name, state: w.state, meta: w.meta }));
|
| 1066 |
+
localStorage.setItem('nodeWfTabs', JSON.stringify({ workflows: lite, counter: wfCounter }));
|
| 1067 |
+
}
|
| 1068 |
+
function loadCurrentWf() {
|
| 1069 |
+
const wf = workflows.find(w => w.id === activeWfId);
|
| 1070 |
+
if (!wf || !wf.state) { updHint(); return; }
|
| 1071 |
+
let state;
|
| 1072 |
+
try { state = typeof wf.state === 'string' ? JSON.parse(wf.state) : wf.state; } catch(e) { console.error('Failed to parse state', e); return; }
|
| 1073 |
+
nodeMeta = {};
|
| 1074 |
+
if (wf.meta) { for (const [k, v] of Object.entries(wf.meta)) { nodeMeta[k] = { ...v, imgB64: null, outB64: null, lastHash: null }; } }
|
| 1075 |
+
py(`graph.load_state(${JSON.stringify(state)})`);
|
| 1076 |
+
for (const [nid, nState] of Object.entries(state.nodes)) {
|
| 1077 |
+
const m = nodeMeta[nid] || { x: Math.random()*300+50, y: Math.random()*200+50, w: 190, h: 120 };
|
| 1078 |
+
nodeMeta[nid] = m;
|
| 1079 |
+
const el = document.createElement('div');
|
| 1080 |
+
el.className = 'node' + (nState.disabled ? ' disabled' : '');
|
| 1081 |
+
el.dataset.nid = nid;
|
| 1082 |
+
el.style.left = m.x + 'px'; el.style.top = m.y + 'px';
|
| 1083 |
+
if (m.w) el.style.width = m.w + 'px';
|
| 1084 |
+
const td = pyJson(`json.dumps(NODE_DEFS["${nState.type}"])`);
|
| 1085 |
+
const hdr = document.createElement('div'); hdr.className = 'nhdr'; hdr.style.background = td.color;
|
| 1086 |
+
hdr.innerHTML = `<span class="nhdr-title">${td.name}</span><span class="ndel" title="Delete">×</span>`;
|
| 1087 |
+
el.appendChild(hdr);
|
| 1088 |
+
const body = document.createElement('div'); body.className = 'nbody'; el.appendChild(body);
|
| 1089 |
+
const res = document.createElement('div'); res.className = 'nres'; el.appendChild(res);
|
| 1090 |
+
wfInner.appendChild(el);
|
| 1091 |
+
renderNodeBody(parseInt(nid));
|
| 1092 |
+
}
|
| 1093 |
+
const nids = Object.keys(state.nodes).map(Number);
|
| 1094 |
+
if (nids.length) nextNid = Math.max(...nids) + 1;
|
| 1095 |
+
updPortStyles(); drawConns(); updHint(); drawMinimap();
|
| 1096 |
+
}
|
| 1097 |
+
/* ═══════════════════════════════════════════
|
| 1098 |
+
LIBRARY SAVE/LOAD
|
| 1099 |
+
═══════════════════════════════════════════ */
|
| 1100 |
+
function loadWfLibrary() {
|
| 1101 |
+
const list = $('#wfList');
|
| 1102 |
+
const libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]');
|
| 1103 |
+
if (!libs.length) { list.innerHTML = '<div class="sb-empty">No saved workflows yet</div>'; return; }
|
| 1104 |
+
list.innerHTML = '';
|
| 1105 |
+
libs.forEach((lib, i) => {
|
| 1106 |
+
const div = document.createElement('div'); div.className = 'sb-wf-item';
|
| 1107 |
+
div.innerHTML = `<span>${lib.name}</span><span class="sb-wf-del">×</span>`;
|
| 1108 |
+
div.querySelector('span:first-child').addEventListener('click', () => loadFromLibrary(i));
|
| 1109 |
+
div.querySelector('.sb-wf-del').addEventListener('click', e => { e.stopPropagation(); removeFromLibrary(i); });
|
| 1110 |
+
list.appendChild(div);
|
| 1111 |
+
});
|
| 1112 |
+
}
|
| 1113 |
+
function saveToLibrary() {
|
| 1114 |
+
saveCurrentWf();
|
| 1115 |
+
const wf = workflows.find(w => w.id === activeWfId);
|
| 1116 |
+
if (!wf || !wf.state) { toast('Nothing to save', 'err'); return; }
|
| 1117 |
+
const name = prompt('Save workflow as:', wf.name);
|
| 1118 |
+
if (!name) return;
|
| 1119 |
+
const libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]');
|
| 1120 |
+
const liteMeta = {};
|
| 1121 |
+
for (const [k, v] of Object.entries(nodeMeta)) liteMeta[k] = { x: v.x, y: v.y, w: v.w, h: v.h };
|
| 1122 |
+
const stateObj = typeof wf.state === 'string' ? JSON.parse(wf.state) : wf.state;
|
| 1123 |
+
libs.push({ name, state: stateObj, meta: liteMeta });
|
| 1124 |
+
localStorage.setItem('nodeWfLibrary', JSON.stringify(libs));
|
| 1125 |
+
loadWfLibrary(); toast('Saved to library', 'ok');
|
| 1126 |
+
}
|
| 1127 |
+
function loadFromLibrary(idx) {
|
| 1128 |
+
const libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]');
|
| 1129 |
+
if (!libs[idx]) return;
|
| 1130 |
+
addTab();
|
| 1131 |
+
const wf = workflows.find(w => w.id === activeWfId);
|
| 1132 |
+
wf.name = libs[idx].name;
|
| 1133 |
+
wf.state = JSON.stringify(libs[idx].state);
|
| 1134 |
+
wf.meta = libs[idx].meta || {};
|
| 1135 |
+
loadCurrentWf(); renderTabs(); toast('Workflow loaded', 'inf');
|
| 1136 |
+
}
|
| 1137 |
+
function removeFromLibrary(idx) {
|
| 1138 |
+
let libs = JSON.parse(localStorage.getItem('nodeWfLibrary') || '[]');
|
| 1139 |
+
libs.splice(idx, 1);
|
| 1140 |
+
localStorage.setItem('nodeWfLibrary', JSON.stringify(libs));
|
| 1141 |
+
loadWfLibrary();
|
| 1142 |
+
}
|
| 1143 |
+
$('#btnSave').addEventListener('click', saveToLibrary);
|
| 1144 |
+
$('#btnClear').addEventListener('click', () => { clearWfDOM(); py('graph.clear()'); updHint(); toast('Canvas cleared', 'inf'); });
|
| 1145 |
+
/* ══���════════════════════════════════════════
|
| 1146 |
+
SETTINGS
|
| 1147 |
+
═══════════════════════════════════════════ */
|
| 1148 |
+
$('#btnSettings').addEventListener('click', e => { e.stopPropagation(); $('#settingsPanel').classList.toggle('open'); });
|
| 1149 |
+
document.addEventListener('click', e => { if (!e.target.closest('#settingsPanel') && !e.target.closest('#btnSettings')) $('#settingsPanel').classList.remove('open'); });
|
| 1150 |
+
$('#gridSizeSel').addEventListener('change', e => { gridSize = parseInt(e.target.value); updateGrid(); });
|
| 1151 |
+
/* ═══════════════════════════════════════════
|
| 1152 |
+
WORKFLOW EXECUTION
|
| 1153 |
+
═══════════════════════════════════════════ */
|
| 1154 |
+
async function runWorkflow() {
|
| 1155 |
+
if (!pyReady) { toast('Environment not ready', 'err'); return; }
|
| 1156 |
+
const nodeCount = pyJson('len(graph.nodes)');
|
| 1157 |
+
if (!nodeCount) { toast('Add some nodes first', 'err'); return; }
|
| 1158 |
+
const btn = $('#btnRun');
|
| 1159 |
+
btn.disabled = true; btn.textContent = 'Running...';
|
| 1160 |
+
const forceRun = $('#forceRunCheck').checked;
|
| 1161 |
+
wfInner.querySelectorAll('.node').forEach(n => n.classList.remove('done','running','err'));
|
| 1162 |
+
const order = pyJson('json.dumps(graph.topological_sort())');
|
| 1163 |
+
if (!order) { toast('Cycle detected!', 'err'); btn.disabled = false; btn.textContent = 'Run'; return; }
|
| 1164 |
+
const edges = pyJson('json.dumps(graph.edges)');
|
| 1165 |
+
const inMap = {};
|
| 1166 |
+
order.forEach(nid => inMap[nid] = []);
|
| 1167 |
+
if (edges) edges.forEach(e => inMap[e.tn].push(e));
|
| 1168 |
+
let hadError = false;
|
| 1169 |
+
for (const nid of order) {
|
| 1170 |
+
const nd = nodeMeta[nid]; if (!nd) continue;
|
| 1171 |
+
const state = pyJson(`json.dumps(graph.nodes[${nid}])`);
|
| 1172 |
+
const el = wfInner.querySelector(`.node[data-nid="${nid}"]`);
|
| 1173 |
+
const isValue = pyJson(`graph.is_value_node(${nid})`);
|
| 1174 |
+
const td = pyJson(`json.dumps(NODE_DEFS["${state.type}"])`);
|
| 1175 |
+
if (el) { el.classList.remove('done','running','err'); el.classList.add('running'); }
|
| 1176 |
+
try {
|
| 1177 |
+
let inputB64 = null;
|
| 1178 |
+
const baseIns = pyJson(`json.dumps(NODE_DEFS["${state.type}"]["ins"])`);
|
| 1179 |
+
if (inMap[nid]) {
|
| 1180 |
+
inMap[nid].forEach(conn => {
|
| 1181 |
+
const src = nodeMeta[conn.fn]; if (!src) return;
|
| 1182 |
+
if (conn.tp === 0 && baseIns.length > 0 && baseIns[0] === 'image') {
|
| 1183 |
+
inputB64 = src.outB64;
|
| 1184 |
+
}
|
| 1185 |
+
if (conn.tp >= baseIns.length && state.promoted) {
|
| 1186 |
+
const pId = state.promoted[conn.tp - baseIns.length];
|
| 1187 |
+
if (pId && src.outVal !== undefined && src.outVal !== null) {
|
| 1188 |
+
state.params[pId] = src.outVal;
|
| 1189 |
+
}
|
| 1190 |
+
}
|
| 1191 |
+
});
|
| 1192 |
+
}
|
| 1193 |
+
const currentHash = hashStr((inputB64||'').slice(0,80) + JSON.stringify(state.params) + state.type + String(state.disabled));
|
| 1194 |
+
if (state.disabled) {
|
| 1195 |
+
nd.outB64 = inputB64;
|
| 1196 |
+
nd.outVal = isValue ? state.params.value : undefined;
|
| 1197 |
+
nd.lastHash = currentHash;
|
| 1198 |
+
} else if (!forceRun && nd.lastHash === currentHash && (nd.outB64 || isValue)) {
|
| 1199 |
+
// Cache hit
|
| 1200 |
+
} else {
|
| 1201 |
+
if (isValue) {
|
| 1202 |
+
nd.outVal = state.params.value;
|
| 1203 |
+
nd.outB64 = null;
|
| 1204 |
+
} else if (state.type === 'image_input') {
|
| 1205 |
+
nd.outB64 = nd.imgB64 || null;
|
| 1206 |
+
nd.outVal = undefined;
|
| 1207 |
+
} else if (state.type === 'image_output') {
|
| 1208 |
+
nd.outB64 = inputB64;
|
| 1209 |
+
nd.outVal = undefined;
|
| 1210 |
+
} else {
|
| 1211 |
+
if (!inputB64) {
|
| 1212 |
+
nd.outB64 = null;
|
| 1213 |
+
} else {
|
| 1214 |
+
pyodide.globals.set('_in_b64', inputB64);
|
| 1215 |
+
pyodide.globals.set('_op', state.type);
|
| 1216 |
+
pyodide.globals.set('_pjson', JSON.stringify(state.params));
|
| 1217 |
+
py('output_b64 = process_image(_in_b64, _op, _pjson)');
|
| 1218 |
+
nd.outB64 = pyodide.globals.get('output_b64');
|
| 1219 |
+
}
|
| 1220 |
+
nd.outVal = undefined;
|
| 1221 |
+
}
|
| 1222 |
+
nd.lastHash = currentHash;
|
| 1223 |
+
}
|
| 1224 |
+
setPreview(nid, nd.outB64, nd.outVal);
|
| 1225 |
+
if (el) { el.classList.remove('running'); el.classList.add('done'); }
|
| 1226 |
+
} catch (err) {
|
| 1227 |
+
console.error('Node error:', state.type, err);
|
| 1228 |
+
if (el) { el.classList.remove('running'); el.classList.add('err'); }
|
| 1229 |
+
toast('Error in ' + (td.name || state.type) + ': ' + (err.message || String(err)).slice(0, 80), 'err');
|
| 1230 |
+
hadError = true; break;
|
| 1231 |
+
}
|
| 1232 |
+
await new Promise(r => setTimeout(r, 15));
|
| 1233 |
+
}
|
| 1234 |
+
if (!hadError) toast('Workflow complete!', 'ok');
|
| 1235 |
+
btn.disabled = false; btn.textContent = 'Run';
|
| 1236 |
+
}
|
| 1237 |
+
$('#btnRun').addEventListener('click', runWorkflow);
|
| 1238 |
+
/* ═══════════════════════════════════════════
|
| 1239 |
+
KEYBOARD SHORTCUTS
|
| 1240 |
+
═══════════════════════════════════════════ */
|
| 1241 |
+
document.addEventListener('keydown', e => {
|
| 1242 |
+
if (e.target.contentEditable === 'true' || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA' || e.target.closest('.CodeMirror')) return;
|
| 1243 |
+
if (e.key === 'Delete' || e.key === 'Backspace') {
|
| 1244 |
+
const sel = wfInner.querySelectorAll('.node.sel');
|
| 1245 |
+
if (sel.length) { sel.forEach(n => delNode(parseInt(n.dataset.nid))); e.preventDefault(); }
|
| 1246 |
+
}
|
| 1247 |
+
if (e.key === 'Escape') {
|
| 1248 |
+
closeCtxMenu(); closeSearchMenu();
|
| 1249 |
+
$('#settingsPanel').classList.remove('open');
|
| 1250 |
+
}
|
| 1251 |
+
if (e.key === ' ') {
|
| 1252 |
+
if (e.target === wfC || wfC.contains(e.target)) { e.preventDefault(); fitAll(); }
|
| 1253 |
+
}
|
| 1254 |
+
});
|
| 1255 |
+
/* ═══════════════════════════════════════════
|
| 1256 |
+
PYODIDE INIT
|
| 1257 |
+
═══════════════════════════════════════════ */
|
| 1258 |
+
async function initPyodide() {
|
| 1259 |
+
const ldMsg = $('#ldMsg'), ldSub = $('#ldSub');
|
| 1260 |
+
try {
|
| 1261 |
+
ldMsg.textContent = 'Loading Pyodide runtime...';
|
| 1262 |
+
pyodide = await loadPyodide();
|
| 1263 |
+
ldSub.textContent = 'Loading numpy...';
|
| 1264 |
+
await pyodide.loadPackage('numpy');
|
| 1265 |
+
ldSub.textContent = 'Loading Pillow...';
|
| 1266 |
+
await pyodide.loadPackage('pillow');
|
| 1267 |
+
ldSub.textContent = 'Loading scikit-image...';
|
| 1268 |
+
try {
|
| 1269 |
+
await pyodide.loadPackage('scikit-image');
|
| 1270 |
+
} catch(e) {
|
| 1271 |
+
ldSub.textContent = 'Installing scikit-image via micropip...';
|
| 1272 |
+
await pyodide.loadPackage('micropip');
|
| 1273 |
+
const mp = pyodide.pyimport('micropip');
|
| 1274 |
+
await mp.install('scikit-image');
|
| 1275 |
+
}
|
| 1276 |
+
ldSub.textContent = 'Initializing engine...';
|
| 1277 |
+
pyodide.runPython(PY_GRAPH);
|
| 1278 |
+
pyodide.runPython(PY_PROCESSOR);
|
| 1279 |
+
pyReady = true;
|
| 1280 |
+
$('#btnRun').disabled = false;
|
| 1281 |
+
$('#sDot').className = 'sdot ok';
|
| 1282 |
+
$('#sTxt').textContent = 'Ready';
|
| 1283 |
+
ldMsg.textContent = 'Ready!';
|
| 1284 |
+
setTimeout(() => $('#loader').classList.add('done'), 300);
|
| 1285 |
+
buildSidebar();
|
| 1286 |
+
initTabs();
|
| 1287 |
+
toast('Ready! Drag nodes or double-click canvas to begin.', 'ok');
|
| 1288 |
+
} catch(e) {
|
| 1289 |
+
console.error('Init error:', e);
|
| 1290 |
+
ldMsg.textContent = 'Failed to load';
|
| 1291 |
+
ldSub.textContent = (e.message || String(e)).slice(0, 300);
|
| 1292 |
+
$('.spinner').style.display = 'none';
|
| 1293 |
+
}
|
| 1294 |
+
}
|
| 1295 |
+
/* ═══════════════════════════════════════════
|
| 1296 |
+
BOOTSTRAP
|
| 1297 |
+
═══════════════════════════════════════════ */
|
| 1298 |
+
resizeCvs();
|
| 1299 |
+
initPyodide();
|
| 1300 |
+
/* ═══════════════════════════════════════════
|
| 1301 |
+
TOUCH SUPPORT
|
| 1302 |
+
═══════════════════════════════════════════ */
|
| 1303 |
+
let lastTouchDist = null;
|
| 1304 |
+
wfC.addEventListener('touchstart', e => {
|
| 1305 |
+
if (e.touches.length === 2) {
|
| 1306 |
+
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
| 1307 |
+
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
| 1308 |
+
lastTouchDist = Math.sqrt(dx*dx + dy*dy);
|
| 1309 |
+
}
|
| 1310 |
+
}, { passive: true });
|
| 1311 |
+
wfC.addEventListener('touchmove', e => {
|
| 1312 |
+
if (e.touches.length === 2 && lastTouchDist !== null) {
|
| 1313 |
+
e.preventDefault();
|
| 1314 |
+
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
| 1315 |
+
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
| 1316 |
+
const dist = Math.sqrt(dx*dx + dy*dy);
|
| 1317 |
+
const factor = dist / lastTouchDist;
|
| 1318 |
+
const nz = Math.max(0.1, Math.min(4, zoom * factor));
|
| 1319 |
+
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
| 1320 |
+
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
| 1321 |
+
const r = wfC.getBoundingClientRect();
|
| 1322 |
+
const mx = cx - r.left, my = cy - r.top;
|
| 1323 |
+
panX = mx - (mx - panX) * (nz / zoom);
|
| 1324 |
+
panY = my - (my - panY) * (nz / zoom);
|
| 1325 |
+
zoom = nz;
|
| 1326 |
+
applyTransform();
|
| 1327 |
+
lastTouchDist = dist;
|
| 1328 |
+
}
|
| 1329 |
+
}, { passive: false });
|
| 1330 |
+
wfC.addEventListener('touchend', () => { lastTouchDist = null; }, { passive: true });
|
| 1331 |
+
</script>
|
| 1332 |
+
</body>
|
| 1333 |
</html>
|