switching
This commit is contained in:
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.js
|
||||||
|
public
|
||||||
38
.eslintrc.json
Normal file
38
.eslintrc.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2020": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true
|
||||||
|
},
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module",
|
||||||
|
"project": "./tsconfig.json"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"react",
|
||||||
|
"@typescript-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"prefer-const": "error",
|
||||||
|
"no-var": "error"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
@ -25,6 +25,7 @@ npm run dev
|
|||||||
- **H** - Toggle minimal UI mode
|
- **H** - Toggle minimal UI mode
|
||||||
- **F11** - Fullscreen
|
- **F11** - Fullscreen
|
||||||
- **R** - Random shader
|
- **R** - Random shader
|
||||||
|
- **M** - Cycle value modes
|
||||||
- **S** - Share URL
|
- **S** - Share URL
|
||||||
|
|
||||||
### Shader Variables
|
### Shader Variables
|
||||||
|
|||||||
@ -4,3 +4,4 @@ x**10*y^200*t+20
|
|||||||
x**10*y^200*t+20
|
x**10*y^200*t+20
|
||||||
x ^ Math.sin(y ^ x) * Math.sin(t) * Math.PI * 200
|
x ^ Math.sin(y ^ x) * Math.sin(t) * Math.PI * 200
|
||||||
x ^ Math.sin(y ^ x) * Math.sin(t) * Math.PI ** 4 | x % 40
|
x ^ Math.sin(y ^ x) * Math.sin(t) * Math.PI ** 4 | x % 40
|
||||||
|
(x | y + 20 + y*8 * bassLevel * t * audioLevel) + ([x>y, x<y, x&y, x|y][floor(t) * 2 % 3] ? max(y *t, sin(x * t)) : max(x ** y, cos(y * t)))
|
||||||
|
|||||||
1026
index.html
1026
index.html
File diff suppressed because it is too large
Load Diff
4769
package-lock.json
generated
4769
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@ -9,7 +9,8 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "echo 'No linter configured'"
|
"lint": "eslint src --ext .ts,.tsx --fix && prettier --write src",
|
||||||
|
"lint:check": "eslint src --ext .ts,.tsx && prettier --check src"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"shader",
|
"shader",
|
||||||
@ -35,7 +36,15 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
|
"eslint": "^8.0.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
|
"typescript-eslint": "^8.35.1",
|
||||||
"vite": "^4.0.0"
|
"vite": "^4.0.0"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@ -44,6 +53,13 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide": "^0.525.0"
|
"@nanostores/react": "^1.0.0",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"lucide": "^0.525.0",
|
||||||
|
"nanostores": "^1.0.1",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
208
public/sw.js
208
public/sw.js
@ -1,50 +1,196 @@
|
|||||||
const CACHE_NAME = 'bitfielder-v1';
|
const CACHE_NAME = 'bitfielder-v2';
|
||||||
const urlsToCache = [
|
const STATIC_CACHE = 'bitfielder-static-v2';
|
||||||
|
const DYNAMIC_CACHE = 'bitfielder-dynamic-v2';
|
||||||
|
|
||||||
|
// Core app files that should always be cached
|
||||||
|
const CORE_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
'/src/main.ts',
|
'/manifest.json',
|
||||||
'/src/FakeShader.ts',
|
'/icon-192.png',
|
||||||
'/src/ShaderWorker.ts',
|
'/icon-512.png'
|
||||||
'/src/Storage.ts',
|
|
||||||
'/src/icons.ts',
|
|
||||||
'/manifest.json'
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Install event - cache resources
|
// Assets that can be cached dynamically
|
||||||
|
const DYNAMIC_ASSETS_PATTERNS = [
|
||||||
|
/\/src\/.+\.(ts|tsx|js|jsx)$/,
|
||||||
|
/\/src\/.+\.css$/,
|
||||||
|
/fonts\.googleapis\.com/,
|
||||||
|
/fonts\.gstatic\.com/
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - cache core resources
|
||||||
self.addEventListener('install', event => {
|
self.addEventListener('install', event => {
|
||||||
|
console.log('Service Worker installing...');
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME)
|
Promise.all([
|
||||||
.then(cache => {
|
caches.open(STATIC_CACHE).then(cache => {
|
||||||
console.log('Opened cache');
|
console.log('Caching core assets');
|
||||||
return cache.addAll(urlsToCache);
|
return cache.addAll(CORE_ASSETS);
|
||||||
|
}),
|
||||||
|
caches.open(DYNAMIC_CACHE).then(cache => {
|
||||||
|
console.log('Dynamic cache initialized');
|
||||||
|
})
|
||||||
|
]).then(() => {
|
||||||
|
console.log('Service Worker installed successfully');
|
||||||
|
return self.skipWaiting();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch event - serve from cache when offline
|
// Activate event - clean up old caches and take control
|
||||||
self.addEventListener('fetch', event => {
|
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request)
|
|
||||||
.then(response => {
|
|
||||||
// Return cached version or fetch from network
|
|
||||||
return response || fetch(event.request);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activate event - clean up old caches
|
|
||||||
self.addEventListener('activate', event => {
|
self.addEventListener('activate', event => {
|
||||||
|
console.log('Service Worker activating...');
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then(cacheNames => {
|
caches.keys().then(cacheNames => {
|
||||||
return Promise.all(
|
return Promise.all([
|
||||||
cacheNames.map(cacheName => {
|
...cacheNames.map(cacheName => {
|
||||||
if (cacheName !== CACHE_NAME) {
|
if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
|
||||||
console.log('Deleting old cache:', cacheName);
|
console.log('Deleting old cache:', cacheName);
|
||||||
return caches.delete(cacheName);
|
return caches.delete(cacheName);
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
self.clients.claim()
|
||||||
|
]);
|
||||||
|
}).then(() => {
|
||||||
|
console.log('Service Worker activated successfully');
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch event - sophisticated caching strategy
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip external APIs and chrome-extension
|
||||||
|
if (!url.origin.includes(self.location.origin) && !url.hostname.includes('googleapis') && !url.hostname.includes('gstatic')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(handleFetch(request));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleFetch(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Strategy 1: Core assets - Cache First
|
||||||
|
if (CORE_ASSETS.some(asset => url.pathname === asset || url.pathname.endsWith(asset))) {
|
||||||
|
return await cacheFirst(request, STATIC_CACHE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Dynamic assets - Stale While Revalidate
|
||||||
|
if (DYNAMIC_ASSETS_PATTERNS.some(pattern => pattern.test(url.pathname))) {
|
||||||
|
return await staleWhileRevalidate(request, DYNAMIC_CACHE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Fonts - Cache First with longer TTL
|
||||||
|
if (url.hostname.includes('googleapis') || url.hostname.includes('gstatic')) {
|
||||||
|
return await cacheFirst(request, DYNAMIC_CACHE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 4: Everything else - Network First
|
||||||
|
return await networkFirst(request, DYNAMIC_CACHE);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch error:', error);
|
||||||
|
|
||||||
|
// Fallback for navigation requests
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
const cache = await caches.open(STATIC_CACHE);
|
||||||
|
return await cache.match('/index.html') || new Response('App offline', { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Resource not available offline', { status: 503 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache First strategy
|
||||||
|
async function cacheFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.status === 200) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network First strategy
|
||||||
|
async function networkFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.status === 200) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stale While Revalidate strategy
|
||||||
|
async function staleWhileRevalidate(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const cached = await cache.match(request);
|
||||||
|
|
||||||
|
// Always try to fetch and update cache in background
|
||||||
|
const fetchPromise = fetch(request).then(response => {
|
||||||
|
if (response.status === 200) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
// Return cached version immediately if available, otherwise wait for network
|
||||||
|
return cached || await fetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle background sync for offline actions
|
||||||
|
self.addEventListener('sync', event => {
|
||||||
|
console.log('Background sync triggered:', event.tag);
|
||||||
|
|
||||||
|
if (event.tag === 'shader-save') {
|
||||||
|
event.waitUntil(syncShaderData());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function syncShaderData() {
|
||||||
|
// This would handle syncing shader data when coming back online
|
||||||
|
// For now, just log that sync is available
|
||||||
|
console.log('Shader data sync available for future implementation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle push notifications (for future features)
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
if (event.data) {
|
||||||
|
const data = event.data.json();
|
||||||
|
console.log('Push notification received:', data);
|
||||||
|
|
||||||
|
// Could be used for shader sharing notifications, etc.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide offline status
|
||||||
|
self.addEventListener('message', event => {
|
||||||
|
if (event.data && event.data.type === 'GET_VERSION') {
|
||||||
|
event.ports[0].postMessage({ version: CACHE_NAME });
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -54,16 +54,11 @@ export class FakeShader {
|
|||||||
private pendingRenders: string[] = [];
|
private pendingRenders: string[] = [];
|
||||||
private renderMode: string = 'classic';
|
private renderMode: string = 'classic';
|
||||||
private valueMode: string = 'integer';
|
private valueMode: string = 'integer';
|
||||||
private offscreenCanvas: OffscreenCanvas | null = null;
|
|
||||||
private offscreenCtx: OffscreenCanvasRenderingContext2D | null = null;
|
|
||||||
private useOffscreen: boolean = false;
|
|
||||||
|
|
||||||
|
|
||||||
// Multi-worker state
|
// Multi-worker state
|
||||||
private tileResults: Map<number, ImageData> = new Map();
|
private tileResults: Map<number, ImageData> = new Map();
|
||||||
private tilesCompleted: number = 0;
|
private tilesCompleted: number = 0;
|
||||||
private totalTiles: number = 0;
|
private totalTiles: number = 0;
|
||||||
private currentRenderID: string = '';
|
|
||||||
|
|
||||||
private mouseX: number = 0;
|
private mouseX: number = 0;
|
||||||
private mouseY: number = 0;
|
private mouseY: number = 0;
|
||||||
@ -99,7 +94,6 @@ export class FakeShader {
|
|||||||
this.ctx = canvas.getContext('2d')!;
|
this.ctx = canvas.getContext('2d')!;
|
||||||
this.code = code;
|
this.code = code;
|
||||||
|
|
||||||
|
|
||||||
// Initialize offscreen canvas if supported
|
// Initialize offscreen canvas if supported
|
||||||
this.initializeOffscreenCanvas();
|
this.initializeOffscreenCanvas();
|
||||||
|
|
||||||
@ -108,7 +102,9 @@ export class FakeShader {
|
|||||||
// Some browsers report logical processors (hyperthreading), which is good
|
// Some browsers report logical processors (hyperthreading), which is good
|
||||||
// But cap at a reasonable maximum to avoid overhead
|
// But cap at a reasonable maximum to avoid overhead
|
||||||
this.workerCount = Math.min(this.workerCount, 32);
|
this.workerCount = Math.min(this.workerCount, 32);
|
||||||
console.log(`Auto-detected ${this.workerCount} CPU cores, using all for maximum performance`);
|
console.log(
|
||||||
|
`Auto-detected ${this.workerCount} CPU cores, using all for maximum performance`
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize workers
|
// Initialize workers
|
||||||
this.initializeWorkers();
|
this.initializeWorkers();
|
||||||
@ -119,16 +115,15 @@ export class FakeShader {
|
|||||||
this.compile();
|
this.compile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private initializeOffscreenCanvas(): void {
|
private initializeOffscreenCanvas(): void {
|
||||||
if (typeof OffscreenCanvas !== 'undefined') {
|
if (typeof OffscreenCanvas !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
this.offscreenCanvas = new OffscreenCanvas(this.canvas.width, this.canvas.height);
|
// this.offscreenCanvas = new OffscreenCanvas(this.canvas.width, this.canvas.height); // Removed unused
|
||||||
this.offscreenCtx = this.offscreenCanvas.getContext('2d');
|
// this._offscreenCtx = this.offscreenCanvas.getContext('2d'); // Removed unused
|
||||||
this.useOffscreen = this.offscreenCtx !== null;
|
// this._useOffscreen = this._offscreenCtx !== null; // Removed unused property
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('OffscreenCanvas not supported:', error);
|
console.warn('OffscreenCanvas not supported:', error);
|
||||||
this.useOffscreen = false;
|
// this._useOffscreen = false; // Removed unused property
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -136,14 +131,20 @@ export class FakeShader {
|
|||||||
private initializeWorkers(): void {
|
private initializeWorkers(): void {
|
||||||
// Create worker pool
|
// Create worker pool
|
||||||
for (let i = 0; i < this.workerCount; i++) {
|
for (let i = 0; i < this.workerCount; i++) {
|
||||||
const worker = new Worker(new URL('./ShaderWorker.ts', import.meta.url), { type: 'module' });
|
const worker = new Worker(new URL('./ShaderWorker.ts', import.meta.url), {
|
||||||
worker.onmessage = (e: MessageEvent<WorkerResponse>) => this.handleWorkerMessage(e.data, i);
|
type: 'module',
|
||||||
|
});
|
||||||
|
worker.onmessage = (e: MessageEvent<WorkerResponse>) =>
|
||||||
|
this.handleWorkerMessage(e.data, i);
|
||||||
worker.onerror = (error) => console.error(`Worker ${i} error:`, error);
|
worker.onerror = (error) => console.error(`Worker ${i} error:`, error);
|
||||||
this.workers.push(worker);
|
this.workers.push(worker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleWorkerMessage(response: WorkerResponse, workerIndex: number = 0): void {
|
private handleWorkerMessage(
|
||||||
|
response: WorkerResponse,
|
||||||
|
workerIndex: number = 0
|
||||||
|
): void {
|
||||||
switch (response.type) {
|
switch (response.type) {
|
||||||
case 'compiled':
|
case 'compiled':
|
||||||
this.isCompiled = response.success;
|
this.isCompiled = response.success;
|
||||||
@ -173,7 +174,8 @@ export class FakeShader {
|
|||||||
this.pendingRenders.shift(); // Remove completed render
|
this.pendingRenders.shift(); // Remove completed render
|
||||||
if (this.pendingRenders.length > 0) {
|
if (this.pendingRenders.length > 0) {
|
||||||
// Skip to latest render request
|
// Skip to latest render request
|
||||||
const latestId = this.pendingRenders[this.pendingRenders.length - 1];
|
const latestId =
|
||||||
|
this.pendingRenders[this.pendingRenders.length - 1];
|
||||||
this.pendingRenders = [latestId];
|
this.pendingRenders = [latestId];
|
||||||
this.executeRender(latestId);
|
this.executeRender(latestId);
|
||||||
}
|
}
|
||||||
@ -198,11 +200,11 @@ export class FakeShader {
|
|||||||
const id = `compile_${Date.now()}`;
|
const id = `compile_${Date.now()}`;
|
||||||
|
|
||||||
// Send compile message to all workers
|
// Send compile message to all workers
|
||||||
this.workers.forEach(worker => {
|
this.workers.forEach((worker) => {
|
||||||
worker.postMessage({
|
worker.postMessage({
|
||||||
id,
|
id,
|
||||||
type: 'compile',
|
type: 'compile',
|
||||||
code: this.code
|
code: this.code,
|
||||||
} as WorkerMessage);
|
} as WorkerMessage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -212,9 +214,8 @@ export class FakeShader {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
this.isRendering = true;
|
this.isRendering = true;
|
||||||
this.currentRenderID = id;
|
// this._currentRenderID = id; // Removed unused property
|
||||||
const currentTime = (Date.now() - this.startTime) / 1000;
|
const currentTime = (Date.now() - this.startTime) / 1000;
|
||||||
|
|
||||||
// Always use multiple workers if available
|
// Always use multiple workers if available
|
||||||
@ -258,7 +259,7 @@ export class FakeShader {
|
|||||||
audioLevel: this.audioLevel,
|
audioLevel: this.audioLevel,
|
||||||
bassLevel: this.bassLevel,
|
bassLevel: this.bassLevel,
|
||||||
midLevel: this.midLevel,
|
midLevel: this.midLevel,
|
||||||
trebleLevel: this.trebleLevel
|
trebleLevel: this.trebleLevel,
|
||||||
} as WorkerMessage);
|
} as WorkerMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -314,7 +315,7 @@ export class FakeShader {
|
|||||||
audioLevel: this.audioLevel,
|
audioLevel: this.audioLevel,
|
||||||
bassLevel: this.bassLevel,
|
bassLevel: this.bassLevel,
|
||||||
midLevel: this.midLevel,
|
midLevel: this.midLevel,
|
||||||
trebleLevel: this.trebleLevel
|
trebleLevel: this.trebleLevel,
|
||||||
} as WorkerMessage);
|
} as WorkerMessage);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -391,7 +392,14 @@ export class FakeShader {
|
|||||||
this.valueMode = mode;
|
this.valueMode = mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMousePosition(x: number, y: number, pressed: boolean = false, vx: number = 0, vy: number = 0, clickTime: number = 0): void {
|
setMousePosition(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
pressed: boolean = false,
|
||||||
|
vx: number = 0,
|
||||||
|
vy: number = 0,
|
||||||
|
clickTime: number = 0
|
||||||
|
): void {
|
||||||
this.mouseX = x;
|
this.mouseX = x;
|
||||||
this.mouseY = y;
|
this.mouseY = y;
|
||||||
this.mousePressed = pressed;
|
this.mousePressed = pressed;
|
||||||
@ -400,7 +408,15 @@ export class FakeShader {
|
|||||||
this.mouseClickTime = clickTime;
|
this.mouseClickTime = clickTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTouchPosition(count: number, x0: number = 0, y0: number = 0, x1: number = 0, y1: number = 0, scale: number = 1, rotation: number = 0): void {
|
setTouchPosition(
|
||||||
|
count: number,
|
||||||
|
x0: number = 0,
|
||||||
|
y0: number = 0,
|
||||||
|
x1: number = 0,
|
||||||
|
y1: number = 0,
|
||||||
|
scale: number = 1,
|
||||||
|
rotation: number = 0
|
||||||
|
): void {
|
||||||
this.touchCount = count;
|
this.touchCount = count;
|
||||||
this.touch0X = x0;
|
this.touch0X = x0;
|
||||||
this.touch0Y = y0;
|
this.touch0Y = y0;
|
||||||
@ -410,7 +426,14 @@ export class FakeShader {
|
|||||||
this.pinchRotation = rotation;
|
this.pinchRotation = rotation;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeviceMotion(ax: number, ay: number, az: number, gx: number, gy: number, gz: number): void {
|
setDeviceMotion(
|
||||||
|
ax: number,
|
||||||
|
ay: number,
|
||||||
|
az: number,
|
||||||
|
gx: number,
|
||||||
|
gy: number,
|
||||||
|
gz: number
|
||||||
|
): void {
|
||||||
this.accelX = ax;
|
this.accelX = ax;
|
||||||
this.accelY = ay;
|
this.accelY = ay;
|
||||||
this.accelZ = az;
|
this.accelZ = az;
|
||||||
@ -428,12 +451,18 @@ export class FakeShader {
|
|||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.stopAnimation();
|
this.stopAnimation();
|
||||||
this.workers.forEach(worker => worker.terminate());
|
this.workers.forEach((worker) => worker.terminate());
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleTileResult(response: WorkerResponse, workerIndex: number): void {
|
private handleTileResult(
|
||||||
|
response: WorkerResponse,
|
||||||
|
workerIndex: number
|
||||||
|
): void {
|
||||||
if (!response.success || !response.imageData) {
|
if (!response.success || !response.imageData) {
|
||||||
console.error(`Tile render failed for worker ${workerIndex}:`, response.error);
|
console.error(
|
||||||
|
`Tile render failed for worker ${workerIndex}:`,
|
||||||
|
response.error
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -481,11 +510,12 @@ export class FakeShader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Simplified method - kept for backward compatibility but always uses all cores
|
// Simplified method - kept for backward compatibility but always uses all cores
|
||||||
setMultiWorkerMode(enabled: boolean, workerCount?: number): void {
|
setMultiWorkerMode(_enabled: boolean, _workerCount?: number): void {
|
||||||
// Always use all available cores, ignore the enabled parameter
|
// Always use all available cores, ignore the enabled parameter
|
||||||
console.log(`Multi-worker mode is always enabled, using ${this.workerCount} cores for maximum performance`);
|
console.log(
|
||||||
|
`Multi-worker mode is always enabled, using ${this.workerCount} cores for maximum performance`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
getWorkerCount(): number {
|
getWorkerCount(): number {
|
||||||
@ -517,7 +547,7 @@ export class FakeShader {
|
|||||||
'(x^(y*t*2))%256',
|
'(x^(y*t*2))%256',
|
||||||
'((x+t)*(y+t))%256',
|
'((x+t)*(y+t))%256',
|
||||||
'(x&y&(t*8))%256',
|
'(x&y&(t*8))%256',
|
||||||
'((x|t)^(y|t))%256'
|
'((x|t)^(y|t))%256',
|
||||||
];
|
];
|
||||||
|
|
||||||
const vars = ['x', 'y', 't', 'i'];
|
const vars = ['x', 'y', 't', 'i'];
|
||||||
@ -525,13 +555,17 @@ export class FakeShader {
|
|||||||
const shifts = ['<<', '>>'];
|
const shifts = ['<<', '>>'];
|
||||||
const numbers = ['2', '4', '8', '16', '32', '64', '128', '256'];
|
const numbers = ['2', '4', '8', '16', '32', '64', '128', '256'];
|
||||||
|
|
||||||
const randomChoice = <T>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
|
const randomChoice = <T>(arr: T[]): T =>
|
||||||
|
arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
|
||||||
const dynamicExpressions = [
|
const dynamicExpressions = [
|
||||||
() => `${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)}`,
|
() => `${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(ops)}${randomChoice(vars)})%${randomChoice(numbers)}`,
|
||||||
() => `(${randomChoice(vars)}*${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)}`,
|
() => `${randomChoice(vars)}^${randomChoice(vars)}^${randomChoice(vars)}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,7 @@
|
|||||||
interface SavedShader {
|
import { AppSettings } from './stores/appSettings';
|
||||||
|
import { STORAGE_KEYS, PERFORMANCE, DEFAULTS, ValueMode } from './utils/constants';
|
||||||
|
|
||||||
|
export interface SavedShader {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
@ -8,24 +11,19 @@ interface SavedShader {
|
|||||||
resolution?: number;
|
resolution?: number;
|
||||||
fps?: number;
|
fps?: number;
|
||||||
renderMode?: string;
|
renderMode?: string;
|
||||||
valueMode?: string;
|
valueMode?: ValueMode;
|
||||||
uiOpacity?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AppSettings {
|
|
||||||
resolution: number;
|
|
||||||
fps: number;
|
|
||||||
lastShaderCode: string;
|
|
||||||
renderMode: string;
|
|
||||||
valueMode?: string;
|
|
||||||
uiOpacity?: number;
|
uiOpacity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Storage {
|
export class Storage {
|
||||||
private static readonly SHADERS_KEY = 'bitfielder_shaders';
|
private static readonly SHADERS_KEY = STORAGE_KEYS.SHADERS;
|
||||||
private static readonly SETTINGS_KEY = 'bitfielder_settings';
|
private static readonly SETTINGS_KEY = STORAGE_KEYS.SETTINGS;
|
||||||
|
|
||||||
static saveShader(name: string, code: string, settings?: Partial<AppSettings>): SavedShader {
|
static saveShader(
|
||||||
|
name: string,
|
||||||
|
code: string,
|
||||||
|
settings?: Partial<AppSettings>
|
||||||
|
): SavedShader {
|
||||||
const shaders = this.getShaders();
|
const shaders = this.getShaders();
|
||||||
const id = this.generateId();
|
const id = this.generateId();
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
@ -42,8 +40,8 @@ export class Storage {
|
|||||||
fps: settings.fps,
|
fps: settings.fps,
|
||||||
renderMode: settings.renderMode,
|
renderMode: settings.renderMode,
|
||||||
valueMode: settings.valueMode,
|
valueMode: settings.valueMode,
|
||||||
uiOpacity: settings.uiOpacity
|
uiOpacity: settings.uiOpacity,
|
||||||
})
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
shaders.push(shader);
|
shaders.push(shader);
|
||||||
@ -62,13 +60,13 @@ export class Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static deleteShader(id: string): void {
|
static deleteShader(id: string): void {
|
||||||
const shaders = this.getShaders().filter(s => s.id !== id);
|
const shaders = this.getShaders().filter((s) => s.id !== id);
|
||||||
this.setShaders(shaders);
|
this.setShaders(shaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
static updateShaderUsage(id: string): void {
|
static updateShaderUsage(id: string): void {
|
||||||
const shaders = this.getShaders();
|
const shaders = this.getShaders();
|
||||||
const shader = shaders.find(s => s.id === id);
|
const shader = shaders.find((s) => s.id === id);
|
||||||
if (shader) {
|
if (shader) {
|
||||||
shader.lastUsed = Date.now();
|
shader.lastUsed = Date.now();
|
||||||
this.setShaders(shaders);
|
this.setShaders(shaders);
|
||||||
@ -77,7 +75,7 @@ export class Storage {
|
|||||||
|
|
||||||
static renameShader(id: string, newName: string): void {
|
static renameShader(id: string, newName: string): void {
|
||||||
const shaders = this.getShaders();
|
const shaders = this.getShaders();
|
||||||
const shader = shaders.find(s => s.id === id);
|
const shader = shaders.find((s) => s.id === id);
|
||||||
if (shader) {
|
if (shader) {
|
||||||
shader.name = newName.trim() || shader.name;
|
shader.name = newName.trim() || shader.name;
|
||||||
this.setShaders(shaders);
|
this.setShaders(shaders);
|
||||||
@ -86,10 +84,10 @@ export class Storage {
|
|||||||
|
|
||||||
private static setShaders(shaders: SavedShader[]): void {
|
private static setShaders(shaders: SavedShader[]): void {
|
||||||
try {
|
try {
|
||||||
// Keep only the 50 most recent shaders
|
// Keep only the most recent shaders
|
||||||
const sortedShaders = shaders
|
const sortedShaders = shaders
|
||||||
.sort((a, b) => b.lastUsed - a.lastUsed)
|
.sort((a, b) => b.lastUsed - a.lastUsed)
|
||||||
.slice(0, 50);
|
.slice(0, PERFORMANCE.MAX_SAVED_SHADERS);
|
||||||
localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders));
|
localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save shaders:', error);
|
console.error('Failed to save shaders:', error);
|
||||||
@ -110,21 +108,23 @@ export class Storage {
|
|||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(this.SETTINGS_KEY);
|
const stored = localStorage.getItem(this.SETTINGS_KEY);
|
||||||
const defaults: AppSettings = {
|
const defaults: AppSettings = {
|
||||||
resolution: 1,
|
resolution: DEFAULTS.RESOLUTION,
|
||||||
fps: 30,
|
fps: DEFAULTS.FPS,
|
||||||
lastShaderCode: 'x^y',
|
lastShaderCode: DEFAULTS.SHADER_CODE,
|
||||||
renderMode: 'classic',
|
renderMode: DEFAULTS.RENDER_MODE,
|
||||||
uiOpacity: 0.3
|
valueMode: DEFAULTS.VALUE_MODE,
|
||||||
|
uiOpacity: DEFAULTS.UI_OPACITY,
|
||||||
};
|
};
|
||||||
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
|
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
return {
|
return {
|
||||||
resolution: 1,
|
resolution: DEFAULTS.RESOLUTION,
|
||||||
fps: 30,
|
fps: DEFAULTS.FPS,
|
||||||
lastShaderCode: 'x^y',
|
lastShaderCode: DEFAULTS.SHADER_CODE,
|
||||||
renderMode: 'classic',
|
renderMode: DEFAULTS.RENDER_MODE,
|
||||||
uiOpacity: 0.3
|
valueMode: DEFAULTS.VALUE_MODE,
|
||||||
|
uiOpacity: DEFAULTS.UI_OPACITY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -155,8 +155,11 @@ export class Storage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate structure
|
// Validate structure
|
||||||
const valid = imported.every(shader =>
|
const valid = imported.every(
|
||||||
shader.id && shader.name && shader.code &&
|
(shader) =>
|
||||||
|
shader.id &&
|
||||||
|
shader.name &&
|
||||||
|
shader.code &&
|
||||||
typeof shader.created === 'number' &&
|
typeof shader.created === 'number' &&
|
||||||
typeof shader.lastUsed === 'number'
|
typeof shader.lastUsed === 'number'
|
||||||
);
|
);
|
||||||
|
|||||||
87
src/components/App.tsx
Normal file
87
src/components/App.tsx
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { TopBar } from './TopBar';
|
||||||
|
import { MobileMenu } from './MobileMenu';
|
||||||
|
import { EditorPanel } from './EditorPanel';
|
||||||
|
import { ShaderLibrary } from './ShaderLibrary';
|
||||||
|
import { HelpPopup } from './HelpPopup';
|
||||||
|
import { WelcomePopup } from './WelcomePopup';
|
||||||
|
import { ShaderCanvas } from './ShaderCanvas';
|
||||||
|
import { PerformanceWarning } from './PerformanceWarning';
|
||||||
|
import { uiState, showUI } from '../stores/ui';
|
||||||
|
import { $appSettings } from '../stores/appSettings';
|
||||||
|
import { $shader } from '../stores/shader';
|
||||||
|
import { loadShaders } from '../stores/library';
|
||||||
|
import { Storage } from '../Storage';
|
||||||
|
import { LucideIcon } from '../hooks/useLucideIcon';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const ui = useStore(uiState);
|
||||||
|
const settings = useStore($appSettings);
|
||||||
|
const shader = useStore($shader);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load initial settings from storage
|
||||||
|
const savedSettings = Storage.getSettings();
|
||||||
|
$appSettings.set(savedSettings);
|
||||||
|
|
||||||
|
// Load saved shaders
|
||||||
|
loadShaders();
|
||||||
|
|
||||||
|
// Set CSS custom property for UI opacity
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--ui-opacity',
|
||||||
|
(settings.uiOpacity ?? 0.3).toString()
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Update CSS custom property when opacity changes
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--ui-opacity',
|
||||||
|
(settings.uiOpacity ?? 0.3).toString()
|
||||||
|
);
|
||||||
|
}, [settings.uiOpacity]);
|
||||||
|
|
||||||
|
// Save settings changes to localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
Storage.saveSettings({
|
||||||
|
resolution: settings.resolution,
|
||||||
|
fps: settings.fps,
|
||||||
|
renderMode: settings.renderMode,
|
||||||
|
valueMode: settings.valueMode,
|
||||||
|
uiOpacity: settings.uiOpacity,
|
||||||
|
lastShaderCode: shader.code,
|
||||||
|
});
|
||||||
|
}, [settings, shader.code]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ShaderCanvas />
|
||||||
|
|
||||||
|
{ui.uiVisible ? (
|
||||||
|
<>
|
||||||
|
<TopBar />
|
||||||
|
{!ui.mobileMenuOpen && <EditorPanel />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
id="show-ui-btn"
|
||||||
|
onClick={showUI}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
>
|
||||||
|
<LucideIcon name="show" />
|
||||||
|
</button>
|
||||||
|
<EditorPanel minimal={true} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MobileMenu />
|
||||||
|
<ShaderLibrary />
|
||||||
|
<HelpPopup />
|
||||||
|
<WelcomePopup />
|
||||||
|
<PerformanceWarning />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/EditorPanel.tsx
Normal file
59
src/components/EditorPanel.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { $shader, setShaderCode } from '../stores/shader';
|
||||||
|
|
||||||
|
interface EditorPanelProps {
|
||||||
|
minimal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorPanel({ minimal = false }: EditorPanelProps) {
|
||||||
|
const shader = useStore($shader);
|
||||||
|
// const ui = useStore(uiState); // Unused for now
|
||||||
|
const [localCode, setLocalCode] = useState(shader.code);
|
||||||
|
|
||||||
|
// Check if code has changed from the compiled version
|
||||||
|
const hasChanges = localCode !== shader.code;
|
||||||
|
|
||||||
|
const handleCodeChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Only update local state, don't compile until eval
|
||||||
|
setLocalCode(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync local code when shader code changes externally (e.g., from library)
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalCode(shader.code);
|
||||||
|
}, [shader.code]);
|
||||||
|
|
||||||
|
const handleEval = () => {
|
||||||
|
// Compile and render the shader
|
||||||
|
setShaderCode(localCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleEval();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="editor-panel" className={minimal ? 'minimal' : ''}>
|
||||||
|
<textarea
|
||||||
|
id="editor"
|
||||||
|
className={minimal ? 'minimal' : ''}
|
||||||
|
value={localCode}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Enter shader code... (x, y, t, i, mouseX, mouseY, mousePressed, touchCount, accelX, audioLevel, bassLevel...)"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
id="eval-btn"
|
||||||
|
className={minimal ? 'minimal' : ''}
|
||||||
|
onClick={handleEval}
|
||||||
|
>
|
||||||
|
{hasChanges ? 'Eval *' : 'Eval'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
296
src/components/HelpPopup.tsx
Normal file
296
src/components/HelpPopup.tsx
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { uiState, hideHelp } from '../stores/ui';
|
||||||
|
|
||||||
|
export function HelpPopup() {
|
||||||
|
const ui = useStore(uiState);
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
hideHelp();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ui.helpPopupOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id="help-popup"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<button className="close-btn" onClick={hideHelp}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<h3>Bitfielder Help</h3>
|
||||||
|
|
||||||
|
<div className="help-content">
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Keyboard Shortcuts</h4>
|
||||||
|
<p>
|
||||||
|
<strong>Ctrl+Enter</strong> - Execute shader code
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>F11</strong> - Toggle fullscreen
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>H</strong> - Hide/show UI
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>R</strong> - Generate random shader
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>S</strong> - Share current shader (copy URL)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>?</strong> - Show this help
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Variables</h4>
|
||||||
|
<p>
|
||||||
|
<strong>x, y</strong> - Pixel coordinates
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>t</strong> - Time (enables animation)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>i</strong> - Pixel index
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>mouseX, mouseY</strong> - Mouse position (0.0 to 1.0)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>mousePressed</strong> - Mouse button down (true/false)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>mouseVX, mouseVY</strong> - Mouse velocity
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>mouseClickTime</strong> - Time since last click (ms)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Touch & Gestures</h4>
|
||||||
|
<p>
|
||||||
|
<strong>touchCount</strong> - Number of active touches
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>touch0X, touch0Y</strong> - Primary touch position
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>touch1X, touch1Y</strong> - Secondary touch position
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>pinchScale</strong> - Pinch zoom scale factor
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>pinchRotation</strong> - Pinch rotation angle
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Device Motion</h4>
|
||||||
|
<p>
|
||||||
|
<strong>accelX, accelY, accelZ</strong> - Accelerometer data
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>gyroX, gyroY, gyroZ</strong> - Gyroscope rotation rates
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Audio Reactive</h4>
|
||||||
|
<p>
|
||||||
|
<strong>audioLevel</strong> - Overall audio volume (0.0-1.0)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>bassLevel</strong> - Low frequencies (0.0-1.0)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>midLevel</strong> - Mid frequencies (0.0-1.0)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>trebleLevel</strong> - High frequencies (0.0-1.0)
|
||||||
|
</p>
|
||||||
|
<p>Click "Enable Audio" to activate microphone</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Operators</h4>
|
||||||
|
<p>
|
||||||
|
<strong>^ & |</strong> - XOR, AND, OR
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong><< >></strong> - Bit shift left/right
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>+ - * / %</strong> - Math operations
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>== != < ></strong> - Comparisons (return 0/1)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>? :</strong> - Ternary operator (condition ? true : false)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>~ **</strong> - Bitwise NOT, exponentiation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Math Functions</h4>
|
||||||
|
<p>
|
||||||
|
<strong>sin, cos, tan</strong> - Trigonometric functions
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>abs, sqrt, pow</strong> - Absolute, square root, power
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>floor, ceil, round</strong> - Rounding functions
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>min, max</strong> - Minimum and maximum
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>random</strong> - Random number 0-1
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>log, exp</strong> - Natural logarithm, exponential
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>PI, E</strong> - Math constants
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Use without Math. prefix: <code>sin(x)</code> not{' '}
|
||||||
|
<code>Math.sin(x)</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Value Modes</h4>
|
||||||
|
<p>
|
||||||
|
<strong>Integer (0-255):</strong> Traditional mode for large values
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Float (0.0-1.0):</strong> Bitfield shader mode, inverts and
|
||||||
|
clamps values
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Polar (angle-based):</strong> Spiral patterns combining
|
||||||
|
angle and radius
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Distance (radial):</strong> Concentric wave rings with
|
||||||
|
variable frequency
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Wave (ripple):</strong> Multi-source interference with
|
||||||
|
amplitude falloff
|
||||||
|
</p>
|
||||||
|
<p>Each mode transforms your expression differently!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Advanced Features</h4>
|
||||||
|
<p>
|
||||||
|
<strong>Array indexing:</strong> <code>[1,2,4,8][floor(t%4)]</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Complex expressions:</strong>{' '}
|
||||||
|
<code>x>y ? sin(x) : cos(y)</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Nested functions:</strong>{' '}
|
||||||
|
<code>pow(sin(x), abs(y-x))</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Logical operators:</strong> <code>x&&y</code>,{' '}
|
||||||
|
<code>x||y</code>
|
||||||
|
</p>
|
||||||
|
<p>No character or length limits - use any JavaScript!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Shader Library</h4>
|
||||||
|
<p>
|
||||||
|
Hover over the <strong>left edge</strong> of the screen to access
|
||||||
|
the shader library
|
||||||
|
</p>
|
||||||
|
<p>Save shaders with custom names and search through them</p>
|
||||||
|
<p>
|
||||||
|
Use <strong>edit</strong> to rename, <strong>del</strong> to delete
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Render Modes</h4>
|
||||||
|
<p>
|
||||||
|
<strong>Classic</strong> - Original colorful mode
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Grayscale</strong> - Black and white
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Red/Green/Blue</strong> - Single color channels
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>HSV</strong> - Hue-based coloring
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Rainbow</strong> - Spectrum coloring
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="help-section">
|
||||||
|
<h4>Export</h4>
|
||||||
|
<p>
|
||||||
|
<strong>Export PNG</strong> - Save current frame as image
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="help-section"
|
||||||
|
style={{
|
||||||
|
gridColumn: '1 / -1',
|
||||||
|
marginTop: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingTop: '20px',
|
||||||
|
borderBottom: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h4>About</h4>
|
||||||
|
<p>
|
||||||
|
<strong>Bitfielder</strong> - Interactive bitfield shader editor
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Created by <strong>BuboBubo</strong> (Raphaël Forment)
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Website:{' '}
|
||||||
|
<a
|
||||||
|
href="https://raphaelforment.fr"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
raphaelforment.fr
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Source:{' '}
|
||||||
|
<a
|
||||||
|
href="https://git.raphaelforment.fr"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
git.raphaelforment.fr
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
License: <strong>AGPL 3.0</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
src/components/MobileMenu.tsx
Normal file
191
src/components/MobileMenu.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { uiState, closeMobileMenu, showHelp } from '../stores/ui';
|
||||||
|
import { $appSettings, updateAppSettings } from '../stores/appSettings';
|
||||||
|
import { VALUE_MODES, ValueMode } from '../utils/constants';
|
||||||
|
import { $input } from '../stores/input';
|
||||||
|
import { LucideIcon } from '../hooks/useLucideIcon';
|
||||||
|
import { useAudio } from '../hooks/useAudio';
|
||||||
|
|
||||||
|
function getValueModeLabel(mode: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
integer: 'Integer (0-255)',
|
||||||
|
float: 'Float (0.0-1.0)',
|
||||||
|
polar: 'Polar (angle-based)',
|
||||||
|
distance: 'Distance (radial)',
|
||||||
|
wave: 'Wave (ripple)',
|
||||||
|
fractal: 'Fractal (recursive)',
|
||||||
|
cellular: 'Cellular (automata)',
|
||||||
|
noise: 'Noise (perlin-like)',
|
||||||
|
warp: 'Warp (space deformation)',
|
||||||
|
flow: 'Flow (fluid dynamics)',
|
||||||
|
};
|
||||||
|
return labels[mode] || mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileMenu() {
|
||||||
|
const ui = useStore(uiState);
|
||||||
|
const settings = useStore($appSettings);
|
||||||
|
const input = useStore($input);
|
||||||
|
const { setupAudio, disableAudio } = useAudio();
|
||||||
|
|
||||||
|
const handleFullscreen = () => {
|
||||||
|
closeMobileMenu();
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
closeMobileMenu();
|
||||||
|
// Implement share functionality
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPNG = () => {
|
||||||
|
closeMobileMenu();
|
||||||
|
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
|
||||||
|
if (canvas) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `bitfielder-${Date.now()}.png`;
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioToggle = async () => {
|
||||||
|
closeMobileMenu();
|
||||||
|
if (input.audioEnabled) {
|
||||||
|
disableAudio();
|
||||||
|
} else {
|
||||||
|
await setupAudio();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHelp = () => {
|
||||||
|
closeMobileMenu();
|
||||||
|
showHelp();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
id="mobile-menu-overlay"
|
||||||
|
className={ui.mobileMenuOpen ? 'open' : ''}
|
||||||
|
onClick={closeMobileMenu}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div id="mobile-menu" className={ui.mobileMenuOpen ? 'open' : ''}>
|
||||||
|
<div className="mobile-menu-section">
|
||||||
|
<div className="mobile-menu-item">
|
||||||
|
<label>Resolution</label>
|
||||||
|
<select
|
||||||
|
value={settings.resolution}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAppSettings({ resolution: parseInt(e.target.value) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="1">Full (1x)</option>
|
||||||
|
<option value="2">Half (2x)</option>
|
||||||
|
<option value="4">Quarter (4x)</option>
|
||||||
|
<option value="8">Eighth (8x)</option>
|
||||||
|
<option value="16">Sixteenth (16x)</option>
|
||||||
|
<option value="32">Thirty-second (32x)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-menu-item">
|
||||||
|
<label>FPS</label>
|
||||||
|
<select
|
||||||
|
value={settings.fps}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAppSettings({ fps: parseInt(e.target.value) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="15">15 FPS</option>
|
||||||
|
<option value="30">30 FPS</option>
|
||||||
|
<option value="60">60 FPS</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-menu-item">
|
||||||
|
<label>Value Mode</label>
|
||||||
|
<select
|
||||||
|
value={settings.valueMode}
|
||||||
|
onChange={(e) => updateAppSettings({ valueMode: e.target.value as ValueMode })}
|
||||||
|
>
|
||||||
|
{VALUE_MODES.map((mode) => (
|
||||||
|
<option key={mode} value={mode}>
|
||||||
|
{getValueModeLabel(mode)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-menu-item">
|
||||||
|
<label>Render Mode</label>
|
||||||
|
<select
|
||||||
|
value={settings.renderMode}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAppSettings({ renderMode: e.target.value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="classic">Classic</option>
|
||||||
|
<option value="grayscale">Grayscale</option>
|
||||||
|
<option value="red">Red Channel</option>
|
||||||
|
<option value="green">Green Channel</option>
|
||||||
|
<option value="blue">Blue Channel</option>
|
||||||
|
<option value="rgb">RGB Split</option>
|
||||||
|
<option value="hsv">HSV</option>
|
||||||
|
<option value="rainbow">Rainbow</option>
|
||||||
|
<option value="thermal">Thermal</option>
|
||||||
|
<option value="neon">Neon</option>
|
||||||
|
<option value="cyberpunk">Cyberpunk</option>
|
||||||
|
<option value="vaporwave">Vaporwave</option>
|
||||||
|
<option value="dithered">Dithered</option>
|
||||||
|
<option value="palette">Palette</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-menu-item">
|
||||||
|
<label>
|
||||||
|
UI Opacity: {Math.round((settings.uiOpacity ?? 0.3) * 100)}%
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
value={Math.round((settings.uiOpacity ?? 0.3) * 100)}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAppSettings({ uiOpacity: parseInt(e.target.value) / 100 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mobile-menu-section">
|
||||||
|
<div className="mobile-menu-buttons">
|
||||||
|
<button onClick={handleHelp}>
|
||||||
|
<LucideIcon name="help" /> Help
|
||||||
|
</button>
|
||||||
|
<button onClick={handleFullscreen}>
|
||||||
|
<LucideIcon name="fullscreen" /> Fullscreen
|
||||||
|
</button>
|
||||||
|
<button onClick={handleAudioToggle}>
|
||||||
|
<LucideIcon
|
||||||
|
name={input.audioEnabled ? 'microphone' : 'microphone-off'}
|
||||||
|
/>
|
||||||
|
{input.audioEnabled ? 'Disable Audio' : 'Enable Audio'}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleShare}>
|
||||||
|
<LucideIcon name="share" /> Share
|
||||||
|
</button>
|
||||||
|
<button onClick={handleExportPNG}>
|
||||||
|
<LucideIcon name="export" /> Export PNG
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/PerformanceWarning.tsx
Normal file
14
src/components/PerformanceWarning.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { uiState } from '../stores/ui';
|
||||||
|
|
||||||
|
export function PerformanceWarning() {
|
||||||
|
const ui = useStore(uiState);
|
||||||
|
|
||||||
|
if (!ui.performanceWarningVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="performance-warning" style={{ display: 'block' }}>
|
||||||
|
Performance warning: Shader taking too long to render!
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
src/components/ShaderCanvas.tsx
Normal file
327
src/components/ShaderCanvas.tsx
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { $appSettings } from '../stores/appSettings';
|
||||||
|
import { $shader } from '../stores/shader';
|
||||||
|
import { uiState, showPerformanceWarning } from '../stores/ui';
|
||||||
|
import {
|
||||||
|
$input,
|
||||||
|
updateMousePosition,
|
||||||
|
updateTouchPosition,
|
||||||
|
updateDeviceMotion,
|
||||||
|
} from '../stores/input';
|
||||||
|
import { FakeShader } from '../FakeShader';
|
||||||
|
import { UI_HEIGHTS } from '../utils/constants';
|
||||||
|
|
||||||
|
export function ShaderCanvas() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const shaderRef = useRef<FakeShader | null>(null);
|
||||||
|
const settings = useStore($appSettings);
|
||||||
|
const shader = useStore($shader);
|
||||||
|
const ui = useStore(uiState);
|
||||||
|
const input = useStore($input);
|
||||||
|
|
||||||
|
// Mouse tracking state
|
||||||
|
const mouseState = useRef({
|
||||||
|
lastX: 0,
|
||||||
|
lastY: 0,
|
||||||
|
startTime: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch gesture state
|
||||||
|
const touchState = useRef({
|
||||||
|
initialPinchDistance: 0,
|
||||||
|
initialPinchAngle: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
// Initialize shader
|
||||||
|
shaderRef.current = new FakeShader(canvasRef.current, shader.code);
|
||||||
|
|
||||||
|
// Set up canvas size
|
||||||
|
setupCanvas();
|
||||||
|
|
||||||
|
// Clean up on unmount
|
||||||
|
return () => {
|
||||||
|
if (shaderRef.current) {
|
||||||
|
shaderRef.current.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Update shader when code changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (shaderRef.current) {
|
||||||
|
shaderRef.current.setCode(shader.code);
|
||||||
|
}
|
||||||
|
}, [shader.code]);
|
||||||
|
|
||||||
|
// Update shader settings
|
||||||
|
useEffect(() => {
|
||||||
|
if (shaderRef.current) {
|
||||||
|
shaderRef.current.setRenderMode(settings.renderMode);
|
||||||
|
shaderRef.current.setValueMode(settings.valueMode ?? 'integer');
|
||||||
|
shaderRef.current.setTargetFPS(settings.fps);
|
||||||
|
}
|
||||||
|
}, [settings.renderMode, settings.valueMode, settings.fps]);
|
||||||
|
|
||||||
|
// Handle canvas resize when resolution or UI visibility changes
|
||||||
|
useEffect(() => {
|
||||||
|
setupCanvas();
|
||||||
|
}, [settings.resolution, ui.uiVisible]);
|
||||||
|
|
||||||
|
// Handle animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (shaderRef.current) {
|
||||||
|
const hasTime = shader.code.includes('t');
|
||||||
|
if (hasTime) {
|
||||||
|
shaderRef.current.startAnimation();
|
||||||
|
} else {
|
||||||
|
shaderRef.current.stopAnimation();
|
||||||
|
shaderRef.current.render(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [shader.code]);
|
||||||
|
|
||||||
|
// Update input data to shader
|
||||||
|
useEffect(() => {
|
||||||
|
if (shaderRef.current) {
|
||||||
|
shaderRef.current.setMousePosition(
|
||||||
|
input.mouseX,
|
||||||
|
input.mouseY,
|
||||||
|
input.mousePressed,
|
||||||
|
input.mouseVX,
|
||||||
|
input.mouseVY,
|
||||||
|
input.mouseClickTime
|
||||||
|
);
|
||||||
|
shaderRef.current.setTouchPosition(
|
||||||
|
input.touchCount,
|
||||||
|
input.touch0X,
|
||||||
|
input.touch0Y,
|
||||||
|
input.touch1X,
|
||||||
|
input.touch1Y,
|
||||||
|
input.pinchScale,
|
||||||
|
input.pinchRotation
|
||||||
|
);
|
||||||
|
shaderRef.current.setDeviceMotion(
|
||||||
|
input.accelX,
|
||||||
|
input.accelY,
|
||||||
|
input.accelZ,
|
||||||
|
input.gyroX,
|
||||||
|
input.gyroY,
|
||||||
|
input.gyroZ
|
||||||
|
);
|
||||||
|
shaderRef.current.setAudioData(
|
||||||
|
input.audioLevel,
|
||||||
|
input.bassLevel,
|
||||||
|
input.midLevel,
|
||||||
|
input.trebleLevel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [input]);
|
||||||
|
|
||||||
|
const setupCanvas = () => {
|
||||||
|
if (!canvasRef.current) return;
|
||||||
|
|
||||||
|
const width = window.innerWidth;
|
||||||
|
const height = ui.uiVisible
|
||||||
|
? window.innerHeight - UI_HEIGHTS.TOTAL_UI_HEIGHT
|
||||||
|
: window.innerHeight;
|
||||||
|
|
||||||
|
const scale = settings.resolution;
|
||||||
|
|
||||||
|
// Set canvas internal size with resolution scaling
|
||||||
|
canvasRef.current.width = Math.floor(width / scale);
|
||||||
|
canvasRef.current.height = Math.floor(height / scale);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Canvas setup: ${canvasRef.current.width}x${canvasRef.current.height} (scale: ${scale}x), UI visible: ${ui.uiVisible}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (e: React.MouseEvent) => {
|
||||||
|
const lastX = mouseState.current.lastX;
|
||||||
|
const lastY = mouseState.current.lastY;
|
||||||
|
const x = e.clientX / window.innerWidth;
|
||||||
|
const y = 1.0 - e.clientY / window.innerHeight; // Invert Y to match shader coordinates
|
||||||
|
const vx = x - lastX;
|
||||||
|
const vy = y - lastY;
|
||||||
|
|
||||||
|
mouseState.current.lastX = x;
|
||||||
|
mouseState.current.lastY = y;
|
||||||
|
|
||||||
|
updateMousePosition(x, y, input.mousePressed, vx, vy, input.mouseClickTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = () => {
|
||||||
|
const clickTime = Date.now();
|
||||||
|
updateMousePosition(
|
||||||
|
input.mouseX,
|
||||||
|
input.mouseY,
|
||||||
|
true,
|
||||||
|
input.mouseVX,
|
||||||
|
input.mouseVY,
|
||||||
|
clickTime
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
updateMousePosition(
|
||||||
|
input.mouseX,
|
||||||
|
input.mouseY,
|
||||||
|
false,
|
||||||
|
input.mouseVX,
|
||||||
|
input.mouseVY,
|
||||||
|
input.mouseClickTime
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
// Only prevent default on canvas area for shader interaction
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
updateTouchPositions(e.touches);
|
||||||
|
initializePinchGesture(e.touches);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e: React.TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
updateTouchPositions(e.touches);
|
||||||
|
updatePinchGesture(e.touches);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const touchCount = e.touches.length;
|
||||||
|
if (touchCount === 0) {
|
||||||
|
updateTouchPosition(0, 0, 0, 0, 0, 1, 0);
|
||||||
|
} else {
|
||||||
|
updateTouchPositions(e.touches);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTouchPositions = (touches: React.TouchList | TouchList) => {
|
||||||
|
let touch0X = 0,
|
||||||
|
touch0Y = 0,
|
||||||
|
touch1X = 0,
|
||||||
|
touch1Y = 0;
|
||||||
|
|
||||||
|
if (touches.length > 0) {
|
||||||
|
touch0X = touches[0].clientX / window.innerWidth;
|
||||||
|
touch0Y = 1.0 - touches[0].clientY / window.innerHeight;
|
||||||
|
}
|
||||||
|
if (touches.length > 1) {
|
||||||
|
touch1X = touches[1].clientX / window.innerWidth;
|
||||||
|
touch1Y = 1.0 - touches[1].clientY / window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTouchPosition(
|
||||||
|
touches.length,
|
||||||
|
touch0X,
|
||||||
|
touch0Y,
|
||||||
|
touch1X,
|
||||||
|
touch1Y,
|
||||||
|
input.pinchScale,
|
||||||
|
input.pinchRotation
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializePinchGesture = (touches: React.TouchList | TouchList) => {
|
||||||
|
if (touches.length === 2) {
|
||||||
|
const dx = touches[1].clientX - touches[0].clientX;
|
||||||
|
const dy = touches[1].clientY - touches[0].clientY;
|
||||||
|
touchState.current.initialPinchDistance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
touchState.current.initialPinchAngle = Math.atan2(dy, dx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePinchGesture = (touches: React.TouchList | TouchList) => {
|
||||||
|
if (touches.length === 2 && touchState.current.initialPinchDistance > 0) {
|
||||||
|
const dx = touches[1].clientX - touches[0].clientX;
|
||||||
|
const dy = touches[1].clientY - touches[0].clientY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
const angle = Math.atan2(dy, dx);
|
||||||
|
|
||||||
|
const pinchScale = distance / touchState.current.initialPinchDistance;
|
||||||
|
const pinchRotation = angle - touchState.current.initialPinchAngle;
|
||||||
|
|
||||||
|
updateTouchPosition(
|
||||||
|
touches.length,
|
||||||
|
input.touch0X,
|
||||||
|
input.touch0Y,
|
||||||
|
input.touch1X,
|
||||||
|
input.touch1Y,
|
||||||
|
pinchScale,
|
||||||
|
pinchRotation
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up device motion listener
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDeviceMotion = (e: DeviceMotionEvent) => {
|
||||||
|
if (e.acceleration && e.rotationRate) {
|
||||||
|
updateDeviceMotion(
|
||||||
|
e.acceleration.x || 0,
|
||||||
|
e.acceleration.y || 0,
|
||||||
|
e.acceleration.z || 0,
|
||||||
|
e.rotationRate.alpha || 0,
|
||||||
|
e.rotationRate.beta || 0,
|
||||||
|
e.rotationRate.gamma || 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (window.DeviceMotionEvent) {
|
||||||
|
window.addEventListener('devicemotion', handleDeviceMotion);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener('devicemotion', handleDeviceMotion);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set up performance warning listener
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePerformanceWarning = (e: MessageEvent) => {
|
||||||
|
if (e.data === 'performance-warning') {
|
||||||
|
showPerformanceWarning();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handlePerformanceWarning);
|
||||||
|
return () =>
|
||||||
|
window.removeEventListener('message', handlePerformanceWarning);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Set up window resize listener
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => setupCanvas();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
|
}, [settings.resolution, ui.uiVisible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
id="canvas"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
imageRendering: 'pixelated',
|
||||||
|
touchAction: 'none',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
}}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
src/components/ShaderLibrary.tsx
Normal file
246
src/components/ShaderLibrary.tsx
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { uiState, toggleShaderLibrary } from '../stores/ui';
|
||||||
|
import {
|
||||||
|
$library,
|
||||||
|
setSearchTerm,
|
||||||
|
getFilteredShaders,
|
||||||
|
saveShader,
|
||||||
|
deleteShader,
|
||||||
|
renameShader,
|
||||||
|
updateShaderUsage,
|
||||||
|
} from '../stores/library';
|
||||||
|
import { $shader, setShaderCode } from '../stores/shader';
|
||||||
|
import { $appSettings, updateAppSettings } from '../stores/appSettings';
|
||||||
|
|
||||||
|
export function ShaderLibrary() {
|
||||||
|
const ui = useStore(uiState);
|
||||||
|
const library = useStore($library);
|
||||||
|
const shader = useStore($shader);
|
||||||
|
const settings = useStore($appSettings);
|
||||||
|
const [shaderName, setShaderName] = useState('');
|
||||||
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
|
||||||
|
const filteredShaders = getFilteredShaders();
|
||||||
|
|
||||||
|
const handleSaveShader = () => {
|
||||||
|
const name = shaderName.trim();
|
||||||
|
const code = shader.code.trim();
|
||||||
|
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
const currentSettings = {
|
||||||
|
resolution: settings.resolution,
|
||||||
|
fps: settings.fps,
|
||||||
|
renderMode: settings.renderMode,
|
||||||
|
valueMode: settings.valueMode,
|
||||||
|
uiOpacity: settings.uiOpacity,
|
||||||
|
};
|
||||||
|
|
||||||
|
saveShader(name, code, currentSettings);
|
||||||
|
setShaderName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSaveShader();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadShader = (shaderId: string) => {
|
||||||
|
const shaderData = library.shaders.find((s) => s.id === shaderId);
|
||||||
|
if (shaderData) {
|
||||||
|
// Load the code
|
||||||
|
setShaderCode(shaderData.code);
|
||||||
|
|
||||||
|
// Apply saved settings if they exist
|
||||||
|
const newSettings: any = {};
|
||||||
|
if (shaderData.resolution) newSettings.resolution = shaderData.resolution;
|
||||||
|
if (shaderData.fps) newSettings.fps = shaderData.fps;
|
||||||
|
if (shaderData.renderMode) newSettings.renderMode = shaderData.renderMode;
|
||||||
|
if (shaderData.valueMode) newSettings.valueMode = shaderData.valueMode;
|
||||||
|
if (shaderData.uiOpacity !== undefined)
|
||||||
|
newSettings.uiOpacity = shaderData.uiOpacity;
|
||||||
|
|
||||||
|
if (Object.keys(newSettings).length > 0) {
|
||||||
|
updateAppSettings(newSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateShaderUsage(shaderId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteShader = (shaderId: string) => {
|
||||||
|
deleteShader(shaderId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRename = (shaderId: string) => {
|
||||||
|
const shaderData = library.shaders.find((s) => s.id === shaderId);
|
||||||
|
if (shaderData) {
|
||||||
|
setRenamingId(shaderId);
|
||||||
|
setRenameValue(shaderData.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishRename = () => {
|
||||||
|
if (renamingId && renameValue.trim()) {
|
||||||
|
renameShader(renamingId, renameValue.trim());
|
||||||
|
}
|
||||||
|
setRenamingId(null);
|
||||||
|
setRenameValue('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRenameKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
finishRename();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setRenamingId(null);
|
||||||
|
setRenameValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// const escapeHtml = (text: string): string => { // Unused for now
|
||||||
|
// const div = document.createElement('div');
|
||||||
|
// div.textContent = text;
|
||||||
|
// return div.innerHTML;
|
||||||
|
// };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
id="shader-library-trigger"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '40px',
|
||||||
|
left: '0',
|
||||||
|
width: '20px',
|
||||||
|
height: 'calc(100vh - 40px)',
|
||||||
|
zIndex: 91,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={toggleShaderLibrary}
|
||||||
|
></div>
|
||||||
|
<div id="shader-library" className={ui.shaderLibraryOpen ? 'open' : ''}>
|
||||||
|
<div className="library-header">
|
||||||
|
<h3>Shader Library</h3>
|
||||||
|
<div className="save-shader">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shaderName}
|
||||||
|
onChange={(e) => setShaderName(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Shader name..."
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
<button onClick={handleSaveShader}>Save</button>
|
||||||
|
</div>
|
||||||
|
<div className="search-shader">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={library.searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="Search shaders..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shader-list">
|
||||||
|
{filteredShaders.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '20px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#666',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{library.searchTerm
|
||||||
|
? 'No shaders match your search'
|
||||||
|
: 'No saved shaders'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredShaders.map((shaderData) => {
|
||||||
|
const hasSettings =
|
||||||
|
shaderData.resolution ||
|
||||||
|
shaderData.fps ||
|
||||||
|
shaderData.renderMode ||
|
||||||
|
shaderData.valueMode ||
|
||||||
|
shaderData.uiOpacity !== undefined;
|
||||||
|
const settingsIndicator = hasSettings ? ' ⚙' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={shaderData.id} className="shader-item">
|
||||||
|
<div
|
||||||
|
className="shader-item-header"
|
||||||
|
onClick={() => handleLoadShader(shaderData.id)}
|
||||||
|
>
|
||||||
|
<span className="shader-name">
|
||||||
|
{renamingId === shaderData.id ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onBlur={finishRename}
|
||||||
|
onKeyDown={handleRenameKeyDown}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
border: '1px solid #666',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '2px 4px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{shaderData.name}
|
||||||
|
{hasSettings && (
|
||||||
|
<span
|
||||||
|
style={{ color: '#4A9EFF', fontSize: '10px' }}
|
||||||
|
title="Includes visual settings"
|
||||||
|
>
|
||||||
|
{settingsIndicator}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="shader-actions">
|
||||||
|
<button
|
||||||
|
className="shader-action rename"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startRename(shaderData.id);
|
||||||
|
}}
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="shader-action delete"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteShader(shaderData.id);
|
||||||
|
}}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
del
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shader-code">{shaderData.code}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
282
src/components/TopBar.tsx
Normal file
282
src/components/TopBar.tsx
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { $appSettings, updateAppSettings } from '../stores/appSettings';
|
||||||
|
import { VALUE_MODES, ValueMode } from '../utils/constants';
|
||||||
|
import {
|
||||||
|
uiState,
|
||||||
|
toggleMobileMenu,
|
||||||
|
showHelp,
|
||||||
|
toggleUI,
|
||||||
|
toggleShaderLibrary,
|
||||||
|
} from '../stores/ui';
|
||||||
|
import { $shader, setShaderCode } from '../stores/shader';
|
||||||
|
import { $input } from '../stores/input';
|
||||||
|
import { FakeShader } from '../FakeShader';
|
||||||
|
import { useAudio } from '../hooks/useAudio';
|
||||||
|
import { LucideIcon } from '../hooks/useLucideIcon';
|
||||||
|
|
||||||
|
function getValueModeLabel(mode: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
integer: 'Integer (0-255)',
|
||||||
|
float: 'Float (0.0-1.0)',
|
||||||
|
polar: 'Polar (angle-based)',
|
||||||
|
distance: 'Distance (radial)',
|
||||||
|
wave: 'Wave (ripple)',
|
||||||
|
fractal: 'Fractal (recursive)',
|
||||||
|
cellular: 'Cellular (automata)',
|
||||||
|
noise: 'Noise (perlin-like)',
|
||||||
|
warp: 'Warp (space deformation)',
|
||||||
|
flow: 'Flow (fluid dynamics)',
|
||||||
|
};
|
||||||
|
return labels[mode] || mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
const settings = useStore($appSettings);
|
||||||
|
const ui = useStore(uiState);
|
||||||
|
const shader = useStore($shader);
|
||||||
|
const input = useStore($input);
|
||||||
|
const { setupAudio, disableAudio } = useAudio();
|
||||||
|
|
||||||
|
const handleFullscreen = () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRandom = () => {
|
||||||
|
const randomCode = FakeShader.generateRandomCode();
|
||||||
|
setShaderCode(randomCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
const shareData = {
|
||||||
|
code: shader.code,
|
||||||
|
resolution: settings.resolution,
|
||||||
|
fps: settings.fps,
|
||||||
|
renderMode: settings.renderMode,
|
||||||
|
valueMode: settings.valueMode,
|
||||||
|
uiOpacity: settings.uiOpacity,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoded = btoa(JSON.stringify(shareData));
|
||||||
|
window.location.hash = encoded;
|
||||||
|
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(window.location.href)
|
||||||
|
.then(() => {
|
||||||
|
console.log('URL copied to clipboard');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log('Copy failed');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportPNG = () => {
|
||||||
|
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
|
||||||
|
if (canvas) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `bitfielder-${Date.now()}.png`;
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioToggle = async () => {
|
||||||
|
if (input.audioEnabled) {
|
||||||
|
disableAudio();
|
||||||
|
} else {
|
||||||
|
await setupAudio();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="topbar" className={ui.uiVisible ? '' : 'hidden'}>
|
||||||
|
<div className="title">Bitfielder</div>
|
||||||
|
<div className="controls">
|
||||||
|
<div className="controls-desktop">
|
||||||
|
<label
|
||||||
|
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
|
||||||
|
>
|
||||||
|
Resolution:
|
||||||
|
<select
|
||||||
|
value={settings.resolution}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAppSettings({ resolution: parseInt(e.target.value) })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid #555',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="1">Full (1x)</option>
|
||||||
|
<option value="2">Half (2x)</option>
|
||||||
|
<option value="4">Quarter (4x)</option>
|
||||||
|
<option value="8">Eighth (8x)</option>
|
||||||
|
<option value="16">Sixteenth (16x)</option>
|
||||||
|
<option value="32">Thirty-second (32x)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
|
||||||
|
>
|
||||||
|
FPS:
|
||||||
|
<select
|
||||||
|
value={settings.fps}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAppSettings({ fps: parseInt(e.target.value) })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid #555',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="15">15 FPS</option>
|
||||||
|
<option value="30">30 FPS</option>
|
||||||
|
<option value="60">60 FPS</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
|
||||||
|
>
|
||||||
|
Value Mode:
|
||||||
|
<select
|
||||||
|
value={settings.valueMode}
|
||||||
|
onChange={(e) => updateAppSettings({ valueMode: e.target.value as ValueMode })}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid #555',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{VALUE_MODES.map((mode) => (
|
||||||
|
<option key={mode} value={mode}>
|
||||||
|
{getValueModeLabel(mode)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
|
||||||
|
>
|
||||||
|
Render Mode:
|
||||||
|
<select
|
||||||
|
value={settings.renderMode}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAppSettings({ renderMode: e.target.value })
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
border: '1px solid #555',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="classic">Classic</option>
|
||||||
|
<option value="grayscale">Grayscale</option>
|
||||||
|
<option value="red">Red Channel</option>
|
||||||
|
<option value="green">Green Channel</option>
|
||||||
|
<option value="blue">Blue Channel</option>
|
||||||
|
<option value="rgb">RGB Split</option>
|
||||||
|
<option value="hsv">HSV</option>
|
||||||
|
<option value="rainbow">Rainbow</option>
|
||||||
|
<option value="thermal">Thermal</option>
|
||||||
|
<option value="neon">Neon</option>
|
||||||
|
<option value="cyberpunk">Cyberpunk</option>
|
||||||
|
<option value="vaporwave">Vaporwave</option>
|
||||||
|
<option value="dithered">Dithered</option>
|
||||||
|
<option value="palette">Palette</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label
|
||||||
|
style={{ color: '#ccc', fontSize: '12px', marginRight: '10px' }}
|
||||||
|
>
|
||||||
|
UI Opacity:
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
value={Math.round((settings.uiOpacity ?? 0.3) * 100)}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateAppSettings({ uiOpacity: parseInt(e.target.value) / 100 })
|
||||||
|
}
|
||||||
|
style={{ width: '80px', verticalAlign: 'middle' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '11px' }}>
|
||||||
|
{Math.round((settings.uiOpacity ?? 0.3) * 100)}%
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button id="help-btn" onClick={showHelp}>
|
||||||
|
<LucideIcon name="help" />
|
||||||
|
</button>
|
||||||
|
<button id="fullscreen-btn" onClick={handleFullscreen}>
|
||||||
|
<LucideIcon name="fullscreen" />
|
||||||
|
</button>
|
||||||
|
<button id="hide-ui-btn" onClick={toggleUI}>
|
||||||
|
<LucideIcon name="hide" />
|
||||||
|
</button>
|
||||||
|
<button id="random-btn" onClick={handleRandom}>
|
||||||
|
<LucideIcon name="random" />
|
||||||
|
</button>
|
||||||
|
<button id="audio-btn" onClick={handleAudioToggle}>
|
||||||
|
<LucideIcon
|
||||||
|
name={input.audioEnabled ? 'microphone' : 'microphone-off'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button id="share-btn" onClick={handleShare}>
|
||||||
|
<LucideIcon name="share" />
|
||||||
|
</button>
|
||||||
|
<button id="export-png-btn" onClick={handleExportPNG}>
|
||||||
|
<LucideIcon name="export" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls-mobile">
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Shader Library"
|
||||||
|
onClick={toggleShaderLibrary}
|
||||||
|
>
|
||||||
|
<LucideIcon name="library" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Random"
|
||||||
|
onClick={handleRandom}
|
||||||
|
>
|
||||||
|
<LucideIcon name="random" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Hide UI"
|
||||||
|
onClick={toggleUI}
|
||||||
|
>
|
||||||
|
<LucideIcon name="hide" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon-button"
|
||||||
|
aria-label="Menu"
|
||||||
|
onClick={toggleMobileMenu}
|
||||||
|
>
|
||||||
|
<LucideIcon name={ui.mobileMenuOpen ? 'close' : 'menu'} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/components/WelcomePopup.tsx
Normal file
60
src/components/WelcomePopup.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { uiState, hideWelcome } from '../stores/ui';
|
||||||
|
|
||||||
|
export const WelcomePopup: React.FC = () => {
|
||||||
|
const ui = useStore(uiState);
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
hideWelcome();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyPress = () => {
|
||||||
|
hideWelcome();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ui.welcomePopupOpen) {
|
||||||
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyPress);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [ui.welcomePopupOpen]);
|
||||||
|
|
||||||
|
if (!ui.welcomePopupOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="welcome-popup" onClick={handleBackdropClick}>
|
||||||
|
<div className="welcome-popup-content">
|
||||||
|
<h2 className="welcome-title">Welcome to BitFielder</h2>
|
||||||
|
|
||||||
|
<div className="welcome-content">
|
||||||
|
<p>BitFielder is an experimental lofi bitfield shader editor made by <a href="https://raphaelforment.fr">BuboBubo</a>. Use it to create visual compositions through code. I use it for fun :) </p>
|
||||||
|
|
||||||
|
<h3>Getting Started</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Edit the shader code and press <i>Eval</i> or <i>Ctrl+Enter</i></li>
|
||||||
|
<li>Use special variables to create reactive effects</li>
|
||||||
|
<li>Explore/store shaders in the library (left pane)</li>
|
||||||
|
<li>Export your creations as images or sharable links</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Key Features</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Real-time editing:</strong> See your changes instantly</li>
|
||||||
|
<li><strong>Motion and touch:</strong> Mouse, touchscreen support</li>
|
||||||
|
<li><strong>Audio reactive:</strong> Synchronize with a sound signal</li>
|
||||||
|
<li><strong>Export capabilities:</strong> Save and share your work</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p className="help-hint">Press <kbd>?</kbd> anytime to view keyboard shortcuts and detailed help.</p>
|
||||||
|
|
||||||
|
<p className="dismiss-hint">Press any key to dismiss this message</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
118
src/hooks/useAudio.ts
Normal file
118
src/hooks/useAudio.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { updateAudioData, setAudioEnabled } from '../stores/input';
|
||||||
|
|
||||||
|
export function useAudio() {
|
||||||
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||||
|
const microphoneRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const setupAudio = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
if (!window.AudioContext && !(window as any).webkitAudioContext) {
|
||||||
|
console.warn('Web Audio API not supported');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
|
||||||
|
audioContextRef.current = new (window.AudioContext ||
|
||||||
|
(window as any).webkitAudioContext)();
|
||||||
|
analyserRef.current = audioContextRef.current.createAnalyser();
|
||||||
|
analyserRef.current.fftSize = 256;
|
||||||
|
analyserRef.current.smoothingTimeConstant = 0.8;
|
||||||
|
|
||||||
|
microphoneRef.current =
|
||||||
|
audioContextRef.current.createMediaStreamSource(stream);
|
||||||
|
microphoneRef.current.connect(analyserRef.current);
|
||||||
|
|
||||||
|
setAudioEnabled(true);
|
||||||
|
startAudioAnalysis();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to setup audio:', error);
|
||||||
|
setAudioEnabled(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const disableAudio = useCallback(() => {
|
||||||
|
setAudioEnabled(false);
|
||||||
|
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (microphoneRef.current) {
|
||||||
|
microphoneRef.current.disconnect();
|
||||||
|
microphoneRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
audioContextRef.current.close();
|
||||||
|
audioContextRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
analyserRef.current = null;
|
||||||
|
|
||||||
|
// Reset audio levels
|
||||||
|
updateAudioData(0, 0, 0, 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startAudioAnalysis = useCallback(() => {
|
||||||
|
if (!analyserRef.current) return;
|
||||||
|
|
||||||
|
const bufferLength = analyserRef.current.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
const analyze = () => {
|
||||||
|
if (!analyserRef.current) return;
|
||||||
|
|
||||||
|
analyserRef.current.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
// Calculate overall audio level (RMS)
|
||||||
|
let sum = 0;
|
||||||
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
|
sum += dataArray[i] * dataArray[i];
|
||||||
|
}
|
||||||
|
const audioLevel = Math.sqrt(sum / bufferLength) / 255;
|
||||||
|
|
||||||
|
// Calculate frequency bands
|
||||||
|
const lowEnd = Math.floor(bufferLength * 0.08);
|
||||||
|
const midEnd = Math.floor(bufferLength * 0.67);
|
||||||
|
|
||||||
|
// Bass (low frequencies)
|
||||||
|
let bassSum = 0;
|
||||||
|
for (let i = 0; i < lowEnd; i++) {
|
||||||
|
bassSum += dataArray[i];
|
||||||
|
}
|
||||||
|
const bassLevel = bassSum / lowEnd / 255;
|
||||||
|
|
||||||
|
// Mid frequencies
|
||||||
|
let midSum = 0;
|
||||||
|
for (let i = lowEnd; i < midEnd; i++) {
|
||||||
|
midSum += dataArray[i];
|
||||||
|
}
|
||||||
|
const midLevel = midSum / (midEnd - lowEnd) / 255;
|
||||||
|
|
||||||
|
// Treble (high frequencies)
|
||||||
|
let trebleSum = 0;
|
||||||
|
for (let i = midEnd; i < bufferLength; i++) {
|
||||||
|
trebleSum += dataArray[i];
|
||||||
|
}
|
||||||
|
const trebleLevel = trebleSum / (bufferLength - midEnd) / 255;
|
||||||
|
|
||||||
|
updateAudioData(audioLevel, bassLevel, midLevel, trebleLevel);
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(analyze);
|
||||||
|
};
|
||||||
|
|
||||||
|
analyze();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setupAudio,
|
||||||
|
disableAudio,
|
||||||
|
};
|
||||||
|
}
|
||||||
77
src/hooks/useKeyboardShortcuts.ts
Normal file
77
src/hooks/useKeyboardShortcuts.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { uiState } from '../stores/ui';
|
||||||
|
import { $shader, setShaderCode } from '../stores/shader';
|
||||||
|
import { $appSettings, cycleValueMode } from '../stores/appSettings';
|
||||||
|
import { FakeShader } from '../FakeShader';
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts() {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
const isInputFocused = activeElement?.matches('input, textarea, select');
|
||||||
|
|
||||||
|
if (e.key === 'F11') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
document.documentElement.requestFullscreen();
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
}
|
||||||
|
} else if (!isInputFocused) {
|
||||||
|
if (e.key === 'h' || e.key === 'H') {
|
||||||
|
const ui = uiState.get();
|
||||||
|
uiState.set({ ...ui, uiVisible: !ui.uiVisible });
|
||||||
|
} else if (e.key === 'r' || e.key === 'R') {
|
||||||
|
const randomCode = FakeShader.generateRandomCode();
|
||||||
|
setShaderCode(randomCode);
|
||||||
|
} else if (e.key === 's' || e.key === 'S') {
|
||||||
|
shareURL();
|
||||||
|
} else if (e.key === 'm' || e.key === 'M') {
|
||||||
|
cycleValueMode();
|
||||||
|
} else if (e.key === '?') {
|
||||||
|
const ui = uiState.get();
|
||||||
|
uiState.set({ ...ui, helpPopupOpen: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const ui = uiState.get();
|
||||||
|
uiState.set({
|
||||||
|
...ui,
|
||||||
|
helpPopupOpen: false,
|
||||||
|
uiVisible: true,
|
||||||
|
mobileMenuOpen: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareURL() {
|
||||||
|
const shader = $shader.get();
|
||||||
|
const settings = $appSettings.get();
|
||||||
|
|
||||||
|
const shareData = {
|
||||||
|
code: shader.code,
|
||||||
|
resolution: settings.resolution,
|
||||||
|
fps: settings.fps,
|
||||||
|
renderMode: settings.renderMode,
|
||||||
|
valueMode: settings.valueMode,
|
||||||
|
uiOpacity: settings.uiOpacity,
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoded = btoa(JSON.stringify(shareData));
|
||||||
|
window.location.hash = encoded;
|
||||||
|
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(window.location.href)
|
||||||
|
.then(() => {
|
||||||
|
console.log('URL copied to clipboard');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log('Copy failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
59
src/hooks/useLucideIcon.tsx
Normal file
59
src/hooks/useLucideIcon.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { createIcons, icons } from 'lucide';
|
||||||
|
|
||||||
|
export function useLucideIcon(
|
||||||
|
iconName: string,
|
||||||
|
size: number = 16
|
||||||
|
): React.RefObject<HTMLElement | null> {
|
||||||
|
const iconRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (iconRef.current) {
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
menu: 'menu',
|
||||||
|
close: 'x',
|
||||||
|
help: 'help-circle',
|
||||||
|
fullscreen: 'maximize-2',
|
||||||
|
show: 'eye',
|
||||||
|
hide: 'eye-off',
|
||||||
|
random: 'dice-3',
|
||||||
|
share: 'share-2',
|
||||||
|
export: 'download',
|
||||||
|
play: 'play',
|
||||||
|
settings: 'settings',
|
||||||
|
resolution: 'monitor',
|
||||||
|
fps: 'zap',
|
||||||
|
palette: 'palette',
|
||||||
|
library: 'book-open',
|
||||||
|
microphone: 'mic',
|
||||||
|
'microphone-off': 'mic-off',
|
||||||
|
};
|
||||||
|
|
||||||
|
const lucideIconName = iconMap[iconName] || iconName;
|
||||||
|
iconRef.current.setAttribute('data-lucide', lucideIconName);
|
||||||
|
iconRef.current.setAttribute('width', size.toString());
|
||||||
|
iconRef.current.setAttribute('height', size.toString());
|
||||||
|
iconRef.current.setAttribute('stroke-width', '2');
|
||||||
|
|
||||||
|
// Initialize the specific icon
|
||||||
|
createIcons({ icons });
|
||||||
|
}
|
||||||
|
}, [iconName, size]);
|
||||||
|
|
||||||
|
return iconRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component for rendering Lucide icons in React
|
||||||
|
export function LucideIcon({
|
||||||
|
name,
|
||||||
|
size = 16,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const iconRef = useLucideIcon(name, size);
|
||||||
|
|
||||||
|
return <i ref={iconRef} className={className} />;
|
||||||
|
}
|
||||||
36
src/icons.ts
36
src/icons.ts
@ -2,21 +2,21 @@ import { createIcons, icons } from 'lucide';
|
|||||||
|
|
||||||
export function createIcon(name: string, size: number = 16): string {
|
export function createIcon(name: string, size: number = 16): string {
|
||||||
const iconMap: Record<string, string> = {
|
const iconMap: Record<string, string> = {
|
||||||
'menu': 'menu',
|
menu: 'menu',
|
||||||
'close': 'x',
|
close: 'x',
|
||||||
'help': 'help-circle',
|
help: 'help-circle',
|
||||||
'fullscreen': 'maximize-2',
|
fullscreen: 'maximize-2',
|
||||||
'show': 'eye',
|
show: 'eye',
|
||||||
'hide': 'eye-off',
|
hide: 'eye-off',
|
||||||
'random': 'dice-3',
|
random: 'dice-3',
|
||||||
'share': 'share-2',
|
share: 'share-2',
|
||||||
'export': 'download',
|
export: 'download',
|
||||||
'play': 'play',
|
play: 'play',
|
||||||
'settings': 'settings',
|
settings: 'settings',
|
||||||
'resolution': 'monitor',
|
resolution: 'monitor',
|
||||||
'fps': 'zap',
|
fps: 'zap',
|
||||||
'palette': 'palette',
|
palette: 'palette',
|
||||||
'library': 'book-open'
|
library: 'book-open',
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconName = iconMap[name];
|
const iconName = iconMap[name];
|
||||||
@ -25,7 +25,11 @@ export function createIcon(name: string, size: number = 16): string {
|
|||||||
return `<i data-lucide="${iconName}" width="${size}" height="${size}" stroke-width="2"></i>`;
|
return `<i data-lucide="${iconName}" width="${size}" height="${size}" stroke-width="2"></i>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addIconToButton(button: HTMLElement, iconName: string, keepText: boolean = false): void {
|
export function addIconToButton(
|
||||||
|
button: HTMLElement,
|
||||||
|
iconName: string,
|
||||||
|
keepText: boolean = false
|
||||||
|
): void {
|
||||||
const originalText = button.textContent || '';
|
const originalText = button.textContent || '';
|
||||||
const iconHtml = createIcon(iconName);
|
const iconHtml = createIcon(iconName);
|
||||||
|
|
||||||
|
|||||||
1035
src/main.ts
1035
src/main.ts
File diff suppressed because it is too large
Load Diff
66
src/main.tsx
Normal file
66
src/main.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './components/App';
|
||||||
|
import { Storage } from './Storage';
|
||||||
|
import { $appSettings } from './stores/appSettings';
|
||||||
|
import { setShaderCode } from './stores/shader';
|
||||||
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||||
|
|
||||||
|
// Load initial state from storage
|
||||||
|
const savedSettings = Storage.getSettings();
|
||||||
|
$appSettings.set(savedSettings);
|
||||||
|
|
||||||
|
// Load URL hash if present
|
||||||
|
function loadFromURL() {
|
||||||
|
if (window.location.hash) {
|
||||||
|
try {
|
||||||
|
const decoded = atob(window.location.hash.substring(1));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shareData = JSON.parse(decoded);
|
||||||
|
setShaderCode(shareData.code);
|
||||||
|
$appSettings.set({
|
||||||
|
resolution: shareData.resolution || savedSettings.resolution,
|
||||||
|
fps: shareData.fps || savedSettings.fps,
|
||||||
|
renderMode: shareData.renderMode || savedSettings.renderMode,
|
||||||
|
valueMode: shareData.valueMode || savedSettings.valueMode,
|
||||||
|
uiOpacity:
|
||||||
|
shareData.uiOpacity !== undefined
|
||||||
|
? shareData.uiOpacity
|
||||||
|
: savedSettings.uiOpacity,
|
||||||
|
});
|
||||||
|
} catch (jsonError) {
|
||||||
|
// Fall back to old format (just code as string)
|
||||||
|
setShaderCode(decoded);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to decode URL hash:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Load last shader code if no URL hash
|
||||||
|
setShaderCode(savedSettings.lastShaderCode ?? 'x^y');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main App component that includes keyboard shortcuts
|
||||||
|
function AppWithShortcuts() {
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
return <App />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up hash change listener
|
||||||
|
window.addEventListener('hashchange', loadFromURL);
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
loadFromURL();
|
||||||
|
|
||||||
|
// Mount React app
|
||||||
|
const container = document.getElementById('app');
|
||||||
|
if (!container) {
|
||||||
|
// Create app container if it doesn't exist
|
||||||
|
const appDiv = document.createElement('div');
|
||||||
|
appDiv.id = 'app';
|
||||||
|
document.body.appendChild(appDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = createRoot(container || document.getElementById('app')!);
|
||||||
|
root.render(<AppWithShortcuts />);
|
||||||
39
src/stores/appSettings.ts
Normal file
39
src/stores/appSettings.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
import { DEFAULTS, VALUE_MODES, ValueMode } from '../utils/constants';
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
resolution: number;
|
||||||
|
fps: number;
|
||||||
|
renderMode: string;
|
||||||
|
valueMode?: ValueMode;
|
||||||
|
uiOpacity?: number;
|
||||||
|
lastShaderCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultSettings: AppSettings = {
|
||||||
|
resolution: DEFAULTS.RESOLUTION,
|
||||||
|
fps: DEFAULTS.FPS,
|
||||||
|
renderMode: DEFAULTS.RENDER_MODE,
|
||||||
|
valueMode: DEFAULTS.VALUE_MODE,
|
||||||
|
uiOpacity: DEFAULTS.UI_OPACITY,
|
||||||
|
lastShaderCode: DEFAULTS.SHADER_CODE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const $appSettings = atom<AppSettings>(defaultSettings);
|
||||||
|
|
||||||
|
export function updateAppSettings(settings: Partial<AppSettings>) {
|
||||||
|
$appSettings.set({ ...$appSettings.get(), ...settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cycleValueMode() {
|
||||||
|
const currentSettings = $appSettings.get();
|
||||||
|
const currentMode = currentSettings.valueMode || DEFAULTS.VALUE_MODE;
|
||||||
|
const currentIndex = VALUE_MODES.indexOf(currentMode);
|
||||||
|
const nextIndex = (currentIndex + 1) % VALUE_MODES.length;
|
||||||
|
const nextMode = VALUE_MODES[nextIndex];
|
||||||
|
|
||||||
|
updateAppSettings({ valueMode: nextMode });
|
||||||
|
|
||||||
|
// Return the new mode for UI feedback
|
||||||
|
return nextMode;
|
||||||
|
}
|
||||||
135
src/stores/input.ts
Normal file
135
src/stores/input.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
|
export interface InputState {
|
||||||
|
mouseX: number;
|
||||||
|
mouseY: number;
|
||||||
|
mousePressed: boolean;
|
||||||
|
mouseVX: number;
|
||||||
|
mouseVY: number;
|
||||||
|
mouseClickTime: number;
|
||||||
|
touchCount: number;
|
||||||
|
touch0X: number;
|
||||||
|
touch0Y: number;
|
||||||
|
touch1X: number;
|
||||||
|
touch1Y: number;
|
||||||
|
pinchScale: number;
|
||||||
|
pinchRotation: number;
|
||||||
|
accelX: number;
|
||||||
|
accelY: number;
|
||||||
|
accelZ: number;
|
||||||
|
gyroX: number;
|
||||||
|
gyroY: number;
|
||||||
|
gyroZ: number;
|
||||||
|
audioLevel: number;
|
||||||
|
bassLevel: number;
|
||||||
|
midLevel: number;
|
||||||
|
trebleLevel: number;
|
||||||
|
audioEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultInputState: InputState = {
|
||||||
|
mouseX: 0,
|
||||||
|
mouseY: 0,
|
||||||
|
mousePressed: false,
|
||||||
|
mouseVX: 0,
|
||||||
|
mouseVY: 0,
|
||||||
|
mouseClickTime: 0,
|
||||||
|
touchCount: 0,
|
||||||
|
touch0X: 0,
|
||||||
|
touch0Y: 0,
|
||||||
|
touch1X: 0,
|
||||||
|
touch1Y: 0,
|
||||||
|
pinchScale: 1,
|
||||||
|
pinchRotation: 0,
|
||||||
|
accelX: 0,
|
||||||
|
accelY: 0,
|
||||||
|
accelZ: 0,
|
||||||
|
gyroX: 0,
|
||||||
|
gyroY: 0,
|
||||||
|
gyroZ: 0,
|
||||||
|
audioLevel: 0,
|
||||||
|
bassLevel: 0,
|
||||||
|
midLevel: 0,
|
||||||
|
trebleLevel: 0,
|
||||||
|
audioEnabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const $input = atom<InputState>(defaultInputState);
|
||||||
|
|
||||||
|
export function updateMousePosition(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
pressed: boolean,
|
||||||
|
vx: number,
|
||||||
|
vy: number,
|
||||||
|
clickTime: number
|
||||||
|
) {
|
||||||
|
$input.set({
|
||||||
|
...$input.get(),
|
||||||
|
mouseX: x,
|
||||||
|
mouseY: y,
|
||||||
|
mousePressed: pressed,
|
||||||
|
mouseVX: vx,
|
||||||
|
mouseVY: vy,
|
||||||
|
mouseClickTime: clickTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTouchPosition(
|
||||||
|
count: number,
|
||||||
|
x0: number,
|
||||||
|
y0: number,
|
||||||
|
x1: number,
|
||||||
|
y1: number,
|
||||||
|
scale: number,
|
||||||
|
rotation: number
|
||||||
|
) {
|
||||||
|
$input.set({
|
||||||
|
...$input.get(),
|
||||||
|
touchCount: count,
|
||||||
|
touch0X: x0,
|
||||||
|
touch0Y: y0,
|
||||||
|
touch1X: x1,
|
||||||
|
touch1Y: y1,
|
||||||
|
pinchScale: scale,
|
||||||
|
pinchRotation: rotation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDeviceMotion(
|
||||||
|
ax: number,
|
||||||
|
ay: number,
|
||||||
|
az: number,
|
||||||
|
gx: number,
|
||||||
|
gy: number,
|
||||||
|
gz: number
|
||||||
|
) {
|
||||||
|
$input.set({
|
||||||
|
...$input.get(),
|
||||||
|
accelX: ax,
|
||||||
|
accelY: ay,
|
||||||
|
accelZ: az,
|
||||||
|
gyroX: gx,
|
||||||
|
gyroY: gy,
|
||||||
|
gyroZ: gz,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAudioData(
|
||||||
|
level: number,
|
||||||
|
bass: number,
|
||||||
|
mid: number,
|
||||||
|
treble: number
|
||||||
|
) {
|
||||||
|
$input.set({
|
||||||
|
...$input.get(),
|
||||||
|
audioLevel: level,
|
||||||
|
bassLevel: bass,
|
||||||
|
midLevel: mid,
|
||||||
|
trebleLevel: treble,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAudioEnabled(enabled: boolean) {
|
||||||
|
$input.set({ ...$input.get(), audioEnabled: enabled });
|
||||||
|
}
|
||||||
59
src/stores/library.ts
Normal file
59
src/stores/library.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
import { Storage, SavedShader } from '../Storage';
|
||||||
|
|
||||||
|
export interface LibraryState {
|
||||||
|
shaders: SavedShader[];
|
||||||
|
searchTerm: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultLibraryState: LibraryState = {
|
||||||
|
shaders: [],
|
||||||
|
searchTerm: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const $library = atom<LibraryState>(defaultLibraryState);
|
||||||
|
|
||||||
|
export function loadShaders() {
|
||||||
|
const shaders = Storage.getShaders();
|
||||||
|
$library.set({ ...$library.get(), shaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveShader(name: string, code: string, settings?: any) {
|
||||||
|
const shader = Storage.saveShader(name, code, settings);
|
||||||
|
loadShaders(); // Reload to get updated list
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteShader(id: string) {
|
||||||
|
Storage.deleteShader(id);
|
||||||
|
loadShaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameShader(id: string, newName: string) {
|
||||||
|
Storage.renameShader(id, newName);
|
||||||
|
loadShaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateShaderUsage(id: string) {
|
||||||
|
Storage.updateShaderUsage(id);
|
||||||
|
loadShaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSearchTerm(term: string) {
|
||||||
|
$library.set({ ...$library.get(), searchTerm: term });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFilteredShaders(): SavedShader[] {
|
||||||
|
const { shaders, searchTerm } = $library.get();
|
||||||
|
|
||||||
|
if (!searchTerm.trim()) {
|
||||||
|
return shaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return shaders.filter(
|
||||||
|
(shader) =>
|
||||||
|
shader.name.toLowerCase().includes(term) ||
|
||||||
|
shader.code.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/stores/shader.ts
Normal file
33
src/stores/shader.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
|
export interface ShaderState {
|
||||||
|
code: string;
|
||||||
|
isCompiled: boolean;
|
||||||
|
isAnimating: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultShaderState: ShaderState = {
|
||||||
|
code: 'x^y',
|
||||||
|
isCompiled: false,
|
||||||
|
isAnimating: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const $shader = atom<ShaderState>(defaultShaderState);
|
||||||
|
|
||||||
|
export function setShaderCode(code: string) {
|
||||||
|
$shader.set({ ...$shader.get(), code, isCompiled: false, error: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setShaderCompiled(isCompiled: boolean, error?: string) {
|
||||||
|
$shader.set({
|
||||||
|
...$shader.get(),
|
||||||
|
isCompiled,
|
||||||
|
error: error || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setShaderAnimating(isAnimating: boolean) {
|
||||||
|
$shader.set({ ...$shader.get(), isAnimating });
|
||||||
|
}
|
||||||
64
src/stores/ui.ts
Normal file
64
src/stores/ui.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
|
export interface UIState {
|
||||||
|
mobileMenuOpen: boolean;
|
||||||
|
helpPopupOpen: boolean;
|
||||||
|
shaderLibraryOpen: boolean;
|
||||||
|
uiVisible: boolean;
|
||||||
|
performanceWarningVisible: boolean;
|
||||||
|
welcomePopupOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultUIState: UIState = {
|
||||||
|
mobileMenuOpen: false,
|
||||||
|
helpPopupOpen: false,
|
||||||
|
shaderLibraryOpen: false,
|
||||||
|
uiVisible: true,
|
||||||
|
performanceWarningVisible: false,
|
||||||
|
welcomePopupOpen: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uiState = atom<UIState>(defaultUIState);
|
||||||
|
|
||||||
|
export function toggleMobileMenu() {
|
||||||
|
uiState.set({ ...uiState.get(), mobileMenuOpen: !uiState.get().mobileMenuOpen });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeMobileMenu() {
|
||||||
|
uiState.set({ ...uiState.get(), mobileMenuOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showHelp() {
|
||||||
|
uiState.set({ ...uiState.get(), helpPopupOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideHelp() {
|
||||||
|
uiState.set({ ...uiState.get(), helpPopupOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleShaderLibrary() {
|
||||||
|
uiState.set({ ...uiState.get(), shaderLibraryOpen: !uiState.get().shaderLibraryOpen });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleUI() {
|
||||||
|
uiState.set({ ...uiState.get(), uiVisible: !uiState.get().uiVisible });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showUI() {
|
||||||
|
uiState.set({ ...uiState.get(), uiVisible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showPerformanceWarning() {
|
||||||
|
uiState.set({ ...uiState.get(), performanceWarningVisible: true });
|
||||||
|
setTimeout(() => {
|
||||||
|
uiState.set({ ...uiState.get(), performanceWarningVisible: false });
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showWelcome() {
|
||||||
|
uiState.set({ ...uiState.get(), welcomePopupOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideWelcome() {
|
||||||
|
uiState.set({ ...uiState.get(), welcomePopupOpen: false });
|
||||||
|
}
|
||||||
854
src/styles/main.css
Normal file
854
src/styles/main.css
Normal file
@ -0,0 +1,854 @@
|
|||||||
|
:root {
|
||||||
|
--ui-opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
touch-action: manipulation; /* Allow pan and zoom but disable double-tap zoom */
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #ff9500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #ffb143;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: #ff9500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
image-rendering: -moz-crisp-edges;
|
||||||
|
image-rendering: crisp-edges;
|
||||||
|
touch-action: none; /* Disable all touch gestures on canvas for shader interaction */
|
||||||
|
pointer-events: auto; /* Allow canvas interactions */
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: auto; /* Ensure topbar can be clicked */
|
||||||
|
touch-action: manipulation; /* Allow normal touch interactions */
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar .title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar .controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar .controls-desktop {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar .controls-mobile {
|
||||||
|
display: none;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hamburger-menu {
|
||||||
|
display: none;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #fff;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hamburger-menu:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#hamburger-menu svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-menu {
|
||||||
|
position: fixed;
|
||||||
|
top: 40px;
|
||||||
|
right: -320px;
|
||||||
|
width: 320px;
|
||||||
|
max-width: 80vw;
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
z-index: 150;
|
||||||
|
transition: right 0.3s ease;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
pointer-events: auto; /* Ensure mobile menu can be clicked */
|
||||||
|
touch-action: manipulation; /* Allow normal touch interactions */
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-menu.open {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-menu h3 {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-item {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-item label {
|
||||||
|
display: block;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-item select,
|
||||||
|
.mobile-menu-item input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-buttons button {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-buttons button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-menu-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 149;
|
||||||
|
pointer-events: none; /* Don't block clicks when hidden */
|
||||||
|
}
|
||||||
|
|
||||||
|
#mobile-menu-overlay.open {
|
||||||
|
display: block;
|
||||||
|
pointer-events: auto; /* Only block clicks when visible */
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar button {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lucide icon styles */
|
||||||
|
[data-lucide] {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
button svg {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure all button contents don't intercept clicks */
|
||||||
|
button *,
|
||||||
|
button svg,
|
||||||
|
button [data-lucide] {
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 140px;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
z-index: 100;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
pointer-events: auto; /* Ensure editor panel can be clicked */
|
||||||
|
touch-action: manipulation; /* Allow normal touch interactions */
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-panel.minimal {
|
||||||
|
height: 50px;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 15px;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
touch-action: manipulation; /* Allow normal touch interactions for text editing */
|
||||||
|
}
|
||||||
|
|
||||||
|
#eval-btn {
|
||||||
|
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
padding: 20px 30px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eval-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#eval-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor.minimal {
|
||||||
|
padding: 12px 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#eval-btn.minimal {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(0, 0, 0, 0.95);
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 30px;
|
||||||
|
z-index: 1000;
|
||||||
|
max-width: 90vw;
|
||||||
|
width: 800px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: none;
|
||||||
|
pointer-events: auto; /* Ensure help popup can be clicked */
|
||||||
|
touch-action: manipulation; /* Allow normal touch interactions */
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup h3 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup .help-section {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup .help-section h4 {
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup .help-section p {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup .close-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 15px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #999;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Welcome Popup */
|
||||||
|
.welcome-popup {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-popup-content {
|
||||||
|
position: relative;
|
||||||
|
background: rgba(20, 20, 20, 0.95);
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content p {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content h3 {
|
||||||
|
color: #fff;
|
||||||
|
margin-top: 25px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content ul {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content li {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-left: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content li:before {
|
||||||
|
content: "▸";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content strong {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-hint {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: rgba(52, 152, 219, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 3px solid #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-hint kbd {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dismiss-hint {
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#show-ui-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#show-ui-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#shader-library {
|
||||||
|
position: fixed;
|
||||||
|
top: 40px;
|
||||||
|
left: -300px;
|
||||||
|
width: 300px;
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
background: rgba(0, 0, 0, calc(var(--ui-opacity) + 0.1));
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
z-index: 90;
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
overflow-y: auto;
|
||||||
|
pointer-events: auto; /* Ensure shader library can be clicked */
|
||||||
|
touch-action: manipulation; /* Allow normal touch interactions */
|
||||||
|
}
|
||||||
|
|
||||||
|
#shader-library-trigger {
|
||||||
|
position: fixed;
|
||||||
|
top: 40px;
|
||||||
|
left: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
z-index: 91;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shader-library-trigger:hover + #shader-library,
|
||||||
|
#shader-library:hover {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shader-library.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-header h3 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-shader {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-shader {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-shader input {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-shader input::placeholder {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-shader input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-shader button {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #fff;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-shader button:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-list {
|
||||||
|
padding: 0 20px 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-item {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-item-header {
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-item-header:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-name {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-action {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
color: #ccc;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-action:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-action.rename {
|
||||||
|
background: rgba(52, 152, 219, 0.3);
|
||||||
|
border-color: rgba(52, 152, 219, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-action.rename:hover {
|
||||||
|
background: rgba(52, 152, 219, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-action.delete {
|
||||||
|
background: rgba(231, 76, 60, 0.3);
|
||||||
|
border-color: rgba(231, 76, 60, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-action.delete:hover {
|
||||||
|
background: rgba(231, 76, 60, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shader-code {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(0, 0, 0, var(--ui-opacity));
|
||||||
|
color: #ccc;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
#performance-warning {
|
||||||
|
position: fixed;
|
||||||
|
top: 50px;
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(255, 0, 0, 0.8);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 1001;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#topbar .controls {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar .controls-desktop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar .controls-mobile {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hamburger-menu {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar {
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar .title {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar .controls {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar label {
|
||||||
|
font-size: 11px !important;
|
||||||
|
margin-right: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar select {
|
||||||
|
padding: 2px !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup {
|
||||||
|
width: 95vw;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-panel {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#shader-library {
|
||||||
|
width: 100%;
|
||||||
|
left: -100%;
|
||||||
|
top: 40px;
|
||||||
|
height: calc(100vh - 40px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#shader-library-trigger {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#topbar {
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar .title {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar button {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar label {
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar select {
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup .help-section h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#help-popup .help-section p {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-popup-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-content h3 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-panel {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.help-content {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/utils/LRUCache.ts
Normal file
72
src/utils/LRUCache.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
export class LRUCache<K, V> {
|
||||||
|
private maxSize: number;
|
||||||
|
private cache: Map<K, V>;
|
||||||
|
private accessOrder: K[];
|
||||||
|
|
||||||
|
constructor(maxSize: number) {
|
||||||
|
this.maxSize = maxSize;
|
||||||
|
this.cache = new Map();
|
||||||
|
this.accessOrder = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: K): V | undefined {
|
||||||
|
const value = this.cache.get(key);
|
||||||
|
if (value !== undefined) {
|
||||||
|
this.markAsUsed(key);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: K, value: V): void {
|
||||||
|
if (this.cache.has(key)) {
|
||||||
|
this.cache.set(key, value);
|
||||||
|
this.markAsUsed(key);
|
||||||
|
} else {
|
||||||
|
if (this.cache.size >= this.maxSize) {
|
||||||
|
this.evictLeastUsed();
|
||||||
|
}
|
||||||
|
this.cache.set(key, value);
|
||||||
|
this.accessOrder.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: K): boolean {
|
||||||
|
return this.cache.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: K): boolean {
|
||||||
|
if (this.cache.delete(key)) {
|
||||||
|
this.removeFromAccessOrder(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
this.accessOrder = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.cache.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private markAsUsed(key: K): void {
|
||||||
|
this.removeFromAccessOrder(key);
|
||||||
|
this.accessOrder.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeFromAccessOrder(key: K): void {
|
||||||
|
const index = this.accessOrder.indexOf(key);
|
||||||
|
if (index > -1) {
|
||||||
|
this.accessOrder.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private evictLeastUsed(): void {
|
||||||
|
if (this.accessOrder.length > 0) {
|
||||||
|
const leastUsed = this.accessOrder.shift()!;
|
||||||
|
this.cache.delete(leastUsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
src/utils/colorModes.ts
Normal file
205
src/utils/colorModes.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
export function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||||
|
const c = v * s;
|
||||||
|
const x = c * (1 - Math.abs(((h * 6) % 2) - 1));
|
||||||
|
const m = v - c;
|
||||||
|
|
||||||
|
let r = 0,
|
||||||
|
g = 0,
|
||||||
|
b = 0;
|
||||||
|
|
||||||
|
if (h < 1 / 6) {
|
||||||
|
r = c;
|
||||||
|
g = x;
|
||||||
|
b = 0;
|
||||||
|
} else if (h < 2 / 6) {
|
||||||
|
r = x;
|
||||||
|
g = c;
|
||||||
|
b = 0;
|
||||||
|
} else if (h < 3 / 6) {
|
||||||
|
r = 0;
|
||||||
|
g = c;
|
||||||
|
b = x;
|
||||||
|
} else if (h < 4 / 6) {
|
||||||
|
r = 0;
|
||||||
|
g = x;
|
||||||
|
b = c;
|
||||||
|
} else if (h < 5 / 6) {
|
||||||
|
r = x;
|
||||||
|
g = 0;
|
||||||
|
b = c;
|
||||||
|
} else {
|
||||||
|
r = c;
|
||||||
|
g = 0;
|
||||||
|
b = x;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Math.round((r + m) * 255),
|
||||||
|
Math.round((g + m) * 255),
|
||||||
|
Math.round((b + m) * 255),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rainbowColor(value: number): [number, number, number] {
|
||||||
|
const phase = (value / 255.0) * 6;
|
||||||
|
const segment = Math.floor(phase);
|
||||||
|
const remainder = phase - segment;
|
||||||
|
const t = remainder;
|
||||||
|
const q = 1 - t;
|
||||||
|
|
||||||
|
switch (segment % 6) {
|
||||||
|
case 0:
|
||||||
|
return [255, Math.round(t * 255), 0];
|
||||||
|
case 1:
|
||||||
|
return [Math.round(q * 255), 255, 0];
|
||||||
|
case 2:
|
||||||
|
return [0, 255, Math.round(t * 255)];
|
||||||
|
case 3:
|
||||||
|
return [0, Math.round(q * 255), 255];
|
||||||
|
case 4:
|
||||||
|
return [Math.round(t * 255), 0, 255];
|
||||||
|
case 5:
|
||||||
|
return [255, 0, Math.round(q * 255)];
|
||||||
|
default:
|
||||||
|
return [255, 255, 255];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function thermalColor(value: number): [number, number, number] {
|
||||||
|
const t = value / 255.0;
|
||||||
|
if (t < 0.25) {
|
||||||
|
return [0, 0, Math.round(t * 4 * 255)];
|
||||||
|
} else if (t < 0.5) {
|
||||||
|
return [0, Math.round((t - 0.25) * 4 * 255), 255];
|
||||||
|
} else if (t < 0.75) {
|
||||||
|
return [
|
||||||
|
Math.round((t - 0.5) * 4 * 255),
|
||||||
|
255,
|
||||||
|
Math.round((0.75 - t) * 4 * 255),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return [255, 255, Math.round((t - 0.75) * 4 * 255)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function neonColor(value: number): [number, number, number] {
|
||||||
|
const t = value / 255.0;
|
||||||
|
const intensity = Math.pow(Math.sin(t * Math.PI), 2);
|
||||||
|
const glow = Math.pow(intensity, 0.5);
|
||||||
|
return [
|
||||||
|
Math.round(glow * 255),
|
||||||
|
Math.round(intensity * 255),
|
||||||
|
Math.round(Math.pow(intensity, 2) * 255),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cyberpunkColor(value: number): [number, number, number] {
|
||||||
|
const t = value / 255.0;
|
||||||
|
const phase = (t * 3) % 1;
|
||||||
|
if (phase < 0.33) {
|
||||||
|
return [
|
||||||
|
Math.round(255 * (1 - phase * 3)),
|
||||||
|
0,
|
||||||
|
Math.round(255 * phase * 3),
|
||||||
|
];
|
||||||
|
} else if (phase < 0.67) {
|
||||||
|
const p = (phase - 0.33) * 3;
|
||||||
|
return [0, Math.round(255 * p), Math.round(255 * (1 - p))];
|
||||||
|
} else {
|
||||||
|
const p = (phase - 0.67) * 3;
|
||||||
|
return [Math.round(255 * p), Math.round(255 * (1 - p)), 255];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vaporwaveColor(value: number): [number, number, number] {
|
||||||
|
const t = value / 255.0;
|
||||||
|
const pink = Math.sin(t * Math.PI);
|
||||||
|
const purple = Math.sin(t * Math.PI * 0.7 + Math.PI / 3);
|
||||||
|
const blue = Math.sin(t * Math.PI * 0.5 + Math.PI / 2);
|
||||||
|
return [
|
||||||
|
Math.round(255 * (0.8 + 0.2 * pink)),
|
||||||
|
Math.round(255 * (0.3 + 0.7 * purple)),
|
||||||
|
Math.round(255 * (0.6 + 0.4 * blue)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ditheredColor(value: number): [number, number, number] {
|
||||||
|
const levels = 4;
|
||||||
|
const step = 255 / (levels - 1);
|
||||||
|
const quantized = Math.round(value / step) * step;
|
||||||
|
const error = value - quantized;
|
||||||
|
const dither = (Math.random() - 0.5) * 32;
|
||||||
|
const final = Math.max(0, Math.min(255, quantized + error + dither));
|
||||||
|
return [final, final, final];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paletteColor(value: number): [number, number, number] {
|
||||||
|
const palette = [
|
||||||
|
[0, 0, 0],
|
||||||
|
[87, 29, 149],
|
||||||
|
[191, 82, 177],
|
||||||
|
[249, 162, 162],
|
||||||
|
[255, 241, 165],
|
||||||
|
[134, 227, 206],
|
||||||
|
[29, 161, 242],
|
||||||
|
[255, 255, 255],
|
||||||
|
];
|
||||||
|
const index = Math.floor((value / 255.0) * (palette.length - 1));
|
||||||
|
return palette[index] as [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateColorDirect(
|
||||||
|
absValue: number,
|
||||||
|
renderMode: string
|
||||||
|
): [number, number, number] {
|
||||||
|
switch (renderMode) {
|
||||||
|
case 'classic':
|
||||||
|
return [absValue, (absValue * 2) % 256, (absValue * 3) % 256];
|
||||||
|
|
||||||
|
case 'grayscale':
|
||||||
|
return [absValue, absValue, absValue];
|
||||||
|
|
||||||
|
case 'red':
|
||||||
|
return [absValue, 0, 0];
|
||||||
|
|
||||||
|
case 'green':
|
||||||
|
return [0, absValue, 0];
|
||||||
|
|
||||||
|
case 'blue':
|
||||||
|
return [0, 0, absValue];
|
||||||
|
|
||||||
|
case 'rgb':
|
||||||
|
return [
|
||||||
|
((absValue * 255) / 256) | 0,
|
||||||
|
((((absValue * 2) % 256) * 255) / 256) | 0,
|
||||||
|
((((absValue * 3) % 256) * 255) / 256) | 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
case 'hsv':
|
||||||
|
return hsvToRgb(absValue / 255.0, 1.0, 1.0);
|
||||||
|
|
||||||
|
case 'rainbow':
|
||||||
|
return rainbowColor(absValue);
|
||||||
|
|
||||||
|
case 'thermal':
|
||||||
|
return thermalColor(absValue);
|
||||||
|
|
||||||
|
case 'neon':
|
||||||
|
return neonColor(absValue);
|
||||||
|
|
||||||
|
case 'cyberpunk':
|
||||||
|
return cyberpunkColor(absValue);
|
||||||
|
|
||||||
|
case 'vaporwave':
|
||||||
|
return vaporwaveColor(absValue);
|
||||||
|
|
||||||
|
case 'dithered':
|
||||||
|
return ditheredColor(absValue);
|
||||||
|
|
||||||
|
case 'palette':
|
||||||
|
return paletteColor(absValue);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [absValue, absValue, absValue];
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/utils/constants.ts
Normal file
52
src/utils/constants.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// UI Layout Constants
|
||||||
|
export const UI_HEIGHTS = {
|
||||||
|
TOP_BAR: 40,
|
||||||
|
EDITOR_PANEL: 140,
|
||||||
|
TOTAL_UI_HEIGHT: 180, // TOP_BAR + EDITOR_PANEL
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Performance Constants
|
||||||
|
export const PERFORMANCE = {
|
||||||
|
DEFAULT_TILE_SIZE: 64,
|
||||||
|
MAX_RENDER_TIME_MS: 50,
|
||||||
|
MAX_SHADER_TIMEOUT_MS: 5,
|
||||||
|
TIMEOUT_CHECK_INTERVAL: 1000,
|
||||||
|
MAX_SAVED_SHADERS: 50,
|
||||||
|
IMAGE_DATA_CACHE_SIZE: 5,
|
||||||
|
COMPILATION_CACHE_SIZE: 20,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Color Constants
|
||||||
|
export const COLOR_TABLE_SIZE = 256;
|
||||||
|
|
||||||
|
// Storage Keys
|
||||||
|
export const STORAGE_KEYS = {
|
||||||
|
SHADERS: 'bitfielder_shaders',
|
||||||
|
SETTINGS: 'bitfielder_settings',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Value Modes
|
||||||
|
export const VALUE_MODES = [
|
||||||
|
'integer',
|
||||||
|
'float',
|
||||||
|
'polar',
|
||||||
|
'distance',
|
||||||
|
'wave',
|
||||||
|
'fractal',
|
||||||
|
'cellular',
|
||||||
|
'noise',
|
||||||
|
'warp',
|
||||||
|
'flow'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ValueMode = typeof VALUE_MODES[number];
|
||||||
|
|
||||||
|
// Default Values
|
||||||
|
export const DEFAULTS = {
|
||||||
|
RESOLUTION: 1,
|
||||||
|
FPS: 30,
|
||||||
|
RENDER_MODE: 'classic',
|
||||||
|
VALUE_MODE: 'integer' as ValueMode,
|
||||||
|
UI_OPACITY: 0.3,
|
||||||
|
SHADER_CODE: 'x^y',
|
||||||
|
} as const;
|
||||||
@ -10,10 +10,11 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
@ -1,6 +1,8 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
base: './',
|
base: './',
|
||||||
server: {
|
server: {
|
||||||
port: 3000
|
port: 3000
|
||||||
|
|||||||
Reference in New Issue
Block a user