switching

This commit is contained in:
2025-07-06 13:11:19 +02:00
parent f84b515523
commit ec8786ab9b
38 changed files with 9935 additions and 3539 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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 />
</>
);
}

View 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>
);
}

View 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}>
&times;
</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>&lt;&lt; &gt;&gt;</strong> - Bit shift left/right
</p>
<p>
<strong>+ - * / %</strong> - Math operations
</p>
<p>
<strong>== != &lt; &gt;</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&gt;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&amp;&amp;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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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}
/>
);
}

View 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
View 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>
);
}

View 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
View 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,
};
}

View 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');
});
}

View 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} />;
}

View File

@ -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 });
}

File diff suppressed because it is too large Load Diff

66
src/main.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;