From 423b02a19543f3cd89797d4d176cb2032eab5e18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 5 Jul 2025 02:34:28 +0200 Subject: [PATCH] first commit --- .gitignore | 55 ++++ README.md | 59 +++++ index.html | 475 +++++++++++++++++++++++++++++++++++ package-lock.json | 595 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 46 ++++ src/FakeShader.ts | 237 ++++++++++++++++++ src/ShaderWorker.ts | 182 ++++++++++++++ src/Storage.ts | 156 ++++++++++++ src/main.ts | 405 ++++++++++++++++++++++++++++++ tsconfig.json | 19 ++ vite.config.ts | 11 + 11 files changed, 2240 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/FakeShader.ts create mode 100644 src/ShaderWorker.ts create mode 100644 src/Storage.ts create mode 100644 src/main.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9176590 --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Build outputs +dist/ +build/ +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Editor directories and files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Temporary folders +tmp/ +temp/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..412c062 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Bitfielder + +A minimalist live shader editor for bitfield patterns. + +## Features + +- **Live coding** - Real-time shader compilation and rendering +- **Performance controls** - Adjustable FPS (15/30/60) and resolution scaling +- **Shader library** - Save, search, and organize shaders +- **Live performance mode** - Minimal UI for fullscreen presentations +- **URL sharing** - Share shaders via encoded URLs + +## Installation + +```bash +npm install +npm run dev +``` + +## Usage + +### Basic Controls + +- **Ctrl+Enter** - Execute shader code +- **H** - Toggle minimal UI mode +- **F11** - Fullscreen +- **R** - Random shader +- **S** - Share URL + +### Shader Variables + +- `x, y` - Pixel coordinates +- `t` - Time (enables animation) +- `i` - Pixel index + +### Operators + +- `^ & |` - XOR, AND, OR +- `<< >>` - Bit shift +- `+ - * / %` - Math operations + +### Library + +Hover the right edge of the screen to access the shader library. Save shaders with custom names and search through your collection. + +## Examples + +```javascript +x^y // Basic XOR pattern +(x*y)%256 // Multiplication pattern +(x+y+t*10)%256 // Animated pattern +((x>>4)^(y>>4))<<4 // Bit-shifted XOR +``` + +## Build + +```bash +npm run build +``` \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..1d698f0 --- /dev/null +++ b/index.html @@ -0,0 +1,475 @@ + + + + + + Bitfielder + + + + + + + +
+
Bitfielder
+
+ + + + + + + +
+
+ +
+ +
+ +
+
+
+

Shader Library

+
+ + +
+
+ +
+
+
+ +
+
+ +
+ +

Bitfielder Help

+ +
+

Keyboard Shortcuts

+

Ctrl+Enter - Execute shader code

+

F11 - Toggle fullscreen

+

H - Hide/show UI

+

R - Generate random shader

+

S - Share current shader (copy URL)

+

? - Show this help

+
+ +
+

Shader Library

+

Hover over the right edge of the screen to access the shader library

+

Save shaders with custom names and search through them

+

Use edit to rename, del to delete

+
+ +
+

Variables

+

x, y - Pixel coordinates

+

t - Time (enables animation)

+

i - Pixel index

+
+ +
+

Operators

+

^ & | - XOR, AND, OR

+

<< >> - Bit shift left/right

+

+ - * / % - Math operations

+
+
+ +
+ Performance warning: Shader taking too long to render! +
+ + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f0b0558 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,595 @@ +{ + "name": "bitfielder", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bitfielder", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.0.0", + "vite": "^4.0.0" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "4.5.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.14.tgz", + "integrity": "sha512-+v57oAaoYNnO3hIu5Z/tJRZjq5aHM2zDve9YZ8HngVHbhk66RStobhb1sqPMIPEleV6cNKYK4eGrAbE9Ulbl2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c78e8fb --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "bitfielder", + "version": "1.0.0", + "description": "A minimalist live shader editor for bitfield patterns", + "type": "module", + "main": "./dist/index.html", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "lint": "echo 'No linter configured'" + }, + "keywords": [ + "shader", + "bitfield", + "live-coding", + "visual", + "performance", + "canvas", + "procedural", + "graphics" + ], + "author": "bitfielder", + "license": "AGPL3", + "repository": { + "type": "git", + "url": "git+https://git.raphaelforment.fr/BuboBubo/bitfielder" + }, + "bugs": { + "url": "https://git.raphaelforment.fr/BuboBubo/bitfielder/issues" + }, + "homepage": "https://git.raphaelforment.fr/BuboBubo/bitfielder", + "engines": { + "node": ">=16.0.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "vite": "^4.0.0" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ] +} diff --git a/src/FakeShader.ts b/src/FakeShader.ts new file mode 100644 index 0000000..89cabc6 --- /dev/null +++ b/src/FakeShader.ts @@ -0,0 +1,237 @@ +interface WorkerMessage { + id: string; + type: 'compile' | 'render'; + code?: string; + width?: number; + height?: number; + time?: number; +} + +interface WorkerResponse { + id: string; + type: 'compiled' | 'rendered' | 'error'; + success: boolean; + imageData?: ImageData; + error?: string; +} + +export class FakeShader { + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private code: string; + private worker: Worker; + private animationId: number | null = null; + private startTime: number = Date.now(); + private isCompiled: boolean = false; + private isRendering: boolean = false; + private pendingRenders: string[] = []; + + // Frame rate limiting + private targetFPS: number = 30; + private frameInterval: number = 1000 / this.targetFPS; + private lastFrameTime: number = 0; + + constructor(canvas: HTMLCanvasElement, code: string = 'x^y') { + this.canvas = canvas; + this.ctx = canvas.getContext('2d')!; + this.code = code; + + // Initialize worker + this.worker = new Worker(new URL('./ShaderWorker.ts', import.meta.url), { type: 'module' }); + this.worker.onmessage = (e: MessageEvent) => this.handleWorkerMessage(e.data); + this.worker.onerror = (error) => console.error('Worker error:', error); + + this.compile(); + } + + private handleWorkerMessage(response: WorkerResponse): void { + switch (response.type) { + case 'compiled': + this.isCompiled = response.success; + if (!response.success) { + console.error('Compilation failed:', response.error); + this.fillBlack(); + } + break; + + case 'rendered': + this.isRendering = false; + if (response.success && response.imageData) { + this.ctx.putImageData(response.imageData, 0, 0); + } else { + console.error('Render failed:', response.error); + this.fillBlack(); + } + + // Process pending renders + if (this.pendingRenders.length > 0) { + this.pendingRenders.shift(); // Remove completed render + if (this.pendingRenders.length > 0) { + // Skip to latest render request + const latestId = this.pendingRenders[this.pendingRenders.length - 1]; + this.pendingRenders = [latestId]; + this.executeRender(latestId); + } + } + break; + + case 'error': + this.isRendering = false; + console.error('Worker error:', response.error); + this.fillBlack(); + break; + } + } + + private fillBlack(): void { + this.ctx.fillStyle = '#000'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + } + + private compile(): void { + this.isCompiled = false; + const id = `compile_${Date.now()}`; + this.worker.postMessage({ + id, + type: 'compile', + code: this.code + } as WorkerMessage); + } + + private executeRender(id: string): void { + if (!this.isCompiled || this.isRendering) { + return; + } + + this.isRendering = true; + const currentTime = (Date.now() - this.startTime) / 1000; + + this.worker.postMessage({ + id, + type: 'render', + width: this.canvas.width, + height: this.canvas.height, + time: currentTime + } as WorkerMessage); + } + + setCode(code: string): void { + this.code = code; + this.compile(); + } + + render(animate: boolean = false): void { + const currentTime = performance.now(); + + // Frame rate limiting + if (animate && currentTime - this.lastFrameTime < this.frameInterval) { + if (animate) { + this.animationId = requestAnimationFrame(() => this.render(true)); + } + return; + } + + this.lastFrameTime = currentTime; + + if (!this.isCompiled) { + this.fillBlack(); + if (animate) { + this.animationId = requestAnimationFrame(() => this.render(true)); + } + return; + } + + const renderId = `render_${Date.now()}_${Math.random()}`; + + // Add to pending renders queue + this.pendingRenders.push(renderId); + + // If not currently rendering, start immediately + if (!this.isRendering) { + this.executeRender(renderId); + } + + // Continue animation + if (animate) { + this.animationId = requestAnimationFrame(() => this.render(true)); + } + } + + startAnimation(): void { + this.stopAnimation(); + this.startTime = Date.now(); + this.lastFrameTime = 0; // Reset frame timing + this.render(true); + } + + stopAnimation(): void { + if (this.animationId !== null) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + + // Clear pending renders + this.pendingRenders = []; + } + + setTargetFPS(fps: number): void { + this.targetFPS = Math.max(1, Math.min(120, fps)); // Clamp between 1-120 FPS + this.frameInterval = 1000 / this.targetFPS; + } + + destroy(): void { + this.stopAnimation(); + this.worker.terminate(); + } + + static generateRandomCode(): string { + const presets = [ + 'x^y', + 'x&y', + 'x|y', + '(x*y)%256', + '(x+y+t*10)%256', + '((x>>4)^(y>>4))<<4', + '(x^y^(x*y))%256', + '((x&y)|(x^y))%256', + '(x+y)&255', + 'x%y', + '(x^(y<<2))%256', + '((x*t)^y)%256', + '(x&(y|t*8))%256', + '((x>>2)|(y<<2))%256', + '(x*y*t)%256', + '(x+y*t)%256', + '(x^y^(t*16))%256', + '((x*t)&(y*t))%256', + '(x+(y<<(t%4)))%256', + '((x*t%128)^y)%256', + '(x^(y*t*2))%256', + '((x+t)*(y+t))%256', + '(x&y&(t*8))%256', + '((x|t)^(y|t))%256' + ]; + + const vars = ['x', 'y', 't', 'i']; + const ops = ['^', '&', '|', '+', '-', '*', '%']; + const shifts = ['<<', '>>']; + const numbers = ['2', '4', '8', '16', '32', '64', '128', '256']; + + const randomChoice = (arr: T[]): T => arr[Math.floor(Math.random() * arr.length)]; + + const dynamicExpressions = [ + () => `${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)}`, + () => `(${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)})%${randomChoice(numbers)}`, + () => `${randomChoice(vars)}${randomChoice(shifts)}${Math.floor(Math.random() * 8)}`, + () => `(${randomChoice(vars)}*${randomChoice(vars)})%${randomChoice(numbers)}`, + () => `${randomChoice(vars)}^${randomChoice(vars)}^${randomChoice(vars)}`, + ]; + + // 70% chance to pick from presets, 30% chance to generate dynamic + if (Math.random() < 0.7) { + return randomChoice(presets); + } else { + return randomChoice(dynamicExpressions)(); + } + } +} \ No newline at end of file diff --git a/src/ShaderWorker.ts b/src/ShaderWorker.ts new file mode 100644 index 0000000..17fc44f --- /dev/null +++ b/src/ShaderWorker.ts @@ -0,0 +1,182 @@ +// WebWorker for safe shader compilation and execution +interface WorkerMessage { + id: string; + type: 'compile' | 'render'; + code?: string; + width?: number; + height?: number; + time?: number; +} + +interface WorkerResponse { + id: string; + type: 'compiled' | 'rendered' | 'error'; + success: boolean; + imageData?: ImageData; + error?: string; +} + +class ShaderWorker { + private compiledFunction: Function | null = null; + private lastCode: string = ''; + + constructor() { + self.onmessage = (e: MessageEvent) => { + this.handleMessage(e.data); + }; + } + + private handleMessage(message: WorkerMessage): void { + try { + switch (message.type) { + case 'compile': + this.compileShader(message.id, message.code!); + break; + case 'render': + this.renderShader(message.id, message.width!, message.height!, message.time!); + break; + } + } catch (error) { + this.postError(message.id, error instanceof Error ? error.message : 'Unknown error'); + } + } + + private compileShader(id: string, code: string): void { + if (code === this.lastCode && this.compiledFunction) { + this.postMessage({ id, type: 'compiled', success: true }); + return; + } + + try { + const safeCode = this.sanitizeCode(code); + this.compiledFunction = new Function('x', 'y', 't', 'i', ` + // Timeout protection + const startTime = performance.now(); + let iterations = 0; + + function checkTimeout() { + iterations++; + if (iterations % 1000 === 0 && performance.now() - startTime > 5) { + throw new Error('Shader timeout'); + } + } + + return (function() { + checkTimeout(); + return ${safeCode}; + })(); + `); + + this.lastCode = code; + this.postMessage({ id, type: 'compiled', success: true }); + } catch (error) { + this.compiledFunction = null; + this.postError(id, error instanceof Error ? error.message : 'Compilation failed'); + } + } + + private renderShader(id: string, width: number, height: number, time: number): void { + if (!this.compiledFunction) { + this.postError(id, 'No compiled shader'); + return; + } + + const imageData = new ImageData(width, height); + const data = imageData.data; + const startTime = performance.now(); + const maxRenderTime = 50; // 50ms max render time + + try { + for (let y = 0; y < height; y++) { + // Check timeout every row + if (performance.now() - startTime > maxRenderTime) { + // Fill remaining pixels with black and break + for (let remainingY = y; remainingY < height; remainingY++) { + for (let remainingX = 0; remainingX < width; remainingX++) { + const i = (remainingY * width + remainingX) * 4; + data[i] = 0; // R + data[i + 1] = 0; // G + data[i + 2] = 0; // B + data[i + 3] = 255; // A + } + } + break; + } + + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const pixelIndex = y * width + x; + + try { + const value = this.compiledFunction(x, y, time, pixelIndex); + const safeValue = isFinite(value) ? value : 0; + const color = Math.abs(safeValue) % 256; + + data[i] = color; // R + data[i + 1] = (color * 2) % 256; // G + data[i + 2] = (color * 3) % 256; // B + data[i + 3] = 255; // A + } catch (error) { + data[i] = 0; // R + data[i + 1] = 0; // G + data[i + 2] = 0; // B + data[i + 3] = 255; // A + } + } + } + + this.postMessage({ id, type: 'rendered', success: true, imageData }); + } catch (error) { + this.postError(id, error instanceof Error ? error.message : 'Render failed'); + } + } + + private sanitizeCode(code: string): string { + // Strict whitelist approach + const allowedPattern = /^[0-9a-zA-Z\s\+\-\*\/\%\^\&\|\(\)\<\>\~\?:,\.xyti]+$/; + + if (!allowedPattern.test(code)) { + throw new Error('Invalid characters in shader code'); + } + + // Check for dangerous keywords + const dangerousKeywords = [ + 'eval', 'Function', 'constructor', 'prototype', '__proto__', + 'window', 'document', 'global', 'process', 'require', + 'import', 'export', 'class', 'function', 'var', 'let', 'const', + 'while', 'for', 'do', 'if', 'else', 'switch', 'case', 'break', + 'continue', 'return', 'throw', 'try', 'catch', 'finally' + ]; + + const codeWords = code.toLowerCase().split(/[^a-z]/); + for (const keyword of dangerousKeywords) { + if (codeWords.includes(keyword)) { + throw new Error(`Forbidden keyword: ${keyword}`); + } + } + + // Limit expression complexity + const complexity = (code.match(/[\(\)]/g) || []).length; + if (complexity > 20) { + throw new Error('Expression too complex'); + } + + // Limit code length + if (code.length > 200) { + throw new Error('Code too long'); + } + + return code; + } + + private postMessage(response: WorkerResponse): void { + self.postMessage(response); + } + + private postError(id: string, error: string): void { + this.postMessage({ id, type: 'error', success: false, error }); + } +} + +// Initialize worker +new ShaderWorker(); \ No newline at end of file diff --git a/src/Storage.ts b/src/Storage.ts new file mode 100644 index 0000000..de81bfd --- /dev/null +++ b/src/Storage.ts @@ -0,0 +1,156 @@ +interface SavedShader { + id: string; + name: string; + code: string; + created: number; + lastUsed: number; +} + +interface AppSettings { + resolution: number; + fps: number; + lastShaderCode: string; +} + +export class Storage { + private static readonly SHADERS_KEY = 'bitfielder_shaders'; + private static readonly SETTINGS_KEY = 'bitfielder_settings'; + + static saveShader(name: string, code: string): SavedShader { + const shaders = this.getShaders(); + const id = this.generateId(); + const timestamp = Date.now(); + + const shader: SavedShader = { + id, + name: name.trim() || `Shader ${shaders.length + 1}`, + code, + created: timestamp, + lastUsed: timestamp + }; + + shaders.push(shader); + this.setShaders(shaders); + return shader; + } + + static getShaders(): SavedShader[] { + try { + const stored = localStorage.getItem(this.SHADERS_KEY); + return stored ? JSON.parse(stored) : []; + } catch (error) { + console.error('Failed to load shaders:', error); + return []; + } + } + + static deleteShader(id: string): void { + const shaders = this.getShaders().filter(s => s.id !== id); + this.setShaders(shaders); + } + + static updateShaderUsage(id: string): void { + const shaders = this.getShaders(); + const shader = shaders.find(s => s.id === id); + if (shader) { + shader.lastUsed = Date.now(); + this.setShaders(shaders); + } + } + + static renameShader(id: string, newName: string): void { + const shaders = this.getShaders(); + const shader = shaders.find(s => s.id === id); + if (shader) { + shader.name = newName.trim() || shader.name; + this.setShaders(shaders); + } + } + + private static setShaders(shaders: SavedShader[]): void { + try { + // Keep only the 50 most recent shaders + const sortedShaders = shaders + .sort((a, b) => b.lastUsed - a.lastUsed) + .slice(0, 50); + localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders)); + } catch (error) { + console.error('Failed to save shaders:', error); + } + } + + static saveSettings(settings: Partial): void { + try { + const current = this.getSettings(); + const updated = { ...current, ...settings }; + localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(updated)); + } catch (error) { + console.error('Failed to save settings:', error); + } + } + + static getSettings(): AppSettings { + try { + const stored = localStorage.getItem(this.SETTINGS_KEY); + const defaults: AppSettings = { + resolution: 1, + fps: 30, + lastShaderCode: 'x^y' + }; + return stored ? { ...defaults, ...JSON.parse(stored) } : defaults; + } catch (error) { + console.error('Failed to load settings:', error); + return { + resolution: 1, + fps: 30, + lastShaderCode: 'x^y' + }; + } + } + + static clearAll(): void { + try { + localStorage.removeItem(this.SHADERS_KEY); + localStorage.removeItem(this.SETTINGS_KEY); + } catch (error) { + console.error('Failed to clear storage:', error); + } + } + + private static generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substr(2); + } + + static exportShaders(): string { + const shaders = this.getShaders(); + return JSON.stringify(shaders, null, 2); + } + + static importShaders(jsonData: string): boolean { + try { + const imported = JSON.parse(jsonData) as SavedShader[]; + if (!Array.isArray(imported)) { + return false; + } + + // Validate structure + const valid = imported.every(shader => + shader.id && shader.name && shader.code && + typeof shader.created === 'number' && + typeof shader.lastUsed === 'number' + ); + + if (!valid) { + return false; + } + + const existing = this.getShaders(); + const merged = [...existing, ...imported]; + this.setShaders(merged); + return true; + } catch (error) { + console.error('Failed to import shaders:', error); + return false; + } + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..17deeea --- /dev/null +++ b/src/main.ts @@ -0,0 +1,405 @@ +import { FakeShader } from './FakeShader'; +import { Storage } from './Storage'; + +class BitfielderApp { + private shader: FakeShader; + private canvas: HTMLCanvasElement; + private editor: HTMLTextAreaElement; + private isAnimating: boolean = false; + private uiVisible: boolean = true; + private performanceWarning: HTMLElement; + private libraryOpen: boolean = false; + + constructor() { + this.canvas = document.getElementById('canvas') as HTMLCanvasElement; + this.editor = document.getElementById('editor') as HTMLTextAreaElement; + this.performanceWarning = document.getElementById('performance-warning') as HTMLElement; + + this.loadSettings(); + this.setupCanvas(); + this.shader = new FakeShader(this.canvas, this.editor.value); + + this.setupEventListeners(); + this.loadFromURL(); + this.renderShaderLibrary(); + this.render(); + + window.addEventListener('resize', () => this.setupCanvas()); + window.addEventListener('beforeunload', () => this.saveCurrentShader()); + } + + private setupCanvas(): void { + // Calculate the actual available space + const width = window.innerWidth; + const height = this.uiVisible ? + window.innerHeight - 180 : // subtract topbar (40px) and editor panel (140px) + window.innerHeight; // full height when UI is hidden + + // Get resolution scale from dropdown + const resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement; + const scale = parseInt(resolutionSelect.value) || 1; + + // Set canvas internal size with resolution scaling + this.canvas.width = Math.floor(width / scale); + this.canvas.height = Math.floor(height / scale); + + console.log(`Canvas setup: ${this.canvas.width}x${this.canvas.height} (scale: ${scale}x), UI visible: ${this.uiVisible}`); + } + + private setupEventListeners(): void { + const helpBtn = document.getElementById('help-btn')!; + const fullscreenBtn = document.getElementById('fullscreen-btn')!; + const hideUiBtn = document.getElementById('hide-ui-btn')!; + const showUiBtn = document.getElementById('show-ui-btn')!; + const randomBtn = document.getElementById('random-btn')!; + const shareBtn = document.getElementById('share-btn')!; + const resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement; + const fpsSelect = document.getElementById('fps-select') as HTMLSelectElement; + const helpPopup = document.getElementById('help-popup')!; + const closeBtn = helpPopup.querySelector('.close-btn')!; + + // Library elements + const saveShaderBtn = document.getElementById('save-shader-btn')!; + const shaderNameInput = document.getElementById('shader-name-input') as HTMLInputElement; + const shaderSearchInput = document.getElementById('shader-search-input') as HTMLInputElement; + + helpBtn.addEventListener('click', () => this.showHelp()); + fullscreenBtn.addEventListener('click', () => this.toggleFullscreen()); + hideUiBtn.addEventListener('click', () => this.toggleUI()); + showUiBtn.addEventListener('click', () => this.showUI()); + randomBtn.addEventListener('click', () => this.generateRandom()); + shareBtn.addEventListener('click', () => this.shareURL()); + resolutionSelect.addEventListener('change', () => this.updateResolution()); + fpsSelect.addEventListener('change', () => this.updateFPS()); + closeBtn.addEventListener('click', () => this.hideHelp()); + + // Library events + saveShaderBtn.addEventListener('click', () => this.saveCurrentShader()); + shaderNameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + this.saveCurrentShader(); + } + }); + shaderSearchInput.addEventListener('input', () => this.renderShaderLibrary()); + + + // Close help popup when clicking outside + helpPopup.addEventListener('click', (e) => { + if (e.target === helpPopup) { + this.hideHelp(); + } + }); + + this.editor.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + this.shader.setCode(this.editor.value); + this.render(); + } + }); + + + document.addEventListener('keydown', (e) => { + if (e.key === 'F11') { + e.preventDefault(); + this.toggleFullscreen(); + } else if (e.key === 'h' || e.key === 'H') { + if (!this.editor.matches(':focus')) { + this.toggleUI(); + } + } else if (e.key === 'r' || e.key === 'R') { + if (!this.editor.matches(':focus')) { + this.generateRandom(); + } + } else if (e.key === 's' || e.key === 'S') { + if (!this.editor.matches(':focus')) { + this.shareURL(); + } + } else if (e.key === '?') { + if (!this.editor.matches(':focus')) { + this.showHelp(); + } + } else if (e.key === 'Escape') { + this.hideHelp(); + this.showUI(); + } + }); + + window.addEventListener('hashchange', () => this.loadFromURL()); + + // Listen for performance warnings + window.addEventListener('message', (e) => { + if (e.data === 'performance-warning') { + this.showPerformanceWarning(); + } + }); + } + + private render(): void { + const hasTime = this.editor.value.includes('t'); + + if (hasTime && !this.isAnimating) { + this.isAnimating = true; + this.shader.startAnimation(); + } else if (!hasTime && this.isAnimating) { + this.isAnimating = false; + this.shader.stopAnimation(); + this.shader.render(false); + } else if (!hasTime) { + this.shader.render(false); + } + } + + private toggleFullscreen(): void { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen(); + } else { + document.exitFullscreen(); + } + } + + private toggleUI(): void { + this.uiVisible = !this.uiVisible; + const topbar = document.getElementById('topbar')!; + const editorPanel = document.getElementById('editor-panel')!; + const editor = document.getElementById('editor')!; + const showUiBtn = document.getElementById('show-ui-btn')!; + + if (this.uiVisible) { + // Show full UI + topbar.classList.remove('hidden'); + editorPanel.classList.remove('minimal'); + editor.classList.remove('minimal'); + showUiBtn.style.display = 'none'; + } else { + // Hide topbar, make editor minimal + topbar.classList.add('hidden'); + editorPanel.classList.add('minimal'); + editor.classList.add('minimal'); + showUiBtn.style.display = 'block'; + } + + // Recalculate canvas size when UI is hidden/shown + this.setupCanvas(); + } + + private showUI(): void { + this.uiVisible = true; + const topbar = document.getElementById('topbar')!; + const editorPanel = document.getElementById('editor-panel')!; + const editor = document.getElementById('editor')!; + const showUiBtn = document.getElementById('show-ui-btn')!; + + topbar.classList.remove('hidden'); + editorPanel.classList.remove('minimal'); + editor.classList.remove('minimal'); + showUiBtn.style.display = 'none'; + + // Recalculate canvas size when UI is shown + this.setupCanvas(); + } + + private showHelp(): void { + const helpPopup = document.getElementById('help-popup')!; + helpPopup.style.display = 'block'; + } + + private hideHelp(): void { + const helpPopup = document.getElementById('help-popup')!; + helpPopup.style.display = 'none'; + } + + private showPerformanceWarning(): void { + this.performanceWarning.style.display = 'block'; + setTimeout(() => { + this.performanceWarning.style.display = 'none'; + }, 3000); + } + + private shareURL(): void { + const encoded = btoa(this.editor.value); + window.location.hash = encoded; + + navigator.clipboard.writeText(window.location.href).then(() => { + console.log('URL copied to clipboard'); + }).catch(() => { + console.log('Copy failed'); + }); + } + + private loadFromURL(): void { + if (window.location.hash) { + try { + const decoded = atob(window.location.hash.substring(1)); + this.editor.value = decoded; + this.shader.setCode(decoded); + this.render(); + } catch (e) { + console.error('Failed to decode URL hash:', e); + } + } + } + + private updateResolution(): void { + this.setupCanvas(); + Storage.saveSettings({ resolution: parseInt((document.getElementById('resolution-select') as HTMLSelectElement).value) }); + } + + private updateFPS(): void { + const fpsSelect = document.getElementById('fps-select') as HTMLSelectElement; + const fps = parseInt(fpsSelect.value); + this.shader.setTargetFPS(fps); + Storage.saveSettings({ fps }); + } + + private loadSettings(): void { + const settings = Storage.getSettings(); + + // Apply settings to UI + (document.getElementById('resolution-select') as HTMLSelectElement).value = settings.resolution.toString(); + (document.getElementById('fps-select') as HTMLSelectElement).value = settings.fps.toString(); + + // Load last shader code if no URL hash + if (!window.location.hash) { + this.editor.value = settings.lastShaderCode; + } + } + + private saveCurrentShader(): void { + const nameInput = document.getElementById('shader-name-input') as HTMLInputElement; + const name = nameInput.value.trim(); + const code = this.editor.value.trim(); + + if (!code) return; + + Storage.saveShader(name, code); + nameInput.value = ''; + this.renderShaderLibrary(); + + // Save as last used shader + Storage.saveSettings({ lastShaderCode: code }); + } + + private renderShaderLibrary(): void { + const shaderList = document.getElementById('shader-list')!; + const searchInput = document.getElementById('shader-search-input') as HTMLInputElement; + const searchTerm = searchInput.value.toLowerCase().trim(); + + let shaders = Storage.getShaders(); + + // Filter by search term + if (searchTerm) { + shaders = shaders.filter(shader => + shader.name.toLowerCase().includes(searchTerm) || + shader.code.toLowerCase().includes(searchTerm) + ); + } + + if (shaders.length === 0) { + const message = searchTerm ? 'No shaders match your search' : 'No saved shaders'; + shaderList.innerHTML = `
${message}
`; + return; + } + + shaderList.innerHTML = shaders.map(shader => ` +
+
+ ${this.escapeHtml(shader.name)} +
+ + +
+
+
${this.escapeHtml(shader.code)}
+
+ `).join(''); + } + + private escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + + + // Public methods for global access + loadShader(id: string): void { + const shaders = Storage.getShaders(); + const shader = shaders.find(s => s.id === id); + if (shader) { + this.editor.value = shader.code; + this.shader.setCode(shader.code); + this.render(); + Storage.updateShaderUsage(id); + this.renderShaderLibrary(); // Refresh to update order + } + } + + deleteShader(id: string): void { + Storage.deleteShader(id); + this.renderShaderLibrary(); + } + + startRename(id: string): void { + const nameElement = document.getElementById(`name-${id}`); + if (!nameElement) return; + + const shaders = Storage.getShaders(); + const shader = shaders.find(s => s.id === id); + if (!shader) return; + + // Replace span with input + const input = document.createElement('input'); + input.type = 'text'; + input.value = shader.name; + input.style.cssText = ` + background: rgba(255, 255, 255, 0.2); + border: 1px solid #666; + color: #fff; + padding: 2px 4px; + border-radius: 2px; + font-family: monospace; + font-size: 12px; + width: 100%; + `; + + nameElement.replaceWith(input); + input.focus(); + input.select(); + + const finishRename = () => { + const newName = input.value.trim(); + if (newName && newName !== shader.name) { + Storage.renameShader(id, newName); + } + this.renderShaderLibrary(); + }; + + input.addEventListener('blur', finishRename); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + finishRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + this.renderShaderLibrary(); + } + }); + } + + renameShader(id: string): void { + // This method is kept for backward compatibility but now uses startRename + this.startRename(id); + } + + private generateRandom(): void { + const randomCode = FakeShader.generateRandomCode(); + this.editor.value = randomCode; + this.shader.setCode(randomCode); + this.render(); + } +} + +const app = new BitfielderApp(); +(window as any).app = app; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b385aa0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..55fe4c7 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + base: './', + server: { + port: 3000 + }, + worker: { + format: 'es' + } +}) \ No newline at end of file