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
|
||||
- **F11** - Fullscreen
|
||||
- **R** - Random shader
|
||||
- **M** - Cycle value modes
|
||||
- **S** - Share URL
|
||||
|
||||
### Shader Variables
|
||||
|
||||
@ -4,3 +4,4 @@ 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 ** 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",
|
||||
"preview": "vite preview",
|
||||
"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": [
|
||||
"shader",
|
||||
@ -35,7 +36,15 @@
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"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-eslint": "^8.35.1",
|
||||
"vite": "^4.0.0"
|
||||
},
|
||||
"files": [
|
||||
@ -44,6 +53,13 @@
|
||||
"LICENSE"
|
||||
],
|
||||
"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 urlsToCache = [
|
||||
const CACHE_NAME = 'bitfielder-v2';
|
||||
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',
|
||||
'/src/main.ts',
|
||||
'/src/FakeShader.ts',
|
||||
'/src/ShaderWorker.ts',
|
||||
'/src/Storage.ts',
|
||||
'/src/icons.ts',
|
||||
'/manifest.json'
|
||||
'/manifest.json',
|
||||
'/icon-192.png',
|
||||
'/icon-512.png'
|
||||
];
|
||||
|
||||
// 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 => {
|
||||
console.log('Service Worker installing...');
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('Opened cache');
|
||||
return cache.addAll(urlsToCache);
|
||||
Promise.all([
|
||||
caches.open(STATIC_CACHE).then(cache => {
|
||||
console.log('Caching core assets');
|
||||
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
|
||||
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
|
||||
// Activate event - clean up old caches and take control
|
||||
self.addEventListener('activate', event => {
|
||||
console.log('Service Worker activating...');
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return Promise.all([
|
||||
...cacheNames.map(cacheName => {
|
||||
if (cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE) {
|
||||
console.log('Deleting old cache:', 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 });
|
||||
}
|
||||
});
|
||||
1066
src/FakeShader.ts
1066
src/FakeShader.ts
File diff suppressed because it is too large
Load Diff
1487
src/ShaderWorker.ts
1487
src/ShaderWorker.ts
File diff suppressed because it is too large
Load Diff
333
src/Storage.ts
333
src/Storage.ts
@ -1,177 +1,180 @@
|
||||
interface SavedShader {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
created: number;
|
||||
lastUsed: number;
|
||||
// Visual settings
|
||||
resolution?: number;
|
||||
fps?: number;
|
||||
renderMode?: string;
|
||||
valueMode?: string;
|
||||
uiOpacity?: number;
|
||||
}
|
||||
import { AppSettings } from './stores/appSettings';
|
||||
import { STORAGE_KEYS, PERFORMANCE, DEFAULTS, ValueMode } from './utils/constants';
|
||||
|
||||
interface AppSettings {
|
||||
resolution: number;
|
||||
fps: number;
|
||||
lastShaderCode: string;
|
||||
renderMode: string;
|
||||
valueMode?: string;
|
||||
uiOpacity?: number;
|
||||
export interface SavedShader {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
created: number;
|
||||
lastUsed: number;
|
||||
// Visual settings
|
||||
resolution?: number;
|
||||
fps?: number;
|
||||
renderMode?: string;
|
||||
valueMode?: ValueMode;
|
||||
uiOpacity?: number;
|
||||
}
|
||||
|
||||
export class Storage {
|
||||
private static readonly SHADERS_KEY = 'bitfielder_shaders';
|
||||
private static readonly SETTINGS_KEY = 'bitfielder_settings';
|
||||
|
||||
static saveShader(name: string, code: string, settings?: Partial<AppSettings>): SavedShader {
|
||||
const shaders = this.getShaders();
|
||||
const id = this.generateId();
|
||||
const timestamp = Date.now();
|
||||
|
||||
const shader: SavedShader = {
|
||||
id,
|
||||
name: name.trim() || `Shader ${shaders.length + 1}`,
|
||||
code,
|
||||
created: timestamp,
|
||||
lastUsed: timestamp,
|
||||
// Include settings if provided
|
||||
...(settings && {
|
||||
resolution: settings.resolution,
|
||||
fps: settings.fps,
|
||||
renderMode: settings.renderMode,
|
||||
valueMode: settings.valueMode,
|
||||
uiOpacity: settings.uiOpacity
|
||||
})
|
||||
};
|
||||
|
||||
shaders.push(shader);
|
||||
this.setShaders(shaders);
|
||||
return shader;
|
||||
private static readonly SHADERS_KEY = STORAGE_KEYS.SHADERS;
|
||||
private static readonly SETTINGS_KEY = STORAGE_KEYS.SETTINGS;
|
||||
|
||||
static saveShader(
|
||||
name: string,
|
||||
code: string,
|
||||
settings?: Partial<AppSettings>
|
||||
): SavedShader {
|
||||
const shaders = this.getShaders();
|
||||
const id = this.generateId();
|
||||
const timestamp = Date.now();
|
||||
|
||||
const shader: SavedShader = {
|
||||
id,
|
||||
name: name.trim() || `Shader ${shaders.length + 1}`,
|
||||
code,
|
||||
created: timestamp,
|
||||
lastUsed: timestamp,
|
||||
// Include settings if provided
|
||||
...(settings && {
|
||||
resolution: settings.resolution,
|
||||
fps: settings.fps,
|
||||
renderMode: settings.renderMode,
|
||||
valueMode: settings.valueMode,
|
||||
uiOpacity: settings.uiOpacity,
|
||||
}),
|
||||
};
|
||||
|
||||
shaders.push(shader);
|
||||
this.setShaders(shaders);
|
||||
return shader;
|
||||
}
|
||||
|
||||
static getShaders(): SavedShader[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.SHADERS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load shaders:', error);
|
||||
return [];
|
||||
}
|
||||
|
||||
static getShaders(): SavedShader[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.SHADERS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('Failed to load shaders:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static deleteShader(id: string): void {
|
||||
const shaders = this.getShaders().filter((s) => s.id !== id);
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
|
||||
static updateShaderUsage(id: string): void {
|
||||
const shaders = this.getShaders();
|
||||
const shader = shaders.find((s) => s.id === id);
|
||||
if (shader) {
|
||||
shader.lastUsed = Date.now();
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
|
||||
static deleteShader(id: string): void {
|
||||
const shaders = this.getShaders().filter(s => s.id !== id);
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
|
||||
static renameShader(id: string, newName: string): void {
|
||||
const shaders = this.getShaders();
|
||||
const shader = shaders.find((s) => s.id === id);
|
||||
if (shader) {
|
||||
shader.name = newName.trim() || shader.name;
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
|
||||
static updateShaderUsage(id: string): void {
|
||||
const shaders = this.getShaders();
|
||||
const shader = shaders.find(s => s.id === id);
|
||||
if (shader) {
|
||||
shader.lastUsed = Date.now();
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
}
|
||||
|
||||
private static setShaders(shaders: SavedShader[]): void {
|
||||
try {
|
||||
// Keep only the most recent shaders
|
||||
const sortedShaders = shaders
|
||||
.sort((a, b) => b.lastUsed - a.lastUsed)
|
||||
.slice(0, PERFORMANCE.MAX_SAVED_SHADERS);
|
||||
localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders));
|
||||
} catch (error) {
|
||||
console.error('Failed to save shaders:', error);
|
||||
}
|
||||
|
||||
static renameShader(id: string, newName: string): void {
|
||||
const shaders = this.getShaders();
|
||||
const shader = shaders.find(s => s.id === id);
|
||||
if (shader) {
|
||||
shader.name = newName.trim() || shader.name;
|
||||
this.setShaders(shaders);
|
||||
}
|
||||
}
|
||||
|
||||
static saveSettings(settings: Partial<AppSettings>): void {
|
||||
try {
|
||||
const current = this.getSettings();
|
||||
const updated = { ...current, ...settings };
|
||||
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
|
||||
private static setShaders(shaders: SavedShader[]): void {
|
||||
try {
|
||||
// Keep only the 50 most recent shaders
|
||||
const sortedShaders = shaders
|
||||
.sort((a, b) => b.lastUsed - a.lastUsed)
|
||||
.slice(0, 50);
|
||||
localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders));
|
||||
} catch (error) {
|
||||
console.error('Failed to save shaders:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static getSettings(): AppSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.SETTINGS_KEY);
|
||||
const defaults: AppSettings = {
|
||||
resolution: DEFAULTS.RESOLUTION,
|
||||
fps: DEFAULTS.FPS,
|
||||
lastShaderCode: DEFAULTS.SHADER_CODE,
|
||||
renderMode: DEFAULTS.RENDER_MODE,
|
||||
valueMode: DEFAULTS.VALUE_MODE,
|
||||
uiOpacity: DEFAULTS.UI_OPACITY,
|
||||
};
|
||||
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
return {
|
||||
resolution: DEFAULTS.RESOLUTION,
|
||||
fps: DEFAULTS.FPS,
|
||||
lastShaderCode: DEFAULTS.SHADER_CODE,
|
||||
renderMode: DEFAULTS.RENDER_MODE,
|
||||
valueMode: DEFAULTS.VALUE_MODE,
|
||||
uiOpacity: DEFAULTS.UI_OPACITY,
|
||||
};
|
||||
}
|
||||
|
||||
static saveSettings(settings: Partial<AppSettings>): void {
|
||||
try {
|
||||
const current = this.getSettings();
|
||||
const updated = { ...current, ...settings };
|
||||
localStorage.setItem(this.SETTINGS_KEY, JSON.stringify(updated));
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
static clearAll(): void {
|
||||
try {
|
||||
localStorage.removeItem(this.SHADERS_KEY);
|
||||
localStorage.removeItem(this.SETTINGS_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear storage:', error);
|
||||
}
|
||||
|
||||
static getSettings(): AppSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.SETTINGS_KEY);
|
||||
const defaults: AppSettings = {
|
||||
resolution: 1,
|
||||
fps: 30,
|
||||
lastShaderCode: 'x^y',
|
||||
renderMode: 'classic',
|
||||
uiOpacity: 0.3
|
||||
};
|
||||
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
return {
|
||||
resolution: 1,
|
||||
fps: 30,
|
||||
lastShaderCode: 'x^y',
|
||||
renderMode: 'classic',
|
||||
uiOpacity: 0.3
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
static exportShaders(): string {
|
||||
const shaders = this.getShaders();
|
||||
return JSON.stringify(shaders, null, 2);
|
||||
}
|
||||
|
||||
static importShaders(jsonData: string): boolean {
|
||||
try {
|
||||
const imported = JSON.parse(jsonData) as SavedShader[];
|
||||
if (!Array.isArray(imported)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
const valid = imported.every(
|
||||
(shader) =>
|
||||
shader.id &&
|
||||
shader.name &&
|
||||
shader.code &&
|
||||
typeof shader.created === 'number' &&
|
||||
typeof shader.lastUsed === 'number'
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = this.getShaders();
|
||||
const merged = [...existing, ...imported];
|
||||
this.setShaders(merged);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to import shaders:', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
static clearAll(): void {
|
||||
try {
|
||||
localStorage.removeItem(this.SHADERS_KEY);
|
||||
localStorage.removeItem(this.SETTINGS_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2);
|
||||
}
|
||||
|
||||
static exportShaders(): string {
|
||||
const shaders = this.getShaders();
|
||||
return JSON.stringify(shaders, null, 2);
|
||||
}
|
||||
|
||||
static importShaders(jsonData: string): boolean {
|
||||
try {
|
||||
const imported = JSON.parse(jsonData) as SavedShader[];
|
||||
if (!Array.isArray(imported)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate structure
|
||||
const valid = imported.every(shader =>
|
||||
shader.id && shader.name && shader.code &&
|
||||
typeof shader.created === 'number' &&
|
||||
typeof shader.lastUsed === 'number'
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = this.getShaders();
|
||||
const merged = [...existing, ...imported];
|
||||
this.setShaders(merged);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to import shaders:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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} />;
|
||||
}
|
||||
72
src/icons.ts
72
src/icons.ts
@ -1,42 +1,46 @@
|
||||
import { createIcons, icons } from 'lucide';
|
||||
|
||||
export function createIcon(name: string, size: number = 16): string {
|
||||
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'
|
||||
};
|
||||
|
||||
const iconName = iconMap[name];
|
||||
if (!iconName) return '';
|
||||
|
||||
return `<i data-lucide="${iconName}" width="${size}" height="${size}" stroke-width="2"></i>`;
|
||||
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',
|
||||
};
|
||||
|
||||
const iconName = iconMap[name];
|
||||
if (!iconName) return '';
|
||||
|
||||
return `<i data-lucide="${iconName}" width="${size}" height="${size}" stroke-width="2"></i>`;
|
||||
}
|
||||
|
||||
export function addIconToButton(button: HTMLElement, iconName: string, keepText: boolean = false): void {
|
||||
const originalText = button.textContent || '';
|
||||
const iconHtml = createIcon(iconName);
|
||||
|
||||
if (keepText) {
|
||||
button.innerHTML = iconHtml + ' ' + originalText;
|
||||
} else {
|
||||
button.innerHTML = iconHtml;
|
||||
button.setAttribute('aria-label', originalText);
|
||||
}
|
||||
export function addIconToButton(
|
||||
button: HTMLElement,
|
||||
iconName: string,
|
||||
keepText: boolean = false
|
||||
): void {
|
||||
const originalText = button.textContent || '';
|
||||
const iconHtml = createIcon(iconName);
|
||||
|
||||
if (keepText) {
|
||||
button.innerHTML = iconHtml + ' ' + originalText;
|
||||
} else {
|
||||
button.innerHTML = iconHtml;
|
||||
button.setAttribute('aria-label', originalText);
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeLucideIcons(): void {
|
||||
createIcons({ icons });
|
||||
}
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
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,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
server: {
|
||||
port: 3000
|
||||
|
||||
Reference in New Issue
Block a user