mirror of
https://github.com/litruv/picoGraph.git
synced 2026-07-24 02:36:04 +10:00
updated graph rendering to be more efficient. added smooth scrolling, added color type, mmb drag for cutting
This commit is contained in:
7
InstallationLog.txt
Normal file
7
InstallationLog.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
************************************* Invoked: Sat Mar 21 02:32:52 2026
|
||||
[0] Arguments: C:\Users\litru\AppData\Local\Temp\WinGet\MSYS2.MSYS2.20251213\msys2-x86_64-20251213.exe, install, --confirm-command, --root, C:\msys64
|
||||
[10] Operations sanity check succeeded.
|
||||
[22] Using metadata cache from "C:/Users/litru/AppData/Local/cache\\qt-installer-framework\\d75f1c19-3379-3717-ae8d-1404b51494a9"
|
||||
[22] Found 0 cached items.
|
||||
[29] Loaded control script ":/metadata/installer-config/control_js"
|
||||
[30] TargetDirectoryInUse : Error : The directory you selected already exists and contains an installation. Choose a different target for installation.
|
||||
BIN
all_star_croquet-0.p8.png
Normal file
BIN
all_star_croquet-0.p8.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -1,3 +1,20 @@
|
||||
// Enable hot-reload during development
|
||||
try {
|
||||
require('electron-reloader')(module, {
|
||||
debug: false,
|
||||
watchRenderer: true,
|
||||
ignore: [
|
||||
/node_modules/,
|
||||
/\.git/,
|
||||
/dist/,
|
||||
/build/,
|
||||
/pico8-temp-player\.html$/
|
||||
]
|
||||
});
|
||||
} catch (_) {
|
||||
// Ignore errors in production (when electron-reloader isn't installed)
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, ipcMain, Menu } = require("electron");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
@@ -51,6 +68,16 @@ function createMainWindow() {
|
||||
|
||||
window.loadFile(path.join(__dirname, "index.html"));
|
||||
|
||||
// Open DevTools in development
|
||||
window.webContents.openDevTools();
|
||||
|
||||
// Register F12 to toggle DevTools
|
||||
window.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12') {
|
||||
window.webContents.toggleDevTools();
|
||||
}
|
||||
});
|
||||
|
||||
window.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
@@ -75,6 +102,39 @@ function registerIpcHandlers() {
|
||||
|
||||
return { filePath: outputPath };
|
||||
});
|
||||
|
||||
ipcMain.handle("cart:preview", async (_event, cartContent) => {
|
||||
if (typeof cartContent !== "string") {
|
||||
throw new TypeError("Cart content must be a string");
|
||||
}
|
||||
|
||||
const tempCartPath = path.join(app.getPath("temp"), `preview_${Date.now()}.p8`);
|
||||
await fs.writeFile(tempCartPath, cartContent, "utf8");
|
||||
|
||||
return { filePath: tempCartPath };
|
||||
});
|
||||
|
||||
ipcMain.handle("player:writeHtml", async (_event, htmlContent) => {
|
||||
if (typeof htmlContent !== "string") {
|
||||
throw new TypeError("HTML content must be a string");
|
||||
}
|
||||
|
||||
const playerPath = path.join(__dirname, "public", "pico8-temp-player.html");
|
||||
await fs.writeFile(playerPath, htmlContent, "utf8");
|
||||
|
||||
return { filePath: playerPath };
|
||||
});
|
||||
|
||||
ipcMain.handle("player:writeCart", async (_event, cartContent) => {
|
||||
if (typeof cartContent !== "string") {
|
||||
throw new TypeError("Cart content must be a string");
|
||||
}
|
||||
|
||||
const cartPath = path.join(__dirname, "public", "pico8-cart.p8");
|
||||
await fs.writeFile(cartPath, cartContent, "utf8");
|
||||
|
||||
return { filePath: cartPath };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:;" />
|
||||
<title>picoGraph</title>
|
||||
<link rel="stylesheet" href="styles/main.css" />
|
||||
</head>
|
||||
@@ -10,6 +11,7 @@
|
||||
<header class="app-header">
|
||||
<div class="header-primary-actions">
|
||||
<button id="compileButton" class="header-primary-button" type="button">Compile</button>
|
||||
<button id="previewButton" class="header-primary-button" type="button" title="Preview cart">Preview</button>
|
||||
</div>
|
||||
<div class="header-title">picoGraph</div>
|
||||
<div class="header-actions">
|
||||
|
||||
232
package-lock.json
generated
232
package-lock.json
generated
@@ -10,7 +10,8 @@
|
||||
"devDependencies": {
|
||||
"electron": "^30.0.0",
|
||||
"electron-builder": "^24.6.3",
|
||||
"electron-icon-builder": "^2.0.1"
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-reloader": "^1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
@@ -1318,6 +1319,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"normalize-path": "^3.0.0",
|
||||
"picomatch": "^2.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/app-builder-bin": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-4.0.0.tgz",
|
||||
@@ -1729,6 +1744,19 @@
|
||||
"tweetnacl": "^0.14.3"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@@ -2017,6 +2045,31 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
@@ -2375,6 +2428,19 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/date-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz",
|
||||
"integrity": "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"time-zone": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2881,6 +2947,13 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-is-dev": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-1.2.0.tgz",
|
||||
"integrity": "sha512-R1oD5gMBPS7PVU8gJwH6CtT0e6VSoD0+SzSnYpNm+dBkcijgA+K7VAMHDfnRq/lkKPZArpzplTW6jfiMYosdzw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-publish": {
|
||||
"version": "24.13.1",
|
||||
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-24.13.1.tgz",
|
||||
@@ -2935,6 +3008,50 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-reloader": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-reloader/-/electron-reloader-1.2.3.tgz",
|
||||
"integrity": "sha512-aDnACAzNg0QvQhzw7LYOx/nVS10mEtbuG6M0QQvNQcLnJEwFs6is+EGRCnM+KQlQ4KcTbdwnt07nd7ZjHpY4iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.0",
|
||||
"chokidar": "^3.5.0",
|
||||
"date-time": "^3.1.0",
|
||||
"electron-is-dev": "^1.2.0",
|
||||
"find-up": "^5.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-reloader/node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
|
||||
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^6.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-reloader/node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -3369,6 +3486,21 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -3993,6 +4125,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-ci": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz",
|
||||
@@ -4495,6 +4640,22 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -4802,7 +4963,6 @@
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -4891,6 +5051,38 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"yocto-queue": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
|
||||
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
||||
@@ -5564,6 +5756,19 @@
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
@@ -6449,6 +6654,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/time-zone": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz",
|
||||
"integrity": "sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/timm": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/timm/-/timm-1.7.1.tgz",
|
||||
@@ -6869,6 +7084,19 @@
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zip-stream": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz",
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"devDependencies": {
|
||||
"electron": "^30.0.0",
|
||||
"electron-builder": "^24.6.3",
|
||||
"electron-icon-builder": "^2.0.1"
|
||||
"electron-icon-builder": "^2.0.1",
|
||||
"electron-reloader": "^1.2.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.litruv.picograph",
|
||||
@@ -22,7 +23,8 @@
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
"**/*"
|
||||
"**/*",
|
||||
"!picotool/**/*"
|
||||
],
|
||||
"win": {
|
||||
"icon": "build/icons/icon.ico",
|
||||
|
||||
3416
pico8-edu-page.html
Normal file
3416
pico8-edu-page.html
Normal file
File diff suppressed because it is too large
Load Diff
1
picotool
Submodule
1
picotool
Submodule
Submodule picotool added at 49808e5ddb
42
preload.js
42
preload.js
@@ -17,6 +17,48 @@ const api = {
|
||||
|
||||
return ipcRenderer.invoke("lua:compile", source);
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates a temporary .p8 cart file for preview
|
||||
*
|
||||
* @param {string} cartContent Complete .p8 cart file content
|
||||
* @returns {Promise<{ filePath: string }>}
|
||||
*/
|
||||
previewCart(cartContent) {
|
||||
if (typeof cartContent !== "string") {
|
||||
return Promise.reject(new TypeError("Cart content must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("cart:preview", cartContent);
|
||||
},
|
||||
|
||||
/**
|
||||
* Writes the PICO-8 player HTML to disk and returns the file path
|
||||
*
|
||||
* @param {string} htmlContent The player HTML content
|
||||
* @returns {Promise<{ filePath: string }>}
|
||||
*/
|
||||
writePlayerHtml(htmlContent) {
|
||||
if (typeof htmlContent !== "string") {
|
||||
return Promise.reject(new TypeError("HTML content must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("player:writeHtml", htmlContent);
|
||||
},
|
||||
|
||||
/**
|
||||
* Writes the cart .p8 file to the public directory for the player to load
|
||||
*
|
||||
* @param {string} cartContent The .p8 cart content
|
||||
* @returns {Promise<{ filePath: string }>}
|
||||
*/
|
||||
writePlayerCart(cartContent) {
|
||||
if (typeof cartContent !== "string") {
|
||||
return Promise.reject(new TypeError("Cart content must be a string"));
|
||||
}
|
||||
|
||||
return ipcRenderer.invoke("player:writeCart", cartContent);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", api);
|
||||
|
||||
51
public/pico8-cart.p8
Normal file
51
public/pico8-cart.p8
Normal file
@@ -0,0 +1,51 @@
|
||||
pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
-- Generated with picoGraph
|
||||
|
||||
zzzz = ""
|
||||
offset = 0
|
||||
color = 5
|
||||
var1 = nil
|
||||
|
||||
function _update()
|
||||
-- sequence a
|
||||
cls()
|
||||
offset = 0
|
||||
-- sequence b
|
||||
if btn(0, 0) then
|
||||
print("left", 0, offset, rnd((16) + (1)))
|
||||
custom_addoffset()
|
||||
end
|
||||
-- sequence c
|
||||
if btn(1, 0) then
|
||||
print("right", 0, offset, color)
|
||||
local itsalocalvariable = true
|
||||
if itsalocalvariable then
|
||||
custom_addoffset()
|
||||
end
|
||||
end
|
||||
-- sequence d
|
||||
if btn(2, 0) then
|
||||
print("up", 0, offset, color)
|
||||
custom_addoffset()
|
||||
end
|
||||
-- sequence e
|
||||
if btn(3, 0) then
|
||||
print("down", 0, offset, color)
|
||||
custom_addoffset()
|
||||
end
|
||||
end
|
||||
|
||||
function custom_addoffset()
|
||||
offset = (offset) + (7)
|
||||
end
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
3304
public/pico8-player.html
Normal file
3304
public/pico8-player.html
Normal file
File diff suppressed because it is too large
Load Diff
95
public/pico8-temp-player.html
Normal file
95
public/pico8-temp-player.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #1a1a2e; display: flex; align-items: center; justify-content: center; width: 100vw; height: 100vh; overflow: hidden; }
|
||||
#canvas { image-rendering: pixelated; image-rendering: crisp-edges; width: 512px !important; height: 512px !important; border: 8px solid #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="128" height="128"></canvas>
|
||||
<script>
|
||||
var pico8_state = [];
|
||||
var pico8_buttons = [0,0,0,0,0,0,0,0];
|
||||
var codo_command = 0;
|
||||
var _cartname = ['untitled.p8'];
|
||||
var codo_key_buffer = [];
|
||||
var p8_keyboard_state = 0;
|
||||
var pico8_mouse = [];
|
||||
var pico8_gamepads = { count: 0 };
|
||||
var pico8_gpio = new Array(128).fill(0);
|
||||
var p8_dropped_cart = null;
|
||||
var p8_dropped_cart_name = '';
|
||||
|
||||
var CART_B64 = 'cGljby04IGNhcnRyaWRnZSAvLyBodHRwOi8vd3d3LnBpY28tOC5jb20KdmVyc2lvbiA0MQpfX2x1YV9fCi0tIEdlbmVyYXRlZCB3aXRoIHBpY29HcmFwaAoKb2Zmc2V0ID0gMApjb2xvciA9IDUKCmZ1bmN0aW9uIF91cGRhdGUoKQogIC0tIHNlcXVlbmNlIGEKICBjbHMoKQogIG9mZnNldCA9IDAKICAtLSBzZXF1ZW5jZSBiCiAgaWYgYnRuKDAsIDApIHRoZW4KICAgIHByaW50KCJsZWZ0IiwgMCwgb2Zmc2V0LCBybmQoKDE2KSArICgxKSkpCiAgICBjdXN0b21fYWRkb2Zmc2V0KCkKICBlbmQKICAtLSBzZXF1ZW5jZSBjCiAgaWYgYnRuKDEsIDApIHRoZW4KICAgIHByaW50KCJyaWdodCIsIDAsIG9mZnNldCwgY29sb3IpCiAgICBsb2NhbCBpdHNhbG9jYWx2YXJpYWJsZSA9IHRydWUKICAgIGlmIGl0c2Fsb2NhbHZhcmlhYmxlIHRoZW4KICAgICAgY3VzdG9tX2FkZG9mZnNldCgpCiAgICBlbmQKICBlbmQKICAtLSBzZXF1ZW5jZSBkCiAgaWYgYnRuKDIsIDApIHRoZW4KICAgIHByaW50KCJ1cCIsIDAsIG9mZnNldCwgY29sb3IpCiAgICBjdXN0b21fYWRkb2Zmc2V0KCkKICBlbmQKICAtLSBzZXF1ZW5jZSBlCiAgaWYgYnRuKDMsIDApIHRoZW4KICAgIHByaW50KCJkb3duIiwgMCwgb2Zmc2V0LCBjb2xvcikKICAgIGN1c3RvbV9hZGRvZmZzZXQoKQogIGVuZAplbmQKCmZ1bmN0aW9uIF9kcmF3KCkKICAtLSBzZXF1ZW5jZSBhCiAgY2xzKCkKICBvZmZzZXQgPSAwCiAgLS0gc2VxdWVuY2UgYgogIGlmIGJ0bigwLCAwKSB0aGVuCiAgICBwcmludCgibGVmdCIsIDAsIG9mZnNldCwgcm5kKCgxNikgKyAoMSkpKQogICAgY3VzdG9tX2FkZG9mZnNldCgpCiAgZW5kCiAgLS0gc2VxdWVuY2UgYwogIGlmIGJ0bigxLCAwKSB0aGVuCiAgICBwcmludCgicmlnaHQiLCAwLCBvZmZzZXQsIGNvbG9yKQogICAgbG9jYWwgaXRzYWxvY2FsdmFyaWFibGUgPSB0cnVlCiAgICBpZiBpdHNhbG9jYWx2YXJpYWJsZSB0aGVuCiAgICAgIGN1c3RvbV9hZGRvZmZzZXQoKQogICAgZW5kCiAgZW5kCiAgLS0gc2VxdWVuY2UgZAogIGlmIGJ0bigyLCAwKSB0aGVuCiAgICBwcmludCgidXAiLCAwLCBvZmZzZXQsIGNvbG9yKQogICAgY3VzdG9tX2FkZG9mZnNldCgpCiAgZW5kCiAgLS0gc2VxdWVuY2UgZQogIGlmIGJ0bigzLCAwKSB0aGVuCiAgICBwcmludCgiZG93biIsIDAsIG9mZnNldCwgY29sb3IpCiAgICBjdXN0b21fYWRkb2Zmc2V0KCkKICBlbmQKZW5kCgpmdW5jdGlvbiBjdXN0b21fYWRkb2Zmc2V0KCkKICBvZmZzZXQgPSAob2Zmc2V0KSArICg3KQplbmQKX19nZnhfXwowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAowMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMAo=';
|
||||
|
||||
// Silence all XHR — PICO-8 makes BBS metadata calls that crash from file:// origin.
|
||||
(function() {
|
||||
function MockXHR() {
|
||||
this.onload = null; this.onerror = null; this.onreadystatechange = null;
|
||||
this.readyState = 0; this.status = 0; this.statusText = '';
|
||||
this.response = null; this.responseText = ''; this.responseType = '';
|
||||
}
|
||||
MockXHR.UNSENT=0; MockXHR.OPENED=1; MockXHR.HEADERS_RECEIVED=2; MockXHR.LOADING=3; MockXHR.DONE=4;
|
||||
MockXHR.prototype.open = function() { this.readyState = 1; };
|
||||
MockXHR.prototype.send = function() {
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
self.readyState = 4; self.status = 200; self.statusText = 'OK';
|
||||
self.response = self.responseType === 'arraybuffer' ? new ArrayBuffer(0) : '';
|
||||
self.responseText = '';
|
||||
if (typeof self.onload === 'function') self.onload({ target: self });
|
||||
if (typeof self.onreadystatechange === 'function') self.onreadystatechange();
|
||||
}, 0);
|
||||
};
|
||||
MockXHR.prototype.setRequestHeader = function() {};
|
||||
MockXHR.prototype.getResponseHeader = function() { return null; };
|
||||
MockXHR.prototype.getAllResponseHeaders = function() { return ''; };
|
||||
MockXHR.prototype.abort = function() {};
|
||||
MockXHR.prototype.addEventListener = function(e, fn) {
|
||||
if (e==='load') this.onload=fn;
|
||||
if (e==='error') this.onerror=fn;
|
||||
if (e==='readystatechange') this.onreadystatechange=fn;
|
||||
};
|
||||
MockXHR.prototype.removeEventListener = function() {};
|
||||
window.XMLHttpRequest = MockXHR;
|
||||
})();
|
||||
|
||||
var Module = {
|
||||
arguments: [],
|
||||
canvas: document.getElementById('canvas'),
|
||||
preRun: [function() {
|
||||
if (typeof IDBFS !== 'undefined' && typeof MEMFS !== 'undefined') {
|
||||
var orig = FS.mount.bind(FS);
|
||||
FS.mount = function(type, opts, mp) { return orig(type===IDBFS?MEMFS:type, opts, mp); };
|
||||
}
|
||||
FS.syncfs = function(populate, callback) {
|
||||
var cb = typeof callback==='function' ? callback : typeof populate==='function' ? populate : null;
|
||||
if (cb) setTimeout(function(){ cb(null); }, 0);
|
||||
};
|
||||
}],
|
||||
postRun: [function() {
|
||||
setTimeout(function() {
|
||||
p8_dropped_cart = 'data:application/octet-stream;base64,' + CART_B64;
|
||||
p8_dropped_cart_name = 'picograph.p8';
|
||||
codo_command = 9;
|
||||
|
||||
// After cart loads, inject "run" + Enter to execute it
|
||||
setTimeout(function() {
|
||||
// Push keystrokes: r u n Enter (lowercase ASCII)
|
||||
codo_key_buffer.push(114, 117, 110, 13);
|
||||
}, 300);
|
||||
}, 200);
|
||||
}],
|
||||
print: function(t) { console.log('P8:', t); },
|
||||
printErr: function(t) { console.error('P8:', t); },
|
||||
setStatus: function() {}
|
||||
};
|
||||
|
||||
function pico8_audio_context_suspended() { return false; }
|
||||
</script>
|
||||
<script src="./pico8_edu.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
public/pico8_edu.js
Normal file
25
public/pico8_edu.js
Normal file
File diff suppressed because one or more lines are too long
25
public/pico8_player.js
Normal file
25
public/pico8_player.js
Normal file
File diff suppressed because one or more lines are too long
55
public/player.html
Normal file
55
public/player.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PICO-8 Player</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
#canvas {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
background: #000;
|
||||
}
|
||||
#p8_container {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="p8_container">
|
||||
<canvas id="canvas"></canvas>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
// Cart will be injected here
|
||||
window.PICO8_CART_DATA = null;
|
||||
</script>
|
||||
<script src="pico8_player.js"></script>
|
||||
<script>
|
||||
Module = {
|
||||
arguments: ['-run'],
|
||||
canvas: document.getElementById('canvas'),
|
||||
preRun: [],
|
||||
postRun: []
|
||||
};
|
||||
|
||||
// Wait for cart data to be set
|
||||
if (window.PICO8_CART_DATA) {
|
||||
// Create virtual file system entry
|
||||
Module.preRun.push(function() {
|
||||
FS.createDataFile('/', 'cart.p8', window.PICO8_CART_DATA, true, true, true);
|
||||
});
|
||||
Module.arguments.push('/cart.p8');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
55
scripts/ci/find-edu-js.js
Normal file
55
scripts/ci/find-edu-js.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Downloads the latest PICO-8 Education Edition player JS from pico-8-edu.com.
|
||||
* Run with: node scripts/ci/find-edu-js.js
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INDEX_URL = 'https://www.pico-8-edu.com/';
|
||||
const PUBLIC_DIR = path.join(__dirname, '../../public');
|
||||
|
||||
function fetchText(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', d => chunks.push(d));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchBinary(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, res => {
|
||||
const chunks = [];
|
||||
res.on('data', d => chunks.push(d));
|
||||
res.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Fetching index to find current edu player version...');
|
||||
const html = await fetchText(INDEX_URL);
|
||||
|
||||
const match = html.match(/['"]\/play\/(pico8_edu[^'"]+\.js)['"]/);
|
||||
if (!match) {
|
||||
console.error('Could not find edu player JS path in page HTML.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const jsPath = match[1];
|
||||
const jsUrl = `https://www.pico-8-edu.com/play/${jsPath}`;
|
||||
const outFile = path.join(PUBLIC_DIR, 'pico8_edu.js');
|
||||
|
||||
console.log(`Found: ${jsPath}`);
|
||||
console.log(`Downloading from ${jsUrl}...`);
|
||||
|
||||
const buf = await fetchBinary(jsUrl);
|
||||
fs.writeFileSync(outFile, buf);
|
||||
console.log(`Saved to public/pico8_edu.js (${buf.length} bytes)`);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error(e); process.exit(1); });
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @property {string} id Unique pin identifier scoped to the node.
|
||||
* @property {string} name Public display name.
|
||||
* @property {('input'|'output')} direction Pin direction relative to the node.
|
||||
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Pin kind describing blueprint behavior.
|
||||
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any'|'color')} kind Pin kind describing blueprint behavior.
|
||||
* @property {string} [description] Optional tooltip/description.
|
||||
* @property {unknown} [defaultValue] Default value for data pins.
|
||||
*/
|
||||
|
||||
@@ -20,7 +20,7 @@ export class Connection {
|
||||
/**
|
||||
* @param {PinReference} from Origin pin (must be output).
|
||||
* @param {PinReference} to Target pin (must be input).
|
||||
* @param {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Pin kind carried by the connection.
|
||||
* @param {('exec'|'number'|'boolean'|'string'|'table'|'any'|'color')} kind Pin kind carried by the connection.
|
||||
* @param {string} [id] Optional connection identifier.
|
||||
*/
|
||||
constructor(from, to, kind, id) {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { BlueprintWorkspace } from "./ui/BlueprintWorkspace.js";
|
||||
import { NodeRegistry } from "./nodes/NodeRegistry.js";
|
||||
import { LuaGenerator } from "./core/LuaGenerator.js";
|
||||
import { EmbeddedPreview } from "./ui/EmbeddedPreview.js";
|
||||
|
||||
// Global error handlers
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('Global error:', event.error);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {{ compileLua(source: string): Promise<{ filePath: string }> }} ElectronAPI
|
||||
@@ -73,6 +83,9 @@ const deleteNodeButton = /** @type {HTMLButtonElement} */ (
|
||||
const compileButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(document.getElementById("compileButton"), "compileButton")
|
||||
);
|
||||
const previewButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(document.getElementById("previewButton"), "previewButton")
|
||||
);
|
||||
const exportLuaButton = /** @type {HTMLButtonElement} */ (
|
||||
requireElement(document.getElementById("exportLuaButton"), "exportLuaButton")
|
||||
);
|
||||
@@ -141,6 +154,13 @@ const workspace = new BlueprintWorkspace({
|
||||
|
||||
workspace.initialize();
|
||||
|
||||
// Initialize preview system
|
||||
const embeddedPreview = new EmbeddedPreview({
|
||||
getLuaCode: () => {
|
||||
return workspace.exportLua();
|
||||
},
|
||||
});
|
||||
|
||||
/** @type {ElectronAPI | undefined} */
|
||||
const electronAPI =
|
||||
typeof window !== "undefined" && "electronAPI" in window
|
||||
@@ -427,6 +447,10 @@ if (electronAPI && typeof electronAPI.compileLua === "function") {
|
||||
compileButton.title = "Compile is available in the desktop app";
|
||||
}
|
||||
|
||||
previewButton.addEventListener("click", () => {
|
||||
embeddedPreview.open();
|
||||
});
|
||||
|
||||
exportLuaButton.addEventListener("click", () => {
|
||||
openLuaModal();
|
||||
});
|
||||
|
||||
26
scripts/nodes/library/colorLiteral.js
Normal file
26
scripts/nodes/library/colorLiteral.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createNodeModule } from "../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Constant color literal.
|
||||
*/
|
||||
export const colorLiteralNode = createNodeModule(
|
||||
{
|
||||
id: "color_literal",
|
||||
title: "Color",
|
||||
category: "Values",
|
||||
description: "Constant color literal (0-15).",
|
||||
searchTags: ["color", "literal", "constant", "value"],
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ id: "value", name: "Value", direction: "output", kind: "color" },
|
||||
],
|
||||
properties: [
|
||||
{ key: "value", label: "Color", type: "number", defaultValue: 7 },
|
||||
],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ node, formatLiteral }) => {
|
||||
return formatLiteral("number", node.properties.value ?? 7);
|
||||
},
|
||||
}
|
||||
);
|
||||
26
scripts/nodes/library/converters/colorToNumber.js
Normal file
26
scripts/nodes/library/converters/colorToNumber.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createNodeModule } from "../../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Converts a color value to a numeric value.
|
||||
*/
|
||||
export const colorToNumberNode = createNodeModule(
|
||||
{
|
||||
id: "convert_color_to_number",
|
||||
title: "Color to Number",
|
||||
category: "Converters",
|
||||
description: "Convert a color value to a numeric value.",
|
||||
searchTags: ["color", "number", "convert", "cast"],
|
||||
inputs: [
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" }
|
||||
],
|
||||
outputs: [
|
||||
{ id: "number", name: "Number", direction: "output", kind: "number" }
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ resolveValueInput }) => {
|
||||
return resolveValueInput("color", "0");
|
||||
},
|
||||
}
|
||||
);
|
||||
7
scripts/nodes/library/converters/index.js
Normal file
7
scripts/nodes/library/converters/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { colorToNumberNode } from "./colorToNumber.js";
|
||||
import { numberToColorNode } from "./numberToColor.js";
|
||||
|
||||
/**
|
||||
* All type conversion nodes.
|
||||
*/
|
||||
export const converterNodes = [colorToNumberNode, numberToColorNode];
|
||||
26
scripts/nodes/library/converters/numberToColor.js
Normal file
26
scripts/nodes/library/converters/numberToColor.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createNodeModule } from "../../nodeTypes.js";
|
||||
|
||||
/**
|
||||
* Converts a numeric value to a color value.
|
||||
*/
|
||||
export const numberToColorNode = createNodeModule(
|
||||
{
|
||||
id: "convert_number_to_color",
|
||||
title: "Number to Color",
|
||||
category: "Converters",
|
||||
description: "Convert a numeric value to a color value.",
|
||||
searchTags: ["number", "color", "convert", "cast"],
|
||||
inputs: [
|
||||
{ id: "number", name: "Number", direction: "input", kind: "number" }
|
||||
],
|
||||
outputs: [
|
||||
{ id: "color", name: "Color", direction: "output", kind: "color" }
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
{
|
||||
evaluateValue: ({ resolveValueInput }) => {
|
||||
return resolveValueInput("number", "0");
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -33,7 +33,7 @@ export const circNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 4,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -33,7 +33,7 @@ export const circfillNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 4,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -12,7 +12,7 @@ export const clsNode = createNodeModule(
|
||||
searchTags: ["cls", "clear", "screen", "background"],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -12,7 +12,7 @@ export const colorNode = createNodeModule(
|
||||
searchTags: ["color", "color", "ink", "draw"],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -15,7 +15,7 @@ export const cursorNode = createNodeModule(
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "x", name: "X", direction: "input", kind: "number" },
|
||||
{ id: "y", name: "Y", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -29,7 +29,7 @@ export const lineNode = createNodeModule(
|
||||
},
|
||||
{ id: "x1", name: "X1", direction: "input", kind: "number" },
|
||||
{ id: "y1", name: "Y1", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ovalNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 8,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export const ovalfillNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 8,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -12,12 +12,12 @@ export const palNode = createNodeModule(
|
||||
searchTags: ["pal", "palette", "color", "remap"],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "c0", name: "Color 0", direction: "input", kind: "number" },
|
||||
{ id: "c0", name: "Color 0", direction: "input", kind: "color" },
|
||||
{
|
||||
id: "c1",
|
||||
name: "Color 1",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
kind: "color",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ id: "p", name: "Palette", direction: "input", kind: "number" },
|
||||
|
||||
@@ -12,7 +12,7 @@ export const paltNode = createNodeModule(
|
||||
searchTags: ["palt", "transparency", "palette", "sprite"],
|
||||
inputs: [
|
||||
{ id: "exec_in", name: "Exec", direction: "input", kind: "exec" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
{
|
||||
id: "transparent",
|
||||
name: "Transparent",
|
||||
|
||||
@@ -27,7 +27,7 @@ export const pgetNode = createNodeModule(
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ id: "value", name: "Color", direction: "output", kind: "number" },
|
||||
{ id: "value", name: "Color", direction: "output", kind: "color" },
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ export const psetNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export const rectNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 8,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -40,7 +40,7 @@ export const rectfillNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 8,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -47,7 +47,7 @@ export const rrectNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 2,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -47,7 +47,7 @@ export const rrectfillNode = createNodeModule(
|
||||
kind: "number",
|
||||
defaultValue: 2,
|
||||
},
|
||||
{ id: "color", name: "Color", direction: "input", kind: "number" },
|
||||
{ id: "color", name: "Color", direction: "input", kind: "color" },
|
||||
],
|
||||
outputs: [
|
||||
{ id: "exec_out", name: "Exec", direction: "output", kind: "exec" },
|
||||
|
||||
@@ -28,7 +28,7 @@ export const sgetNode = createNodeModule(
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ id: "value", name: "Color", direction: "output", kind: "number" },
|
||||
{ id: "value", name: "Color", direction: "output", kind: "color" },
|
||||
],
|
||||
properties: [],
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ssetNode = createNodeModule(
|
||||
id: "color",
|
||||
name: "Color",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
kind: "color",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getVariableNode } from "./getVariable.js";
|
||||
import { numberLiteralNode } from "./numberLiteral.js";
|
||||
import { stringLiteralNode } from "./stringLiteral.js";
|
||||
import { booleanLiteralNode } from "./booleanLiteral.js";
|
||||
import { colorLiteralNode } from "./colorLiteral.js";
|
||||
import { addNumberNode } from "./addNumber.js";
|
||||
import { multiplyNumberNode } from "./multiplyNumber.js";
|
||||
import { compareNode } from "./compare.js";
|
||||
@@ -31,6 +32,7 @@ import { serialNodes, serialFunctionChecklist } from "./serial/index.js";
|
||||
import { devkitNodes, devkitFunctionChecklist } from "./devkit/index.js";
|
||||
import { luaNodes, luaFunctionChecklist } from "./lua/index.js";
|
||||
import { localVariableNodes } from "./localVariables.js";
|
||||
import { converterNodes } from "./converters/index.js";
|
||||
|
||||
export const nodeModules = [
|
||||
eventInitNode,
|
||||
@@ -42,6 +44,7 @@ export const nodeModules = [
|
||||
numberLiteralNode,
|
||||
stringLiteralNode,
|
||||
booleanLiteralNode,
|
||||
colorLiteralNode,
|
||||
addNumberNode,
|
||||
multiplyNumberNode,
|
||||
compareNode,
|
||||
@@ -51,6 +54,7 @@ export const nodeModules = [
|
||||
customEventNode,
|
||||
callCustomEventNode,
|
||||
...localVariableNodes,
|
||||
...converterNodes,
|
||||
...graphicsNodes,
|
||||
...systemNodes,
|
||||
...tableNodes,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const printNode = createNodeModule(
|
||||
id: "color",
|
||||
name: "Color",
|
||||
direction: "input",
|
||||
kind: "number",
|
||||
kind: "color",
|
||||
defaultValue: 7,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* @property {string} id Stable identifier unique within the node definition.
|
||||
* @property {string} name Display name.
|
||||
* @property {('input'|'output')} direction Pin direction relative to the node.
|
||||
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any')} kind Blueprint connection kind.
|
||||
* @property {('exec'|'number'|'boolean'|'string'|'table'|'any'|'color')} kind Blueprint connection kind.
|
||||
* @property {string} [description] Optional tooltip text.
|
||||
* @property {unknown} [defaultValue] Default literal for data pins.
|
||||
*/
|
||||
|
||||
@@ -6,11 +6,13 @@ import { WorkspaceClipboardManager } from "./workspace/WorkspaceClipboardManager
|
||||
import { WorkspacePersistenceManager } from "./workspace/WorkspacePersistenceManager.js";
|
||||
import { WorkspaceMarqueeManager } from "./workspace/WorkspaceMarqueeManager.js";
|
||||
import { WorkspaceContextMenuManager } from "./workspace/WorkspaceContextMenuManager.js";
|
||||
import { WorkspaceNodeContextMenu } from "./workspace/WorkspaceNodeContextMenu.js";
|
||||
import { WorkspacePaletteManager } from "./workspace/WorkspacePaletteManager.js";
|
||||
import { WorkspaceDropManager } from "./workspace/WorkspaceDropManager.js";
|
||||
import { WorkspaceInlineEditorManager } from "./workspace/WorkspaceInlineEditorManager.js";
|
||||
import { WorkspaceNodeRenderer } from "./workspace/WorkspaceNodeRenderer.js";
|
||||
import { WorkspaceNavigationManager } from "./workspace/WorkspaceNavigationManager.js";
|
||||
import { WorkspaceWireCutManager } from "./workspace/WorkspaceWireCutManager.js";
|
||||
import { renderEventList } from "./workspace/renderEventList.js";
|
||||
import { renderVariableList } from "./workspace/renderVariableList.js";
|
||||
import {
|
||||
@@ -18,7 +20,7 @@ import {
|
||||
} from "./WorkspaceSpawnShortcuts.js";
|
||||
|
||||
/**
|
||||
* @typedef {'any'|'number'|'string'|'boolean'|'table'} VariableType
|
||||
* @typedef {'any'|'number'|'string'|'boolean'|'table'|'color'} VariableType
|
||||
*/
|
||||
|
||||
/** @type {Array<{ value: VariableType, label: string }>} */
|
||||
@@ -28,6 +30,7 @@ const VARIABLE_TYPE_DEFINITIONS = [
|
||||
{ value: "string", label: "String" },
|
||||
{ value: "boolean", label: "Boolean" },
|
||||
{ value: "table", label: "Table" },
|
||||
{ value: "color", label: "Color" },
|
||||
];
|
||||
|
||||
const SUPPORTED_VARIABLE_TYPES = new Set(
|
||||
@@ -175,6 +178,8 @@ export class BlueprintWorkspace {
|
||||
this.nodeDragRotationDecayFrame = null;
|
||||
/** @type {number | null} */
|
||||
this.connectionRefreshFrame = null;
|
||||
/** @type {number | null} */
|
||||
this.periodicUpdateTimer = null;
|
||||
|
||||
this.graph = new NodeGraph();
|
||||
/** @type {Map<string, WorkspaceVariable>} */
|
||||
@@ -240,6 +245,8 @@ export class BlueprintWorkspace {
|
||||
this.paletteManager = null;
|
||||
/** @type {WorkspaceContextMenuManager | null} */
|
||||
this.contextMenuManager = null;
|
||||
/** @type {WorkspaceNodeContextMenu | null} */
|
||||
this.nodeContextMenu = null;
|
||||
/** @type {WorkspaceDropManager | null} */
|
||||
this.dropManager = null;
|
||||
/** @type {WorkspaceMarqueeManager | null} */
|
||||
@@ -252,6 +259,8 @@ export class BlueprintWorkspace {
|
||||
this.navigationManager = null;
|
||||
/** @type {WorkspaceDragManager | null} */
|
||||
this.dragManager = null;
|
||||
/** @type {WorkspaceWireCutManager | null} */
|
||||
this.wireCutManager = null;
|
||||
/** @type {HTMLTemplateElement} */
|
||||
this.nodeTemplate = BlueprintWorkspace.#requireTemplate(
|
||||
"nodeTemplate",
|
||||
@@ -349,6 +358,7 @@ export class BlueprintWorkspace {
|
||||
isTypeDropdownOpen: (context) => this.#isTypeDropdownOpen(context),
|
||||
});
|
||||
this.contextMenuManager = new WorkspaceContextMenuManager(this);
|
||||
this.nodeContextMenu = new WorkspaceNodeContextMenu(this);
|
||||
this.paletteManager = new WorkspacePaletteManager(this);
|
||||
this.dropManager = new WorkspaceDropManager(this);
|
||||
this.marqueeManager = new WorkspaceMarqueeManager(this);
|
||||
@@ -356,6 +366,7 @@ export class BlueprintWorkspace {
|
||||
renderConnections: () => this.#renderConnections(),
|
||||
schedulePersist: () => this.persistenceManager.schedulePersist(),
|
||||
});
|
||||
this.wireCutManager = new WorkspaceWireCutManager(this);
|
||||
this.nodeRenderer = new WorkspaceNodeRenderer(this, {
|
||||
inlineEditorManager: this.inlineEditorManager,
|
||||
applySelectionState: () => this.#applySelectionState(),
|
||||
@@ -380,6 +391,7 @@ export class BlueprintWorkspace {
|
||||
});
|
||||
|
||||
this.contextMenuManager.initialize();
|
||||
this.nodeContextMenu.initialize();
|
||||
this.paletteManager.initialize();
|
||||
this.dropManager.initialize();
|
||||
this.marqueeManager.ensureElement();
|
||||
@@ -405,6 +417,32 @@ export class BlueprintWorkspace {
|
||||
|
||||
this.historyManager.initialize();
|
||||
this.isInitialized = true;
|
||||
this.#startPeriodicUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a periodic update that refreshes the graph when idle.
|
||||
*/
|
||||
#startPeriodicUpdate() {
|
||||
if (this.periodicUpdateTimer !== null) {
|
||||
return;
|
||||
}
|
||||
this.periodicUpdateTimer = window.setInterval(() => {
|
||||
if (!this.isDraggingNodes && this.workspaceDragDepth === 0) {
|
||||
this.#renderConnections();
|
||||
this.nodeRenderer?.refreshAllPinConnections();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the periodic update timer.
|
||||
*/
|
||||
#stopPeriodicUpdate() {
|
||||
if (this.periodicUpdateTimer !== null) {
|
||||
window.clearInterval(this.periodicUpdateTimer);
|
||||
this.periodicUpdateTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -726,7 +764,7 @@ export class BlueprintWorkspace {
|
||||
|
||||
event.preventDefault();
|
||||
const direction = event.deltaY < 0 ? 1 : -1;
|
||||
this.navigationManager.zoomAt(pointer, direction, {
|
||||
this.navigationManager.pushSmoothZoom(pointer, direction, {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
@@ -754,10 +792,25 @@ export class BlueprintWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.nodeContextMenu?.isVisible()) {
|
||||
if (
|
||||
!(rawTarget instanceof Node) ||
|
||||
!this.nodeContextMenu.isTargetInside(rawTarget)
|
||||
) {
|
||||
this.nodeContextMenu.hide();
|
||||
}
|
||||
}
|
||||
|
||||
if (!(rawTarget instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Middle mouse starts a wire-cut gesture from anywhere in the workspace.
|
||||
if (event.button === 1) {
|
||||
this.wireCutManager?.beginCut(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#isWorkspaceBackgroundTarget(rawTarget)) {
|
||||
return;
|
||||
}
|
||||
@@ -789,6 +842,7 @@ export class BlueprintWorkspace {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.nodeContextMenu?.hide();
|
||||
this.contextMenuManager.show(event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
@@ -921,7 +975,7 @@ export class BlueprintWorkspace {
|
||||
this.clipboardManager?.clearPointerPosition();
|
||||
});
|
||||
|
||||
this.connectionLayer.addEventListener("pointerdown", (event) => {
|
||||
this.workspaceElement.addEventListener("pointerdown", (event) => {
|
||||
this.#handleConnectionPointerDown(event);
|
||||
});
|
||||
}
|
||||
@@ -1031,6 +1085,72 @@ export class BlueprintWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes a node by replacing it with a new instance, preserving connections.
|
||||
*
|
||||
* @param {string} nodeId Target node identifier.
|
||||
* @returns {boolean} Whether the node was successfully refreshed.
|
||||
*/
|
||||
refreshNode(nodeId) {
|
||||
const node = this.graph.nodes.get(nodeId);
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const connections = this.graph.getConnectionsForNode(nodeId);
|
||||
const position = { ...node.position };
|
||||
const nodeType = node.type;
|
||||
const properties = { ...node.properties };
|
||||
|
||||
this.historyManager.withSuspended(() => {
|
||||
this.removeNode(nodeId);
|
||||
|
||||
const newNode = this.registry.createNode(nodeType, {
|
||||
id: nodeId,
|
||||
position,
|
||||
});
|
||||
|
||||
newNode.properties = properties;
|
||||
|
||||
this.graph.addNode(newNode);
|
||||
this.renderNode(newNode);
|
||||
|
||||
connections.forEach((connection) => {
|
||||
const isSource = connection.from.nodeId === nodeId;
|
||||
const isTarget = connection.to.nodeId === nodeId;
|
||||
|
||||
if (isSource) {
|
||||
const fromPin = newNode.getPin(connection.from.pinId);
|
||||
const toNode = this.graph.nodes.get(connection.to.nodeId);
|
||||
const toPin = toNode?.getPin(connection.to.pinId);
|
||||
|
||||
if (fromPin && toNode && toPin) {
|
||||
this.graph.connect(
|
||||
{ nodeId: newNode.id, pinId: fromPin.id },
|
||||
{ nodeId: toNode.id, pinId: toPin.id }
|
||||
);
|
||||
}
|
||||
} else if (isTarget) {
|
||||
const fromNode = this.graph.nodes.get(connection.from.nodeId);
|
||||
const fromPin = fromNode?.getPin(connection.from.pinId);
|
||||
const toPin = newNode.getPin(connection.to.pinId);
|
||||
|
||||
if (fromNode && fromPin && toPin) {
|
||||
this.graph.connect(
|
||||
{ nodeId: fromNode.id, pinId: fromPin.id },
|
||||
{ nodeId: newNode.id, pinId: toPin.id }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.#renderConnections();
|
||||
this.nodeRenderer.refreshAllPinConnections();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current node selection.
|
||||
*/
|
||||
@@ -4739,15 +4859,24 @@ export class BlueprintWorkspace {
|
||||
* @param {PointerEvent} event Pointer interaction payload.
|
||||
*/
|
||||
#handleConnectionPointerDown(event) {
|
||||
if (!(event.target instanceof SVGPathElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button !== 0 || !event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = /** @type {SVGPathElement} */ (event.target);
|
||||
// The nodeLayer div sits above the SVG in z-order, so event.target is usually a
|
||||
// node element rather than an SVGPathElement. Fall back to a point-based lookup.
|
||||
let path = event.target instanceof SVGPathElement ? /** @type {SVGPathElement} */ (event.target) : null;
|
||||
if (!path) {
|
||||
const hits = document.elementsFromPoint(event.clientX, event.clientY);
|
||||
path = /** @type {SVGPathElement | null} */ (
|
||||
hits.find((el) => el instanceof SVGPathElement && /** @type {SVGPathElement} */ (el).dataset.connectionId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = path.dataset.connectionId;
|
||||
if (!connectionId) {
|
||||
return;
|
||||
@@ -5179,23 +5308,20 @@ export class BlueprintWorkspace {
|
||||
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
||||
const startRect = startHandle.getBoundingClientRect();
|
||||
const zoom = this.zoomLevel || 1;
|
||||
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||
const anchor = {
|
||||
x:
|
||||
(startRect.left - workspaceRect.left + startRect.width / 2) /
|
||||
zoom,
|
||||
y:
|
||||
(startRect.top - workspaceRect.top + startRect.height / 2) /
|
||||
zoom,
|
||||
x: (startRect.left - workspaceRect.left + startRect.width / 2) / zoom - offset.x,
|
||||
y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y,
|
||||
};
|
||||
const pointer = {
|
||||
x: (clientX - workspaceRect.left) / zoom,
|
||||
y: (clientY - workspaceRect.top) / zoom,
|
||||
x: (clientX - workspaceRect.left) / zoom - offset.x,
|
||||
y: (clientY - workspaceRect.top) / zoom - offset.y,
|
||||
};
|
||||
|
||||
const start = direction === "output" ? anchor : pointer;
|
||||
const end = direction === "output" ? pointer : anchor;
|
||||
|
||||
const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5);
|
||||
const controlOffset = Math.max(60, Math.abs(end.x - start.x) * 0.5);
|
||||
const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
|
||||
start.y
|
||||
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
|
||||
@@ -5445,6 +5571,7 @@ export class BlueprintWorkspace {
|
||||
const targetHeight = Math.max(1, workspaceRect.height / zoom);
|
||||
this.connectionLayer.style.width = `${targetWidth}px`;
|
||||
this.connectionLayer.style.height = `${targetHeight}px`;
|
||||
this.connectionLayer.setAttribute("overflow", "visible");
|
||||
if (this.connectionLayer.hasAttribute("viewBox")) {
|
||||
this.connectionLayer.removeAttribute("viewBox");
|
||||
}
|
||||
@@ -5472,7 +5599,7 @@ export class BlueprintWorkspace {
|
||||
}
|
||||
|
||||
const { start, end } = geometry;
|
||||
const controlOffset = Math.max(60 / zoom, Math.abs(end.x - start.x) * 0.5);
|
||||
const controlOffset = Math.max(60, Math.abs(end.x - start.x) * 0.5);
|
||||
const d = `M ${start.x} ${start.y} C ${start.x + controlOffset} ${
|
||||
start.y
|
||||
} ${end.x - controlOffset} ${end.y} ${end.x} ${end.y}`;
|
||||
@@ -5504,22 +5631,17 @@ export class BlueprintWorkspace {
|
||||
|
||||
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
||||
const zoom = this.zoomLevel || 1;
|
||||
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||
const startRect = startHandle.getBoundingClientRect();
|
||||
const endRect = endHandle.getBoundingClientRect();
|
||||
return {
|
||||
start: {
|
||||
x:
|
||||
(startRect.left - workspaceRect.left + startRect.width / 2) /
|
||||
zoom,
|
||||
y:
|
||||
(startRect.top - workspaceRect.top + startRect.height / 2) /
|
||||
zoom,
|
||||
x: (startRect.left - workspaceRect.left + startRect.width / 2) / zoom - offset.x,
|
||||
y: (startRect.top - workspaceRect.top + startRect.height / 2) / zoom - offset.y,
|
||||
},
|
||||
end: {
|
||||
x:
|
||||
(endRect.left - workspaceRect.left + endRect.width / 2) / zoom,
|
||||
y:
|
||||
(endRect.top - workspaceRect.top + endRect.height / 2) / zoom,
|
||||
x: (endRect.left - workspaceRect.left + endRect.width / 2) / zoom - offset.x,
|
||||
y: (endRect.top - workspaceRect.top + endRect.height / 2) / zoom - offset.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5560,14 +5682,11 @@ export class BlueprintWorkspace {
|
||||
const rect = handle.getBoundingClientRect();
|
||||
const workspaceRect = this.workspaceElement.getBoundingClientRect();
|
||||
const zoom = this.zoomLevel || 1;
|
||||
const offset = this.navigationManager?.getEffectiveOffset() ?? this.workspaceBackgroundOffset ?? { x: 0, y: 0 };
|
||||
|
||||
return {
|
||||
x:
|
||||
(rect.left - workspaceRect.left + rect.width / 2) /
|
||||
zoom,
|
||||
y:
|
||||
(rect.top - workspaceRect.top + rect.height / 2) /
|
||||
zoom,
|
||||
x: (rect.left - workspaceRect.left + rect.width / 2) / zoom - offset.x,
|
||||
y: (rect.top - workspaceRect.top + rect.height / 2) / zoom - offset.y,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6356,10 +6475,12 @@ export class BlueprintWorkspace {
|
||||
*/
|
||||
applySerializedWorkspace(payload) {
|
||||
this.isRestoring = true;
|
||||
this.#stopPeriodicUpdate();
|
||||
try {
|
||||
this.persistenceManager.cancelScheduledPersist();
|
||||
|
||||
this.contextMenuManager.hide();
|
||||
this.nodeContextMenu?.hide();
|
||||
this.#closeTypeDropdown();
|
||||
this.dragManager.removePaletteGhost();
|
||||
this.dragManager.removeNodeGhost();
|
||||
@@ -6480,7 +6601,6 @@ export class BlueprintWorkspace {
|
||||
this.isPaletteVisible = paletteVisible;
|
||||
this.#applyPaletteVisibility(this.isPaletteVisible);
|
||||
|
||||
this.navigationManager.setBackgroundOffset({ x: 0, y: 0 });
|
||||
this.#updateProjectSettingsToggle();
|
||||
this.#renderOverview();
|
||||
this.#setInspectorView("default");
|
||||
@@ -6489,6 +6609,7 @@ export class BlueprintWorkspace {
|
||||
this.#refreshLuaOutput();
|
||||
} finally {
|
||||
this.isRestoring = false;
|
||||
this.#startPeriodicUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
383
scripts/ui/EmbeddedPreview.js
Normal file
383
scripts/ui/EmbeddedPreview.js
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Manages embedded Pico-8 cart preview within the application.
|
||||
*/
|
||||
export class EmbeddedPreview {
|
||||
/**
|
||||
* @param {object} options Configuration options
|
||||
* @param {() => string} options.getLuaCode Function to get generated Lua code
|
||||
*/
|
||||
constructor(options) {
|
||||
this.getLuaCode = options.getLuaCode;
|
||||
this.modal = null;
|
||||
this.canvas = null;
|
||||
this.isOpen = false;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the preview modal if it doesn't exist
|
||||
*/
|
||||
#ensureModal() {
|
||||
if (this.modal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create modal structure
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.className = 'preview-modal';
|
||||
this.modal.setAttribute('role', 'dialog');
|
||||
this.modal.setAttribute('aria-modal', 'true');
|
||||
this.modal.setAttribute('aria-labelledby', 'previewModalTitle');
|
||||
this.modal.hidden = true;
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'preview-modal__backdrop';
|
||||
backdrop.addEventListener('click', () => this.close());
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'preview-modal__panel';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'preview-modal__header';
|
||||
|
||||
// Header controls (left side)
|
||||
const headerControls = document.createElement('div');
|
||||
headerControls.className = 'preview-header-controls';
|
||||
|
||||
const refreshButton = document.createElement('button');
|
||||
refreshButton.type = 'button';
|
||||
refreshButton.className = 'preview-header-btn';
|
||||
refreshButton.setAttribute('aria-label', 'Reload cart');
|
||||
refreshButton.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>';
|
||||
refreshButton.addEventListener('click', () => this.#runCart());
|
||||
|
||||
const stopButton = document.createElement('button');
|
||||
stopButton.type = 'button';
|
||||
stopButton.className = 'preview-header-btn preview-header-btn--stop';
|
||||
stopButton.setAttribute('aria-label', 'Stop cart');
|
||||
stopButton.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="16" height="16" rx="2"/></svg>';
|
||||
stopButton.addEventListener('click', () => this.#stopCart());
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.type = 'button';
|
||||
saveButton.className = 'preview-header-btn preview-header-btn--save';
|
||||
saveButton.setAttribute('aria-label', 'Save .p8');
|
||||
saveButton.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
saveButton.addEventListener('click', () => this.#downloadCart());
|
||||
|
||||
headerControls.appendChild(refreshButton);
|
||||
headerControls.appendChild(stopButton);
|
||||
headerControls.appendChild(saveButton);
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'preview-modal__close';
|
||||
closeButton.setAttribute('aria-label', 'Close preview');
|
||||
closeButton.textContent = '×';
|
||||
closeButton.addEventListener('click', () => this.close());
|
||||
|
||||
header.appendChild(headerControls);
|
||||
header.appendChild(closeButton);
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'preview-modal__content';
|
||||
|
||||
// Create iframe for PICO-8 player
|
||||
this.iframe = document.createElement('iframe');
|
||||
this.iframe.className = 'preview-iframe';
|
||||
this.iframe.allow = 'gamepad';
|
||||
this.iframe.style.display = 'none';
|
||||
|
||||
// Create the canvas for rendering placeholder
|
||||
this.canvas = document.createElement('canvas');
|
||||
this.canvas.width = 128;
|
||||
this.canvas.height = 128;
|
||||
this.canvas.className = 'preview-canvas';
|
||||
|
||||
content.appendChild(this.iframe);
|
||||
content.appendChild(this.canvas);
|
||||
|
||||
// Refocus iframe when clicking in the modal to ensure input keeps working
|
||||
panel.addEventListener('click', (e) => {
|
||||
if (this.iframe.style.display !== 'none') {
|
||||
setTimeout(() => {
|
||||
this.iframe.focus();
|
||||
if (this.iframe.contentWindow) {
|
||||
this.iframe.contentWindow.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(content);
|
||||
|
||||
this.modal.appendChild(backdrop);
|
||||
this.modal.appendChild(panel);
|
||||
document.body.appendChild(this.modal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the cart in the embedded preview
|
||||
*/
|
||||
async #runCart() {
|
||||
try {
|
||||
const luaCode = this.getLuaCode();
|
||||
|
||||
if (!luaCode || !luaCode.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// Generate cart content
|
||||
const cartContent = this.#generateCartContent(luaCode);
|
||||
|
||||
// Load cart in iframe using PICO-8 web player
|
||||
await this.#loadCartInPlayer(cartContent);
|
||||
} catch (error) {
|
||||
console.error('Error running cart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a cart into the PICO-8 web player
|
||||
*
|
||||
* @param {string} cartData The .p8 cart file content as a string
|
||||
*/
|
||||
async #loadCartInPlayer(cartData) {
|
||||
try {
|
||||
const electronAPI = window.electronAPI;
|
||||
if (!electronAPI?.writePlayerHtml) {
|
||||
throw new Error('Player IPC not available');
|
||||
}
|
||||
|
||||
const cartB64 = btoa(unescape(encodeURIComponent(cartData)));
|
||||
|
||||
// HTML references pico8_edu.js by relative path — both files live in public/.
|
||||
const playerHtml = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #1a1a2e; display: flex; align-items: center; justify-content: center; width: 100vw; height: 100vh; overflow: hidden; }
|
||||
#canvas { image-rendering: pixelated; image-rendering: crisp-edges; width: 512px !important; height: 512px !important; border: 8px solid #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="128" height="128"></canvas>
|
||||
<script>
|
||||
var pico8_state = [];
|
||||
var pico8_buttons = [0,0,0,0,0,0,0,0];
|
||||
var codo_command = 0;
|
||||
var _cartname = ['untitled.p8'];
|
||||
var codo_key_buffer = [];
|
||||
var p8_keyboard_state = 0;
|
||||
var pico8_mouse = [];
|
||||
var pico8_gamepads = { count: 0 };
|
||||
var pico8_gpio = new Array(128).fill(0);
|
||||
var p8_dropped_cart = null;
|
||||
var p8_dropped_cart_name = '';
|
||||
|
||||
var CART_B64 = '${cartB64}';
|
||||
|
||||
// Silence all XHR — PICO-8 makes BBS metadata calls that crash from file:// origin.
|
||||
(function() {
|
||||
function MockXHR() {
|
||||
this.onload = null; this.onerror = null; this.onreadystatechange = null;
|
||||
this.readyState = 0; this.status = 0; this.statusText = '';
|
||||
this.response = null; this.responseText = ''; this.responseType = '';
|
||||
}
|
||||
MockXHR.UNSENT=0; MockXHR.OPENED=1; MockXHR.HEADERS_RECEIVED=2; MockXHR.LOADING=3; MockXHR.DONE=4;
|
||||
MockXHR.prototype.open = function() { this.readyState = 1; };
|
||||
MockXHR.prototype.send = function() {
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
self.readyState = 4; self.status = 200; self.statusText = 'OK';
|
||||
self.response = self.responseType === 'arraybuffer' ? new ArrayBuffer(0) : '';
|
||||
self.responseText = '';
|
||||
if (typeof self.onload === 'function') self.onload({ target: self });
|
||||
if (typeof self.onreadystatechange === 'function') self.onreadystatechange();
|
||||
}, 0);
|
||||
};
|
||||
MockXHR.prototype.setRequestHeader = function() {};
|
||||
MockXHR.prototype.getResponseHeader = function() { return null; };
|
||||
MockXHR.prototype.getAllResponseHeaders = function() { return ''; };
|
||||
MockXHR.prototype.abort = function() {};
|
||||
MockXHR.prototype.addEventListener = function(e, fn) {
|
||||
if (e==='load') this.onload=fn;
|
||||
if (e==='error') this.onerror=fn;
|
||||
if (e==='readystatechange') this.onreadystatechange=fn;
|
||||
};
|
||||
MockXHR.prototype.removeEventListener = function() {};
|
||||
window.XMLHttpRequest = MockXHR;
|
||||
})();
|
||||
|
||||
var Module = {
|
||||
arguments: [],
|
||||
canvas: document.getElementById('canvas'),
|
||||
preRun: [function() {
|
||||
if (typeof IDBFS !== 'undefined' && typeof MEMFS !== 'undefined') {
|
||||
var orig = FS.mount.bind(FS);
|
||||
FS.mount = function(type, opts, mp) { return orig(type===IDBFS?MEMFS:type, opts, mp); };
|
||||
}
|
||||
FS.syncfs = function(populate, callback) {
|
||||
var cb = typeof callback==='function' ? callback : typeof populate==='function' ? populate : null;
|
||||
if (cb) setTimeout(function(){ cb(null); }, 0);
|
||||
};
|
||||
}],
|
||||
postRun: [function() {
|
||||
setTimeout(function() {
|
||||
p8_dropped_cart = 'data:application/octet-stream;base64,' + CART_B64;
|
||||
p8_dropped_cart_name = 'picograph.p8';
|
||||
codo_command = 9;
|
||||
|
||||
// After cart loads, inject "run" + Enter to execute it
|
||||
setTimeout(function() {
|
||||
// Push keystrokes: r u n Enter (lowercase ASCII)
|
||||
codo_key_buffer.push(114, 117, 110, 13);
|
||||
}, 300);
|
||||
}, 200);
|
||||
}],
|
||||
print: function(t) { console.log('P8:', t); },
|
||||
printErr: function(t) { console.error('P8:', t); },
|
||||
setStatus: function() {}
|
||||
};
|
||||
|
||||
function pico8_audio_context_suspended() { return false; }
|
||||
</script>
|
||||
<script src="./pico8_edu.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const { filePath } = await electronAPI.writePlayerHtml(playerHtml);
|
||||
const fileUrl = 'file:///' + filePath.replace(/\\/g, '/') + '?t=' + Date.now();
|
||||
|
||||
this.iframe.src = 'about:blank';
|
||||
this.iframe.src = fileUrl;
|
||||
this.iframe.style.display = 'block';
|
||||
this.canvas.style.display = 'none';
|
||||
|
||||
// Focus iframe once loaded so it captures keyboard/gamepad input
|
||||
this.iframe.onload = () => {
|
||||
setTimeout(() => {
|
||||
this.iframe.focus();
|
||||
if (this.iframe.contentWindow) {
|
||||
this.iframe.contentWindow.focus();
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load player:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the running cart
|
||||
*/
|
||||
#stopCart() {
|
||||
this.isRunning = false;
|
||||
|
||||
// Hide iframe and show canvas
|
||||
if (this.iframe) {
|
||||
this.iframe.src = 'about:blank';
|
||||
this.iframe.style.display = 'none';
|
||||
}
|
||||
|
||||
if (this.canvas) {
|
||||
this.canvas.style.display = 'block';
|
||||
const ctx = this.canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, 128, 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the current cart as a .p8 file
|
||||
*/
|
||||
#downloadCart() {
|
||||
try {
|
||||
const luaCode = this.getLuaCode();
|
||||
const cartContent = this.#generateCartContent(luaCode);
|
||||
|
||||
const blob = new Blob([cartContent], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `picograph_${Date.now()}.p8`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading cart:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the .p8 cart file content
|
||||
*
|
||||
* @param {string} luaCode Generated Lua code
|
||||
* @returns {string}
|
||||
*/
|
||||
#generateCartContent(luaCode) {
|
||||
const hasUpdateCallback = /function\s+_update(?:60)?\b|_update(?:60)?\s*=/.test(luaCode);
|
||||
const hasDrawCallback = /function\s+_draw\b|_draw\s*=/.test(luaCode);
|
||||
const fallback = (hasUpdateCallback && hasDrawCallback) ? '' :
|
||||
(!hasUpdateCallback ? '\nfunction _update()\nend\n' : '') +
|
||||
(!hasDrawCallback ? '\nfunction _draw()\n cls()\n print("picograph ok",2,60,7)\nend\n' : '');
|
||||
return `pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
${luaCode}${fallback}
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the preview modal
|
||||
*/
|
||||
open() {
|
||||
this.#ensureModal();
|
||||
this.modal.hidden = false;
|
||||
this.isOpen = true;
|
||||
|
||||
// Auto-run the cart
|
||||
this.#runCart();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the preview modal
|
||||
*/
|
||||
close() {
|
||||
this.#stopCart();
|
||||
if (this.modal) {
|
||||
this.modal.hidden = true;
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the preview modal
|
||||
*/
|
||||
toggle() {
|
||||
if (this.isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
}
|
||||
102
scripts/ui/PreviewManager.js
Normal file
102
scripts/ui/PreviewManager.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
/**
|
||||
* Manages cart preview functionality
|
||||
*/
|
||||
export class PreviewManager {
|
||||
/**
|
||||
* @param {object} options Configuration options
|
||||
* @param {() => string} options.getLuaCode Function to get generated Lua code
|
||||
* @param {string} options.fake08Path Path to fake-08 executable
|
||||
*/
|
||||
constructor(options) {
|
||||
this.getLuaCode = options.getLuaCode;
|
||||
this.fake08Path = options.fake08Path || null;
|
||||
this.currentProcess = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports the current graph as a .p8 file
|
||||
*
|
||||
* @param {string} outputPath Path to save the .p8 file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async exportCart(outputPath) {
|
||||
const luaCode = this.getLuaCode();
|
||||
|
||||
// Create basic .p8 cart format
|
||||
const cartContent = `pico-8 cartridge // http://www.pico-8.com
|
||||
version 41
|
||||
__lua__
|
||||
${luaCode}
|
||||
__gfx__
|
||||
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
||||
`;
|
||||
|
||||
await writeFile(outputPath, cartContent, 'utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches the preview with fake-08
|
||||
*
|
||||
* @param {string} cartPath Path to the .p8 file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async launchPreview(cartPath) {
|
||||
if (!this.fake08Path) {
|
||||
throw new Error('fake-08 path not configured');
|
||||
}
|
||||
|
||||
// Kill existing preview if running
|
||||
if (this.currentProcess) {
|
||||
this.currentProcess.kill();
|
||||
this.currentProcess = null;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.currentProcess = spawn(this.fake08Path, [cartPath]);
|
||||
|
||||
this.currentProcess.on('error', (err) => {
|
||||
console.error('Failed to launch preview:', err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
this.currentProcess.on('exit', (code) => {
|
||||
console.log(`Preview exited with code ${code}`);
|
||||
this.currentProcess = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports and previews the current cart
|
||||
*
|
||||
* @param {string} [tempPath] Optional path for temporary cart file
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async exportAndPreview(tempPath = null) {
|
||||
const outputPath = tempPath || join(process.cwd(), 'temp_preview.p8');
|
||||
|
||||
await this.exportCart(outputPath);
|
||||
|
||||
if (this.fake08Path) {
|
||||
await this.launchPreview(outputPath);
|
||||
} else {
|
||||
console.log(`Cart exported to: ${outputPath}`);
|
||||
console.log('fake-08 not configured - cannot launch preview');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops any running preview
|
||||
*/
|
||||
stopPreview() {
|
||||
if (this.currentProcess) {
|
||||
this.currentProcess.kill();
|
||||
this.currentProcess = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,9 +565,10 @@ export class WorkspaceDragManager {
|
||||
const zoom = this.workspace.zoomLevel || 1;
|
||||
const safeX = Number.isFinite(clientX) ? clientX : rect.left;
|
||||
const safeY = Number.isFinite(clientY) ? clientY : rect.top;
|
||||
const pending = this.workspace.navigationManager?.getPendingLayerOffset?.() ?? { x: 0, y: 0 };
|
||||
return {
|
||||
x: (safeX - rect.left) / zoom,
|
||||
y: (safeY - rect.top) / zoom,
|
||||
x: (safeX - rect.left) / zoom - pending.x,
|
||||
y: (safeY - rect.top) / zoom - pending.y,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -302,7 +302,6 @@ export class WorkspaceHistoryManager {
|
||||
this.#pendingReason = null;
|
||||
|
||||
this.#workspace.applySerializedWorkspace(snapshot.workspace);
|
||||
this.#workspace.applyHistoryView(snapshot.view);
|
||||
this.#workspace.applyHistorySelection(snapshot.selection);
|
||||
|
||||
this.isReady = wasReady;
|
||||
|
||||
@@ -13,6 +13,10 @@ export class WorkspaceNavigationManager {
|
||||
#zoomConfig;
|
||||
#panState;
|
||||
#lastPanTimestamp;
|
||||
#targetZoomLevel;
|
||||
#smoothZoomFocus;
|
||||
#smoothZoomRafId;
|
||||
#pendingLayerOffset;
|
||||
|
||||
/**
|
||||
* @param {import("../BlueprintWorkspace.js").BlueprintWorkspace} workspace Owning workspace instance.
|
||||
@@ -39,6 +43,10 @@ export class WorkspaceNavigationManager {
|
||||
this.#zoomConfig = { min: 0.25, max: 2.5, step: 0.1 };
|
||||
this.#panState = null;
|
||||
this.#lastPanTimestamp = 0;
|
||||
this.#targetZoomLevel = 1;
|
||||
this.#smoothZoomFocus = null;
|
||||
this.#smoothZoomRafId = null;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,18 +56,29 @@ export class WorkspaceNavigationManager {
|
||||
return this.#zoomLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the accumulated node-layer translate offset that is pending commit
|
||||
* during a smooth zoom animation. This must be accounted for when converting
|
||||
* screen/client coordinates to world coordinates mid-lerp.
|
||||
*
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
getPendingLayerOffset() {
|
||||
return { x: this.#pendingLayerOffset.x, y: this.#pendingLayerOffset.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjusts the zoom level within configured bounds.
|
||||
*
|
||||
* @param {number} nextZoom Requested zoom multiplier.
|
||||
* @param {{ silent?: boolean }} [options] Behaviour overrides.
|
||||
* @param {{ silent?: boolean, skipConnectionRender?: boolean }} [options] Behaviour overrides.
|
||||
* @returns {boolean} Whether the zoom value changed.
|
||||
*/
|
||||
setZoomLevel(nextZoom, options = {}) {
|
||||
if (!Number.isFinite(nextZoom)) {
|
||||
return false;
|
||||
}
|
||||
const { silent = false } = options;
|
||||
const { silent = false, skipConnectionRender = false } = options;
|
||||
const clamped = Math.max(
|
||||
this.#zoomConfig.min,
|
||||
Math.min(this.#zoomConfig.max, nextZoom)
|
||||
@@ -68,8 +87,25 @@ export class WorkspaceNavigationManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel any running smooth zoom so programmatic calls take immediate effect.
|
||||
if (this.#smoothZoomRafId !== null) {
|
||||
cancelAnimationFrame(this.#smoothZoomRafId);
|
||||
this.#smoothZoomRafId = null;
|
||||
this.#smoothZoomFocus = null;
|
||||
// Commit any pending layer offset before the snap.
|
||||
const pending = this.#pendingLayerOffset;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
if (Math.abs(pending.x) > 0.0001 || Math.abs(pending.y) > 0.0001) {
|
||||
this.setBackgroundOffset({
|
||||
x: this.#backgroundOffset.x - pending.x,
|
||||
y: this.#backgroundOffset.y - pending.y,
|
||||
});
|
||||
this.translateWorkspace(pending);
|
||||
}
|
||||
}
|
||||
this.#targetZoomLevel = clamped;
|
||||
this.#zoomLevel = clamped;
|
||||
this.updateZoomDisplay();
|
||||
this.updateZoomDisplay(skipConnectionRender);
|
||||
if (!silent) {
|
||||
this.#markViewDirty("view", 200);
|
||||
this.#schedulePersist();
|
||||
@@ -86,25 +122,32 @@ export class WorkspaceNavigationManager {
|
||||
|
||||
/**
|
||||
* Updates workspace transforms to match the stored zoom level.
|
||||
*
|
||||
* @param {boolean} [skipConnectionRender] When true, skips the connection re-render pass.
|
||||
*/
|
||||
updateZoomDisplay() {
|
||||
updateZoomDisplay(skipConnectionRender = false) {
|
||||
const zoom = this.#zoomLevel || 1;
|
||||
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
|
||||
|
||||
const tx = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
|
||||
const ty = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
|
||||
|
||||
if (nodeLayer) {
|
||||
nodeLayer.style.transformOrigin = "0 0";
|
||||
nodeLayer.style.transform = `scale(${zoom})`;
|
||||
nodeLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
|
||||
}
|
||||
|
||||
if (connectionLayer) {
|
||||
connectionLayer.style.transformOrigin = "0 0";
|
||||
connectionLayer.style.transform = `scale(${zoom})`;
|
||||
connectionLayer.style.transform = `translate(${tx}px, ${ty}px) scale(${zoom})`;
|
||||
}
|
||||
|
||||
workspaceElement.style.setProperty("--workspace-zoom", `${zoom}`);
|
||||
this.#applyBackgroundOffset();
|
||||
if (!skipConnectionRender) {
|
||||
this.#renderConnections();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a screen-space point to workspace coordinates.
|
||||
@@ -134,6 +177,109 @@ export class WorkspaceNavigationManager {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues a smooth animated zoom step centred on a screen-space point.
|
||||
* Subsequent calls before the animation settles accumulate the target zoom.
|
||||
*
|
||||
* @param {{ x: number, y: number }} screenPoint Focus point in workspace-relative screen coordinates.
|
||||
* @param {number} direction Positive to zoom in, negative to zoom out.
|
||||
* @param {{ clientX?: number, clientY?: number }} [pointer] Pointer coordinates relative to the viewport.
|
||||
*/
|
||||
pushSmoothZoom(screenPoint, direction, pointer = {}) {
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const step = direction * this.#zoomConfig.step;
|
||||
this.#targetZoomLevel = Math.max(
|
||||
this.#zoomConfig.min,
|
||||
Math.min(this.#zoomConfig.max, this.#targetZoomLevel + step)
|
||||
);
|
||||
this.#smoothZoomFocus = { screenPoint, pointer };
|
||||
|
||||
if (this.#smoothZoomRafId === null) {
|
||||
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances one frame of the smooth zoom animation, easing toward the target.
|
||||
*/
|
||||
#tickSmoothZoom() {
|
||||
this.#smoothZoomRafId = null;
|
||||
|
||||
if (!this.#smoothZoomFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = this.#targetZoomLevel;
|
||||
const current = this.#zoomLevel;
|
||||
const diff = target - current;
|
||||
const settled = Math.abs(diff) < 0.001;
|
||||
const newZoom = settled
|
||||
? target
|
||||
: Math.max(this.#zoomConfig.min, Math.min(this.#zoomConfig.max, current + diff * 0.25));
|
||||
|
||||
const { screenPoint, pointer } = this.#smoothZoomFocus;
|
||||
const previousZoom = current;
|
||||
|
||||
// screenPointToWorld still uses the old zoom at this point, which is correct.
|
||||
const worldPoint = this.screenPointToWorld(screenPoint);
|
||||
this.#zoomLevel = newZoom;
|
||||
|
||||
const scale = previousZoom / newZoom;
|
||||
const shift = {
|
||||
x: worldPoint.x * (scale - 1),
|
||||
y: worldPoint.y * (scale - 1),
|
||||
};
|
||||
|
||||
// Accumulate into a pending layer offset — O(1), no per-node work each frame.
|
||||
this.#pendingLayerOffset.x += shift.x;
|
||||
this.#pendingLayerOffset.y += shift.y;
|
||||
|
||||
// Rebase active interactions if needed.
|
||||
if (Math.abs(shift.x) > 0.0001 || Math.abs(shift.y) > 0.0001) {
|
||||
const { dragManager } = this.#workspace;
|
||||
if (dragManager?.rebaseActiveDragPointer) {
|
||||
dragManager.rebaseActiveDragPointer(pointer);
|
||||
}
|
||||
this.#rebasePanGestureAfterZoom(pointer);
|
||||
}
|
||||
|
||||
// Apply zoom + pending pivot as a single CSS transform on the node layer — O(1).
|
||||
// Combined with background offset for pan.
|
||||
const { nodeLayer, connectionLayer, workspaceElement } = this.#workspace;
|
||||
const ox = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * newZoom;
|
||||
const oy = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * newZoom;
|
||||
if (nodeLayer) {
|
||||
nodeLayer.style.transformOrigin = "0 0";
|
||||
nodeLayer.style.transform = `translate(${ox}px, ${oy}px) scale(${newZoom})`;
|
||||
}
|
||||
if (connectionLayer) {
|
||||
connectionLayer.style.transformOrigin = "0 0";
|
||||
connectionLayer.style.transform = `translate(${ox}px, ${oy}px) scale(${newZoom})`;
|
||||
}
|
||||
workspaceElement.style.setProperty("--workspace-zoom", `${newZoom}`);
|
||||
this.#applyBackgroundOffset();
|
||||
this.#renderConnections();
|
||||
|
||||
if (settled) {
|
||||
this.#smoothZoomFocus = null;
|
||||
// Commit accumulated pivot to node positions in one pass, then restore clean transforms.
|
||||
const finalOffset = this.#pendingLayerOffset;
|
||||
this.#pendingLayerOffset = { x: 0, y: 0 };
|
||||
if (Math.abs(finalOffset.x) > 0.0001 || Math.abs(finalOffset.y) > 0.0001) {
|
||||
this.translateWorkspace(finalOffset);
|
||||
}
|
||||
// Restore clean scale-only transform now that node positions are committed.
|
||||
this.updateZoomDisplay(false);
|
||||
this.#markViewDirty("view", 0);
|
||||
this.#schedulePersist();
|
||||
} else {
|
||||
this.#smoothZoomRafId = requestAnimationFrame(() => this.#tickSmoothZoom());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies zoom centred at a screen-space location.
|
||||
*
|
||||
@@ -149,7 +295,7 @@ export class WorkspaceNavigationManager {
|
||||
const previousZoom = this.#zoomLevel || 1;
|
||||
const worldPoint = this.screenPointToWorld(screenPoint);
|
||||
const nextZoom = previousZoom + direction * this.#zoomConfig.step;
|
||||
const changed = this.setZoomLevel(nextZoom);
|
||||
const changed = this.setZoomLevel(nextZoom, { skipConnectionRender: true });
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
@@ -172,8 +318,9 @@ export class WorkspaceNavigationManager {
|
||||
dragManager.rebaseActiveDragPointer(pointer);
|
||||
}
|
||||
this.#rebasePanGestureAfterZoom(pointer);
|
||||
this.#renderConnections();
|
||||
}
|
||||
|
||||
this.#renderConnections();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +344,18 @@ export class WorkspaceNavigationManager {
|
||||
return this.#backgroundOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total effective viewport offset, including any pending zoom pivot.
|
||||
*
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
getEffectiveOffset() {
|
||||
return {
|
||||
x: this.#backgroundOffset.x + this.#pendingLayerOffset.x,
|
||||
y: this.#backgroundOffset.y + this.#pendingLayerOffset.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a workspace translation to all nodes and the background.
|
||||
*
|
||||
@@ -212,22 +371,7 @@ export class WorkspaceNavigationManager {
|
||||
y: this.#backgroundOffset.y + delta.y,
|
||||
});
|
||||
|
||||
const { graph, dragManager } = this.#workspace;
|
||||
const panState = this.#panState;
|
||||
const panDelta = panState ? panState.lastDelta : null;
|
||||
|
||||
graph.nodes.forEach((node) => {
|
||||
const origin = panState?.nodeOrigins.get(node.id) ?? node.position;
|
||||
const baseDelta = panDelta ?? { x: 0, y: 0 };
|
||||
const next = {
|
||||
x: origin.x + baseDelta.x + delta.x,
|
||||
y: origin.y + baseDelta.y + delta.y,
|
||||
};
|
||||
graph.setNodePosition(node.id, next);
|
||||
if (dragManager?.updateNodeDomPosition) {
|
||||
dragManager.updateNodeDomPosition(node.id, next);
|
||||
}
|
||||
});
|
||||
const { dragManager } = this.#workspace;
|
||||
|
||||
if (dragManager?.applyActiveDragShift) {
|
||||
dragManager.applyActiveDragShift(delta);
|
||||
@@ -381,24 +525,16 @@ export class WorkspaceNavigationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const { dragManager, contextMenuManager, graph, nodeElements } =
|
||||
this.#workspace;
|
||||
const { dragManager, contextMenuManager } = this.#workspace;
|
||||
|
||||
dragManager?.removeNodeGhost();
|
||||
dragManager?.removePaletteGhost();
|
||||
|
||||
const nodeOrigins = new Map();
|
||||
graph.nodes.forEach((node) => {
|
||||
nodeOrigins.set(node.id, { x: node.position.x, y: node.position.y });
|
||||
});
|
||||
|
||||
const state = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
lastDelta: { x: 0, y: 0 },
|
||||
hasMoved: false,
|
||||
nodeOrigins,
|
||||
backgroundOrigin: { ...this.#backgroundOffset },
|
||||
};
|
||||
|
||||
@@ -413,7 +549,6 @@ export class WorkspaceNavigationManager {
|
||||
x: deltaXScreen,
|
||||
y: deltaYScreen,
|
||||
});
|
||||
this.#panState.lastDelta = worldDelta;
|
||||
|
||||
if (!this.#panState.hasMoved) {
|
||||
const distance = Math.hypot(deltaXScreen, deltaYScreen);
|
||||
@@ -434,20 +569,7 @@ export class WorkspaceNavigationManager {
|
||||
y: this.#panState.backgroundOrigin.y + worldDelta.y,
|
||||
};
|
||||
this.setBackgroundOffset(backgroundOffset);
|
||||
|
||||
this.#panState.nodeOrigins.forEach((origin, nodeId) => {
|
||||
const element = nodeElements.get(nodeId);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const next = {
|
||||
x: origin.x + worldDelta.x,
|
||||
y: origin.y + worldDelta.y,
|
||||
};
|
||||
element.style.transform = WorkspaceGeometry.positionToTransform(next);
|
||||
});
|
||||
|
||||
this.#renderConnections();
|
||||
this.updateZoomDisplay();
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent) => {
|
||||
@@ -467,17 +589,6 @@ export class WorkspaceNavigationManager {
|
||||
}
|
||||
|
||||
this.#lastPanTimestamp = this.#timestamp();
|
||||
const delta = finalState.lastDelta;
|
||||
this.setBackgroundOffset({
|
||||
x: finalState.backgroundOrigin.x + delta.x,
|
||||
y: finalState.backgroundOrigin.y + delta.y,
|
||||
});
|
||||
finalState.nodeOrigins.forEach((origin, nodeId) => {
|
||||
graph.setNodePosition(nodeId, {
|
||||
x: origin.x + delta.x,
|
||||
y: origin.y + delta.y,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
this.#panState = state;
|
||||
@@ -527,8 +638,8 @@ export class WorkspaceNavigationManager {
|
||||
*/
|
||||
#applyBackgroundOffset() {
|
||||
const zoom = this.#zoomLevel || 1;
|
||||
const scaledX = this.#backgroundOffset.x * zoom;
|
||||
const scaledY = this.#backgroundOffset.y * zoom;
|
||||
const scaledX = (this.#backgroundOffset.x + this.#pendingLayerOffset.x) * zoom;
|
||||
const scaledY = (this.#backgroundOffset.y + this.#pendingLayerOffset.y) * zoom;
|
||||
const position = `${scaledX}px ${scaledY}px`;
|
||||
this.#workspace.workspaceElement.style.backgroundPosition = `${position}, ${position}, ${position}, ${position}`;
|
||||
}
|
||||
@@ -551,13 +662,6 @@ export class WorkspaceNavigationManager {
|
||||
x: this.#panState.backgroundOrigin.x + shift.x,
|
||||
y: this.#panState.backgroundOrigin.y + shift.y,
|
||||
};
|
||||
|
||||
this.#panState.nodeOrigins.forEach((origin, nodeId, map) => {
|
||||
map.set(nodeId, {
|
||||
x: origin.x + shift.x,
|
||||
y: origin.y + shift.y,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
227
scripts/ui/workspace/WorkspaceNodeContextMenu.js
Normal file
227
scripts/ui/workspace/WorkspaceNodeContextMenu.js
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles node-specific context menu interactions (right-click on nodes).
|
||||
*/
|
||||
export class WorkspaceNodeContextMenu {
|
||||
#workspace;
|
||||
/** @type {HTMLDivElement | null} */
|
||||
#container;
|
||||
/** @type {boolean} */
|
||||
#isVisible;
|
||||
/** @type {string | null} */
|
||||
#targetNodeId;
|
||||
|
||||
/**
|
||||
* @param {BlueprintWorkspace} workspace Owning workspace instance.
|
||||
*/
|
||||
constructor(workspace) {
|
||||
this.#workspace = workspace;
|
||||
this.#container = null;
|
||||
this.#isVisible = false;
|
||||
this.#targetNodeId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the node context menu DOM if it does not already exist.
|
||||
*/
|
||||
initialize() {
|
||||
if (this.#container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "node-context-menu";
|
||||
container.setAttribute("role", "menu");
|
||||
container.setAttribute("aria-label", "Node actions");
|
||||
|
||||
const deleteItem = this.#createMenuItem("Delete", () => {
|
||||
this.#handleDelete();
|
||||
});
|
||||
deleteItem.dataset.action = "delete";
|
||||
container.appendChild(deleteItem);
|
||||
|
||||
const copyItem = this.#createMenuItem("Copy", () => {
|
||||
this.#handleCopy();
|
||||
});
|
||||
copyItem.dataset.action = "copy";
|
||||
container.appendChild(copyItem);
|
||||
|
||||
const refreshItem = this.#createMenuItem("Refresh", () => {
|
||||
this.#handleRefresh();
|
||||
});
|
||||
refreshItem.dataset.action = "refresh";
|
||||
container.appendChild(refreshItem);
|
||||
|
||||
this.#workspace.workspaceElement.appendChild(container);
|
||||
|
||||
document.addEventListener("pointerdown", (event) => {
|
||||
if (!this.#isVisible) {
|
||||
return;
|
||||
}
|
||||
if (!(event.target instanceof Node)) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
if (!container.contains(event.target)) {
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!this.#isVisible) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.hide();
|
||||
}
|
||||
});
|
||||
|
||||
this.#container = container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a menu item button with the specified label and click handler.
|
||||
*
|
||||
* @param {string} label Menu item label.
|
||||
* @param {() => void} onClick Click handler.
|
||||
* @returns {HTMLButtonElement}
|
||||
*/
|
||||
#createMenuItem(label, onClick) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "node-context-menu-item";
|
||||
button.setAttribute("role", "menuitem");
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", () => {
|
||||
onClick();
|
||||
this.hide();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the context menu is currently displayed.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isVisible() {
|
||||
return this.#isVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the provided target node resides inside the context menu.
|
||||
*
|
||||
* @param {Node | null} target Target node.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isTargetInside(target) {
|
||||
return Boolean(
|
||||
this.#container && target && this.#container.contains(target)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the node context menu at the given viewport coordinates.
|
||||
*
|
||||
* @param {number} clientX Viewport X coordinate.
|
||||
* @param {number} clientY Viewport Y coordinate.
|
||||
* @param {string} nodeId Target node identifier.
|
||||
*/
|
||||
show(clientX, clientY, nodeId) {
|
||||
this.initialize();
|
||||
|
||||
if (!this.#container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRect =
|
||||
this.#workspace.workspaceElement.getBoundingClientRect();
|
||||
const relativeX = Math.max(0, clientX - workspaceRect.left);
|
||||
const relativeY = Math.max(0, clientY - workspaceRect.top);
|
||||
|
||||
this.#targetNodeId = nodeId;
|
||||
|
||||
this.#container.classList.add("is-visible");
|
||||
this.#container.style.left = `${relativeX}px`;
|
||||
this.#container.style.top = `${relativeY}px`;
|
||||
this.#isVisible = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.#container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuRect = this.#container.getBoundingClientRect();
|
||||
let adjustedLeft = relativeX;
|
||||
let adjustedTop = relativeY;
|
||||
|
||||
if (menuRect.right > workspaceRect.right) {
|
||||
adjustedLeft -= menuRect.right - workspaceRect.right;
|
||||
}
|
||||
if (menuRect.bottom > workspaceRect.bottom) {
|
||||
adjustedTop -= menuRect.bottom - workspaceRect.bottom;
|
||||
}
|
||||
|
||||
adjustedLeft = Math.max(0, adjustedLeft);
|
||||
adjustedTop = Math.max(0, adjustedTop);
|
||||
|
||||
this.#container.style.left = `${adjustedLeft}px`;
|
||||
this.#container.style.top = `${adjustedTop}px`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Conceals the node context menu and clears its state.
|
||||
*/
|
||||
hide() {
|
||||
if (!this.#container || !this.#isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#container.classList.remove("is-visible");
|
||||
this.#isVisible = false;
|
||||
this.#targetNodeId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the delete action for the target node.
|
||||
*/
|
||||
#handleDelete() {
|
||||
if (!this.#targetNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#workspace.removeNode(this.#targetNodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the copy action for the target node.
|
||||
*/
|
||||
#handleCopy() {
|
||||
if (!this.#targetNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasSelected = this.#workspace.selectedNodeIds.has(this.#targetNodeId);
|
||||
if (!wasSelected) {
|
||||
this.#workspace.selectNode(this.#targetNodeId);
|
||||
}
|
||||
|
||||
this.#workspace.clipboardManager.copySelection();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the refresh action for the target node.
|
||||
*/
|
||||
#handleRefresh() {
|
||||
if (!this.#targetNodeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#workspace.refreshNode(this.#targetNodeId);
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,36 @@ export class WorkspaceNodeRenderer {
|
||||
article.classList.remove("is-hovered");
|
||||
});
|
||||
|
||||
article.addEventListener("pointerdown", (event) => {
|
||||
if (event.button === 2) {
|
||||
this.#workspace.navigationManager?.beginPan(event);
|
||||
}
|
||||
});
|
||||
|
||||
article.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.#workspace.navigationManager?.isPanRecent(300)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = /** @type {EventTarget | null} */ (event.target);
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest(".pin")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#workspace.selectedNodeIds.has(node.id)) {
|
||||
this.#callbacks.selectNode(node.id);
|
||||
}
|
||||
|
||||
this.#workspace.nodeContextMenu.show(event.clientX, event.clientY, node.id);
|
||||
});
|
||||
|
||||
this.#workspace.nodeLayer.appendChild(article);
|
||||
this.#workspace.nodeElements.set(node.id, article);
|
||||
this.#inlineEditors.syncNode(node.id);
|
||||
|
||||
257
scripts/ui/workspace/WorkspaceWireCutManager.js
Normal file
257
scripts/ui/workspace/WorkspaceWireCutManager.js
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* @typedef {import('../BlueprintWorkspace.js').BlueprintWorkspace} BlueprintWorkspace
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ x: number, y: number }} Point
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* pointerId: number,
|
||||
* startClient: Point,
|
||||
* currentClient: Point,
|
||||
* cutLine: SVGLineElement,
|
||||
* hasMoved: boolean,
|
||||
* }} CutState
|
||||
*/
|
||||
|
||||
/**
|
||||
* Drives the middle-mouse wire-cut gesture.
|
||||
* Hold middle mouse and drag a line across wires to disconnect them.
|
||||
*/
|
||||
export class WorkspaceWireCutManager {
|
||||
/** @type {BlueprintWorkspace} */
|
||||
#workspace;
|
||||
/** @type {CutState | null} */
|
||||
#state;
|
||||
|
||||
/**
|
||||
* @param {BlueprintWorkspace} workspace Owning workspace instance.
|
||||
*/
|
||||
constructor(workspace) {
|
||||
this.#workspace = workspace;
|
||||
this.#state = null;
|
||||
|
||||
this.handleMove = this.handleMove.bind(this);
|
||||
this.handleUp = this.handleUp.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a cut gesture from a middle-mouse pointerdown event.
|
||||
*
|
||||
* @param {PointerEvent} event Middle-mouse pointerdown event.
|
||||
*/
|
||||
beginCut(event) {
|
||||
if (this.#state) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const cutLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
||||
cutLine.classList.add("wire-cut-line");
|
||||
|
||||
const svgPos = this.#clientToSvg(event.clientX, event.clientY);
|
||||
cutLine.setAttribute("x1", String(svgPos.x));
|
||||
cutLine.setAttribute("y1", String(svgPos.y));
|
||||
cutLine.setAttribute("x2", String(svgPos.x));
|
||||
cutLine.setAttribute("y2", String(svgPos.y));
|
||||
this.#workspace.connectionLayer.appendChild(cutLine);
|
||||
|
||||
this.#state = {
|
||||
pointerId: event.pointerId,
|
||||
startClient: { x: event.clientX, y: event.clientY },
|
||||
currentClient: { x: event.clientX, y: event.clientY },
|
||||
cutLine,
|
||||
hasMoved: false,
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", this.handleMove);
|
||||
window.addEventListener("pointerup", this.handleUp);
|
||||
window.addEventListener("pointercancel", this.handleUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether a cut gesture is currently active.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isCutting() {
|
||||
return this.#state !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts viewport client coordinates to SVG-layer space.
|
||||
*
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
* @returns {Point}
|
||||
*/
|
||||
#clientToSvg(clientX, clientY) {
|
||||
const zoom = this.#workspace.zoomLevel || 1;
|
||||
const rect = this.#workspace.workspaceElement.getBoundingClientRect();
|
||||
return {
|
||||
x: (clientX - rect.left) / zoom,
|
||||
y: (clientY - rect.top) / zoom,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
handleMove(event) {
|
||||
if (!this.#state || event.pointerId !== this.#state.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#state.currentClient = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const dx = event.clientX - this.#state.startClient.x;
|
||||
const dy = event.clientY - this.#state.startClient.y;
|
||||
if (!this.#state.hasMoved && Math.hypot(dx, dy) >= 4) {
|
||||
this.#state.hasMoved = true;
|
||||
}
|
||||
|
||||
const start = this.#clientToSvg(this.#state.startClient.x, this.#state.startClient.y);
|
||||
const end = this.#clientToSvg(event.clientX, event.clientY);
|
||||
|
||||
this.#state.cutLine.setAttribute("x1", String(start.x));
|
||||
this.#state.cutLine.setAttribute("y1", String(start.y));
|
||||
this.#state.cutLine.setAttribute("x2", String(end.x));
|
||||
this.#state.cutLine.setAttribute("y2", String(end.y));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {PointerEvent} event
|
||||
*/
|
||||
handleUp(event) {
|
||||
if (!this.#state || event.pointerId !== this.#state.pointerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("pointermove", this.handleMove);
|
||||
window.removeEventListener("pointerup", this.handleUp);
|
||||
window.removeEventListener("pointercancel", this.handleUp);
|
||||
|
||||
const state = this.#state;
|
||||
this.#state = null;
|
||||
state.cutLine.remove();
|
||||
|
||||
if (!state.hasMoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
const p1 = this.#clientToSvg(state.startClient.x, state.startClient.y);
|
||||
const p2 = this.#clientToSvg(state.currentClient.x, state.currentClient.y);
|
||||
this.#cutIntersecting(p1, p2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all connections whose bezier curves intersect the given line segment.
|
||||
*
|
||||
* @param {Point} p1 Start of cut segment in SVG space.
|
||||
* @param {Point} p2 End of cut segment in SVG space.
|
||||
*/
|
||||
#cutIntersecting(p1, p2) {
|
||||
const { connectionElements, graph, historyManager } = this.#workspace;
|
||||
/** @type {string[]} */
|
||||
const toRemove = [];
|
||||
|
||||
connectionElements.forEach((pathEl, connectionId) => {
|
||||
const d = pathEl.getAttribute("d");
|
||||
if (!d) {
|
||||
return;
|
||||
}
|
||||
if (this.#bezierIntersectsSegment(d, p1, p2)) {
|
||||
toRemove.push(connectionId);
|
||||
}
|
||||
});
|
||||
|
||||
if (!toRemove.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Batch all removals into a single undo step.
|
||||
historyManager?.withSuspended(() => {
|
||||
toRemove.forEach((id) => graph.removeConnection(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the cubic bezier described by a path `d` attribute crosses the segment p1→p2.
|
||||
*
|
||||
* @param {string} d SVG path `d` attribute value.
|
||||
* @param {Point} p1
|
||||
* @param {Point} p2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#bezierIntersectsSegment(d, p1, p2) {
|
||||
const m = d.match(
|
||||
/M\s*([\d.\-e]+)\s+([\d.\-e]+)\s+C\s*([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)\s+([\d.\-e]+)/i
|
||||
);
|
||||
if (!m) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bp0 = { x: parseFloat(m[1]), y: parseFloat(m[2]) };
|
||||
const bc1 = { x: parseFloat(m[3]), y: parseFloat(m[4]) };
|
||||
const bc2 = { x: parseFloat(m[5]), y: parseFloat(m[6]) };
|
||||
const bp3 = { x: parseFloat(m[7]), y: parseFloat(m[8]) };
|
||||
|
||||
const SAMPLES = 32;
|
||||
let prev = bp0;
|
||||
for (let i = 1; i <= SAMPLES; i++) {
|
||||
const t = i / SAMPLES;
|
||||
const curr = this.#cubicBezierPoint(bp0, bc1, bc2, bp3, t);
|
||||
if (this.#segmentsIntersect(prev, curr, p1, p2)) {
|
||||
return true;
|
||||
}
|
||||
prev = curr;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a cubic bezier at parameter t.
|
||||
*
|
||||
* @param {Point} p0
|
||||
* @param {Point} c1
|
||||
* @param {Point} c2
|
||||
* @param {Point} p3
|
||||
* @param {number} t
|
||||
* @returns {Point}
|
||||
*/
|
||||
#cubicBezierPoint(p0, c1, c2, p3, t) {
|
||||
const mt = 1 - t;
|
||||
return {
|
||||
x: mt * mt * mt * p0.x + 3 * mt * mt * t * c1.x + 3 * mt * t * t * c2.x + t * t * t * p3.x,
|
||||
y: mt * mt * mt * p0.y + 3 * mt * mt * t * c1.y + 3 * mt * t * t * c2.y + t * t * t * p3.y,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if line segments a1→a2 and b1→b2 cross each other.
|
||||
*
|
||||
* @param {Point} a1
|
||||
* @param {Point} a2
|
||||
* @param {Point} b1
|
||||
* @param {Point} b2
|
||||
* @returns {boolean}
|
||||
*/
|
||||
#segmentsIntersect(a1, a2, b1, b2) {
|
||||
const dx1 = a2.x - a1.x;
|
||||
const dy1 = a2.y - a1.y;
|
||||
const dx2 = b2.x - b1.x;
|
||||
const dy2 = b2.y - b1.y;
|
||||
const denom = dx1 * dy2 - dy1 * dx2;
|
||||
if (Math.abs(denom) < 1e-10) {
|
||||
return false;
|
||||
}
|
||||
const dx3 = b1.x - a1.x;
|
||||
const dy3 = b1.y - a1.y;
|
||||
const t = (dx3 * dy2 - dy3 * dx2) / denom;
|
||||
const u = (dx3 * dy1 - dy3 * dx1) / denom;
|
||||
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
||||
}
|
||||
}
|
||||
310
styles/main.css
310
styles/main.css
@@ -15,12 +15,14 @@
|
||||
--pin-boolean: #ff4f4f;
|
||||
--pin-string: #ff66ff;
|
||||
--pin-table: #5ec4ff;
|
||||
--pin-color: #5b8ef5;
|
||||
--pin-any: #8c919d;
|
||||
--variable-color-any: #ff7f11;
|
||||
--variable-color-number: #3ee581;
|
||||
--variable-color-string: #ff66ff;
|
||||
--variable-color-boolean: #ff4f4f;
|
||||
--variable-color-table: #5ec4ff;
|
||||
--variable-color-color: #5b8ef5;
|
||||
--shadow-0: 0 8px 18px rgba(0, 0, 0, 0.35);
|
||||
--grid-color: rgba(255, 255, 255, 0.04);
|
||||
--grid-color-strong: rgba(255, 255, 255, 0.1);
|
||||
@@ -244,7 +246,7 @@ input[type='search'] {
|
||||
.app-body {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr) 280px 280px;
|
||||
grid-template-columns: 280px minmax(0, 1fr) 280px 280px;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
@@ -431,7 +433,7 @@ input[type='search'] {
|
||||
|
||||
.variable-name-display {
|
||||
display: block;
|
||||
padding: 0.2rem 0.35rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
@@ -439,6 +441,14 @@ input[type='search'] {
|
||||
color: var(--text-primary);
|
||||
cursor: text;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variable-name-display[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview-item--variable:hover .variable-name-display,
|
||||
@@ -455,6 +465,7 @@ input[type='search'] {
|
||||
|
||||
.variable-overview-row .overview-item-meta {
|
||||
flex: 0 0 auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview-item--variable .overview-item-action,
|
||||
@@ -626,6 +637,10 @@ input[type='search'] {
|
||||
--overview-variable-color: var(--variable-color-table);
|
||||
}
|
||||
|
||||
.overview-item[data-variable-type='color'] {
|
||||
--overview-variable-color: var(--variable-color-color);
|
||||
}
|
||||
|
||||
.overview-item[data-variable-type] .overview-item-label {
|
||||
cursor: text;
|
||||
}
|
||||
@@ -705,6 +720,10 @@ input[type='search'] {
|
||||
background-position: left top, right top, left bottom, right bottom;
|
||||
}
|
||||
|
||||
.variable-type-option[data-variable-type='color']::before {
|
||||
background: var(--variable-color-color);
|
||||
}
|
||||
|
||||
.variable-type-option:hover,
|
||||
.variable-type-option:focus-visible {
|
||||
background: var(--surface-3);
|
||||
@@ -743,7 +762,7 @@ input[type='search'] {
|
||||
.overview-item-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
visibility: collapse;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overview-empty {
|
||||
@@ -755,6 +774,58 @@ input[type='search'] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-context-menu {
|
||||
position: absolute;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
min-width: 140px;
|
||||
padding: 0.4rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-0);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.node-context-menu.is-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.node-context-menu-item {
|
||||
width: 100%;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.node-context-menu-item:hover,
|
||||
.node-context-menu-item:focus-visible {
|
||||
background: var(--surface-3);
|
||||
border-color: var(--surface-4, var(--edge));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.node-context-menu-item:active {
|
||||
background: var(--surface-4, var(--surface-3));
|
||||
}
|
||||
|
||||
.node-context-menu-item[data-action='delete'] {
|
||||
color: var(--error, #f55);
|
||||
}
|
||||
|
||||
.node-context-menu-item[data-action='delete']:hover,
|
||||
.node-context-menu-item[data-action='delete']:focus-visible {
|
||||
background: color-mix(in srgb, var(--error, #f55) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--error, #f55) 30%, transparent);
|
||||
color: var(--error, #f55);
|
||||
}
|
||||
|
||||
.workspace-context-menu {
|
||||
position: absolute;
|
||||
display: none;
|
||||
@@ -1229,6 +1300,10 @@ input[type='search'] {
|
||||
--node-variable-color: var(--variable-color-table);
|
||||
}
|
||||
|
||||
.blueprint-node[data-variable-type='color'] {
|
||||
--node-variable-color: var(--variable-color-color);
|
||||
}
|
||||
|
||||
.blueprint-node--variable .node-header {
|
||||
padding-left: 1.3rem;
|
||||
border-bottom-color: rgba(0, 0, 0, 0.45);
|
||||
@@ -1700,6 +1775,10 @@ input[type='search'] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pin[data-type='color'] .pin-handle {
|
||||
color: var(--variable-color-color);
|
||||
}
|
||||
|
||||
.pin[data-type='table']:not(.is-disconnected) .pin-handle {
|
||||
border-color: var(--edge);
|
||||
border-width: 1px;
|
||||
@@ -2297,6 +2376,10 @@ body.modal-open {
|
||||
stroke: var(--variable-color-table);
|
||||
}
|
||||
|
||||
.connection-path[data-type='color'] {
|
||||
stroke: var(--variable-color-color);
|
||||
}
|
||||
|
||||
.connection-path[data-type='any'] {
|
||||
stroke: var(--variable-color-any);
|
||||
}
|
||||
@@ -2306,6 +2389,16 @@ body.modal-open {
|
||||
animation: dash 600ms linear infinite;
|
||||
}
|
||||
|
||||
.wire-cut-line {
|
||||
fill: none;
|
||||
stroke: #ff4444;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 6 4;
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
to {
|
||||
stroke-dashoffset: 12;
|
||||
@@ -2321,3 +2414,214 @@ body.modal-open {
|
||||
min-width: 190px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Preview Modal */
|
||||
.preview-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2.5rem;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.preview-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-modal__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview-modal__panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, calc(100vw - 4rem));
|
||||
max-height: calc(100vh - 5rem);
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.preview-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--edge);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.preview-header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.preview-header-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--edge);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.preview-header-btn:hover {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.preview-header-btn--stop {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.preview-header-btn--stop:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.preview-header-btn--save {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.preview-header-btn--save:hover {
|
||||
color: var(--accent);
|
||||
background: rgba(79, 140, 255, 0.15);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.preview-modal__close {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--edge);
|
||||
color: var(--text-primary);
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.preview-modal__close:hover,
|
||||
.preview-modal__close:focus-visible {
|
||||
background: var(--surface-3);
|
||||
border-color: var(--edge);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.preview-modal__content {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.preview-canvas,
|
||||
.preview-iframe {
|
||||
width: 512px;
|
||||
height: 512px;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
background: #000;
|
||||
border: 4px solid #000;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
border: 4px solid #000;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.preview-instructions {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.preview-instructions p {
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.preview-instructions p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preview-instructions ol {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.preview-instructions li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.preview-instructions a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.preview-instructions a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.preview-download-button,
|
||||
.preview-test-button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--edge);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.preview-download-button:hover,
|
||||
.preview-test-button:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.preview-download-button:focus-visible,
|
||||
.preview-test-button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user