first commit

This commit is contained in:
2025-07-05 02:34:28 +02:00
commit 423b02a195
11 changed files with 2240 additions and 0 deletions

237
src/FakeShader.ts Normal file
View File

@ -0,0 +1,237 @@
interface WorkerMessage {
id: string;
type: 'compile' | 'render';
code?: string;
width?: number;
height?: number;
time?: number;
}
interface WorkerResponse {
id: string;
type: 'compiled' | 'rendered' | 'error';
success: boolean;
imageData?: ImageData;
error?: string;
}
export class FakeShader {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
private code: string;
private worker: Worker;
private animationId: number | null = null;
private startTime: number = Date.now();
private isCompiled: boolean = false;
private isRendering: boolean = false;
private pendingRenders: string[] = [];
// Frame rate limiting
private targetFPS: number = 30;
private frameInterval: number = 1000 / this.targetFPS;
private lastFrameTime: number = 0;
constructor(canvas: HTMLCanvasElement, code: string = 'x^y') {
this.canvas = canvas;
this.ctx = canvas.getContext('2d')!;
this.code = code;
// Initialize worker
this.worker = new Worker(new URL('./ShaderWorker.ts', import.meta.url), { type: 'module' });
this.worker.onmessage = (e: MessageEvent<WorkerResponse>) => this.handleWorkerMessage(e.data);
this.worker.onerror = (error) => console.error('Worker error:', error);
this.compile();
}
private handleWorkerMessage(response: WorkerResponse): void {
switch (response.type) {
case 'compiled':
this.isCompiled = response.success;
if (!response.success) {
console.error('Compilation failed:', response.error);
this.fillBlack();
}
break;
case 'rendered':
this.isRendering = false;
if (response.success && response.imageData) {
this.ctx.putImageData(response.imageData, 0, 0);
} else {
console.error('Render failed:', response.error);
this.fillBlack();
}
// Process pending renders
if (this.pendingRenders.length > 0) {
this.pendingRenders.shift(); // Remove completed render
if (this.pendingRenders.length > 0) {
// Skip to latest render request
const latestId = this.pendingRenders[this.pendingRenders.length - 1];
this.pendingRenders = [latestId];
this.executeRender(latestId);
}
}
break;
case 'error':
this.isRendering = false;
console.error('Worker error:', response.error);
this.fillBlack();
break;
}
}
private fillBlack(): void {
this.ctx.fillStyle = '#000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
private compile(): void {
this.isCompiled = false;
const id = `compile_${Date.now()}`;
this.worker.postMessage({
id,
type: 'compile',
code: this.code
} as WorkerMessage);
}
private executeRender(id: string): void {
if (!this.isCompiled || this.isRendering) {
return;
}
this.isRendering = true;
const currentTime = (Date.now() - this.startTime) / 1000;
this.worker.postMessage({
id,
type: 'render',
width: this.canvas.width,
height: this.canvas.height,
time: currentTime
} as WorkerMessage);
}
setCode(code: string): void {
this.code = code;
this.compile();
}
render(animate: boolean = false): void {
const currentTime = performance.now();
// Frame rate limiting
if (animate && currentTime - this.lastFrameTime < this.frameInterval) {
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
return;
}
this.lastFrameTime = currentTime;
if (!this.isCompiled) {
this.fillBlack();
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
return;
}
const renderId = `render_${Date.now()}_${Math.random()}`;
// Add to pending renders queue
this.pendingRenders.push(renderId);
// If not currently rendering, start immediately
if (!this.isRendering) {
this.executeRender(renderId);
}
// Continue animation
if (animate) {
this.animationId = requestAnimationFrame(() => this.render(true));
}
}
startAnimation(): void {
this.stopAnimation();
this.startTime = Date.now();
this.lastFrameTime = 0; // Reset frame timing
this.render(true);
}
stopAnimation(): void {
if (this.animationId !== null) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
// Clear pending renders
this.pendingRenders = [];
}
setTargetFPS(fps: number): void {
this.targetFPS = Math.max(1, Math.min(120, fps)); // Clamp between 1-120 FPS
this.frameInterval = 1000 / this.targetFPS;
}
destroy(): void {
this.stopAnimation();
this.worker.terminate();
}
static generateRandomCode(): string {
const presets = [
'x^y',
'x&y',
'x|y',
'(x*y)%256',
'(x+y+t*10)%256',
'((x>>4)^(y>>4))<<4',
'(x^y^(x*y))%256',
'((x&y)|(x^y))%256',
'(x+y)&255',
'x%y',
'(x^(y<<2))%256',
'((x*t)^y)%256',
'(x&(y|t*8))%256',
'((x>>2)|(y<<2))%256',
'(x*y*t)%256',
'(x+y*t)%256',
'(x^y^(t*16))%256',
'((x*t)&(y*t))%256',
'(x+(y<<(t%4)))%256',
'((x*t%128)^y)%256',
'(x^(y*t*2))%256',
'((x+t)*(y+t))%256',
'(x&y&(t*8))%256',
'((x|t)^(y|t))%256'
];
const vars = ['x', 'y', 't', 'i'];
const ops = ['^', '&', '|', '+', '-', '*', '%'];
const shifts = ['<<', '>>'];
const numbers = ['2', '4', '8', '16', '32', '64', '128', '256'];
const randomChoice = <T>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
const dynamicExpressions = [
() => `${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)}`,
() => `(${randomChoice(vars)}${randomChoice(ops)}${randomChoice(vars)})%${randomChoice(numbers)}`,
() => `${randomChoice(vars)}${randomChoice(shifts)}${Math.floor(Math.random() * 8)}`,
() => `(${randomChoice(vars)}*${randomChoice(vars)})%${randomChoice(numbers)}`,
() => `${randomChoice(vars)}^${randomChoice(vars)}^${randomChoice(vars)}`,
];
// 70% chance to pick from presets, 30% chance to generate dynamic
if (Math.random() < 0.7) {
return randomChoice(presets);
} else {
return randomChoice(dynamicExpressions)();
}
}
}

182
src/ShaderWorker.ts Normal file
View File

@ -0,0 +1,182 @@
// WebWorker for safe shader compilation and execution
interface WorkerMessage {
id: string;
type: 'compile' | 'render';
code?: string;
width?: number;
height?: number;
time?: number;
}
interface WorkerResponse {
id: string;
type: 'compiled' | 'rendered' | 'error';
success: boolean;
imageData?: ImageData;
error?: string;
}
class ShaderWorker {
private compiledFunction: Function | null = null;
private lastCode: string = '';
constructor() {
self.onmessage = (e: MessageEvent<WorkerMessage>) => {
this.handleMessage(e.data);
};
}
private handleMessage(message: WorkerMessage): void {
try {
switch (message.type) {
case 'compile':
this.compileShader(message.id, message.code!);
break;
case 'render':
this.renderShader(message.id, message.width!, message.height!, message.time!);
break;
}
} catch (error) {
this.postError(message.id, error instanceof Error ? error.message : 'Unknown error');
}
}
private compileShader(id: string, code: string): void {
if (code === this.lastCode && this.compiledFunction) {
this.postMessage({ id, type: 'compiled', success: true });
return;
}
try {
const safeCode = this.sanitizeCode(code);
this.compiledFunction = new Function('x', 'y', 't', 'i', `
// Timeout protection
const startTime = performance.now();
let iterations = 0;
function checkTimeout() {
iterations++;
if (iterations % 1000 === 0 && performance.now() - startTime > 5) {
throw new Error('Shader timeout');
}
}
return (function() {
checkTimeout();
return ${safeCode};
})();
`);
this.lastCode = code;
this.postMessage({ id, type: 'compiled', success: true });
} catch (error) {
this.compiledFunction = null;
this.postError(id, error instanceof Error ? error.message : 'Compilation failed');
}
}
private renderShader(id: string, width: number, height: number, time: number): void {
if (!this.compiledFunction) {
this.postError(id, 'No compiled shader');
return;
}
const imageData = new ImageData(width, height);
const data = imageData.data;
const startTime = performance.now();
const maxRenderTime = 50; // 50ms max render time
try {
for (let y = 0; y < height; y++) {
// Check timeout every row
if (performance.now() - startTime > maxRenderTime) {
// Fill remaining pixels with black and break
for (let remainingY = y; remainingY < height; remainingY++) {
for (let remainingX = 0; remainingX < width; remainingX++) {
const i = (remainingY * width + remainingX) * 4;
data[i] = 0; // R
data[i + 1] = 0; // G
data[i + 2] = 0; // B
data[i + 3] = 255; // A
}
}
break;
}
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const pixelIndex = y * width + x;
try {
const value = this.compiledFunction(x, y, time, pixelIndex);
const safeValue = isFinite(value) ? value : 0;
const color = Math.abs(safeValue) % 256;
data[i] = color; // R
data[i + 1] = (color * 2) % 256; // G
data[i + 2] = (color * 3) % 256; // B
data[i + 3] = 255; // A
} catch (error) {
data[i] = 0; // R
data[i + 1] = 0; // G
data[i + 2] = 0; // B
data[i + 3] = 255; // A
}
}
}
this.postMessage({ id, type: 'rendered', success: true, imageData });
} catch (error) {
this.postError(id, error instanceof Error ? error.message : 'Render failed');
}
}
private sanitizeCode(code: string): string {
// Strict whitelist approach
const allowedPattern = /^[0-9a-zA-Z\s\+\-\*\/\%\^\&\|\(\)\<\>\~\?:,\.xyti]+$/;
if (!allowedPattern.test(code)) {
throw new Error('Invalid characters in shader code');
}
// Check for dangerous keywords
const dangerousKeywords = [
'eval', 'Function', 'constructor', 'prototype', '__proto__',
'window', 'document', 'global', 'process', 'require',
'import', 'export', 'class', 'function', 'var', 'let', 'const',
'while', 'for', 'do', 'if', 'else', 'switch', 'case', 'break',
'continue', 'return', 'throw', 'try', 'catch', 'finally'
];
const codeWords = code.toLowerCase().split(/[^a-z]/);
for (const keyword of dangerousKeywords) {
if (codeWords.includes(keyword)) {
throw new Error(`Forbidden keyword: ${keyword}`);
}
}
// Limit expression complexity
const complexity = (code.match(/[\(\)]/g) || []).length;
if (complexity > 20) {
throw new Error('Expression too complex');
}
// Limit code length
if (code.length > 200) {
throw new Error('Code too long');
}
return code;
}
private postMessage(response: WorkerResponse): void {
self.postMessage(response);
}
private postError(id: string, error: string): void {
this.postMessage({ id, type: 'error', success: false, error });
}
}
// Initialize worker
new ShaderWorker();

156
src/Storage.ts Normal file
View File

@ -0,0 +1,156 @@
interface SavedShader {
id: string;
name: string;
code: string;
created: number;
lastUsed: number;
}
interface AppSettings {
resolution: number;
fps: number;
lastShaderCode: string;
}
export class Storage {
private static readonly SHADERS_KEY = 'bitfielder_shaders';
private static readonly SETTINGS_KEY = 'bitfielder_settings';
static saveShader(name: string, code: string): SavedShader {
const shaders = this.getShaders();
const id = this.generateId();
const timestamp = Date.now();
const shader: SavedShader = {
id,
name: name.trim() || `Shader ${shaders.length + 1}`,
code,
created: timestamp,
lastUsed: timestamp
};
shaders.push(shader);
this.setShaders(shaders);
return shader;
}
static getShaders(): SavedShader[] {
try {
const stored = localStorage.getItem(this.SHADERS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to load shaders:', error);
return [];
}
}
static deleteShader(id: string): void {
const shaders = this.getShaders().filter(s => s.id !== id);
this.setShaders(shaders);
}
static updateShaderUsage(id: string): void {
const shaders = this.getShaders();
const shader = shaders.find(s => s.id === id);
if (shader) {
shader.lastUsed = Date.now();
this.setShaders(shaders);
}
}
static renameShader(id: string, newName: string): void {
const shaders = this.getShaders();
const shader = shaders.find(s => s.id === id);
if (shader) {
shader.name = newName.trim() || shader.name;
this.setShaders(shaders);
}
}
private static setShaders(shaders: SavedShader[]): void {
try {
// Keep only the 50 most recent shaders
const sortedShaders = shaders
.sort((a, b) => b.lastUsed - a.lastUsed)
.slice(0, 50);
localStorage.setItem(this.SHADERS_KEY, JSON.stringify(sortedShaders));
} catch (error) {
console.error('Failed to save shaders:', error);
}
}
static saveSettings(settings: Partial<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 getSettings(): AppSettings {
try {
const stored = localStorage.getItem(this.SETTINGS_KEY);
const defaults: AppSettings = {
resolution: 1,
fps: 30,
lastShaderCode: 'x^y'
};
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
} catch (error) {
console.error('Failed to load settings:', error);
return {
resolution: 1,
fps: 30,
lastShaderCode: 'x^y'
};
}
}
static clearAll(): void {
try {
localStorage.removeItem(this.SHADERS_KEY);
localStorage.removeItem(this.SETTINGS_KEY);
} catch (error) {
console.error('Failed to clear storage:', error);
}
}
private static generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
static exportShaders(): string {
const shaders = this.getShaders();
return JSON.stringify(shaders, null, 2);
}
static importShaders(jsonData: string): boolean {
try {
const imported = JSON.parse(jsonData) as SavedShader[];
if (!Array.isArray(imported)) {
return false;
}
// Validate structure
const valid = imported.every(shader =>
shader.id && shader.name && shader.code &&
typeof shader.created === 'number' &&
typeof shader.lastUsed === 'number'
);
if (!valid) {
return false;
}
const existing = this.getShaders();
const merged = [...existing, ...imported];
this.setShaders(merged);
return true;
} catch (error) {
console.error('Failed to import shaders:', error);
return false;
}
}
}

405
src/main.ts Normal file
View File

@ -0,0 +1,405 @@
import { FakeShader } from './FakeShader';
import { Storage } from './Storage';
class BitfielderApp {
private shader: FakeShader;
private canvas: HTMLCanvasElement;
private editor: HTMLTextAreaElement;
private isAnimating: boolean = false;
private uiVisible: boolean = true;
private performanceWarning: HTMLElement;
private libraryOpen: boolean = false;
constructor() {
this.canvas = document.getElementById('canvas') as HTMLCanvasElement;
this.editor = document.getElementById('editor') as HTMLTextAreaElement;
this.performanceWarning = document.getElementById('performance-warning') as HTMLElement;
this.loadSettings();
this.setupCanvas();
this.shader = new FakeShader(this.canvas, this.editor.value);
this.setupEventListeners();
this.loadFromURL();
this.renderShaderLibrary();
this.render();
window.addEventListener('resize', () => this.setupCanvas());
window.addEventListener('beforeunload', () => this.saveCurrentShader());
}
private setupCanvas(): void {
// Calculate the actual available space
const width = window.innerWidth;
const height = this.uiVisible ?
window.innerHeight - 180 : // subtract topbar (40px) and editor panel (140px)
window.innerHeight; // full height when UI is hidden
// Get resolution scale from dropdown
const resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement;
const scale = parseInt(resolutionSelect.value) || 1;
// Set canvas internal size with resolution scaling
this.canvas.width = Math.floor(width / scale);
this.canvas.height = Math.floor(height / scale);
console.log(`Canvas setup: ${this.canvas.width}x${this.canvas.height} (scale: ${scale}x), UI visible: ${this.uiVisible}`);
}
private setupEventListeners(): void {
const helpBtn = document.getElementById('help-btn')!;
const fullscreenBtn = document.getElementById('fullscreen-btn')!;
const hideUiBtn = document.getElementById('hide-ui-btn')!;
const showUiBtn = document.getElementById('show-ui-btn')!;
const randomBtn = document.getElementById('random-btn')!;
const shareBtn = document.getElementById('share-btn')!;
const resolutionSelect = document.getElementById('resolution-select') as HTMLSelectElement;
const fpsSelect = document.getElementById('fps-select') as HTMLSelectElement;
const helpPopup = document.getElementById('help-popup')!;
const closeBtn = helpPopup.querySelector('.close-btn')!;
// Library elements
const saveShaderBtn = document.getElementById('save-shader-btn')!;
const shaderNameInput = document.getElementById('shader-name-input') as HTMLInputElement;
const shaderSearchInput = document.getElementById('shader-search-input') as HTMLInputElement;
helpBtn.addEventListener('click', () => this.showHelp());
fullscreenBtn.addEventListener('click', () => this.toggleFullscreen());
hideUiBtn.addEventListener('click', () => this.toggleUI());
showUiBtn.addEventListener('click', () => this.showUI());
randomBtn.addEventListener('click', () => this.generateRandom());
shareBtn.addEventListener('click', () => this.shareURL());
resolutionSelect.addEventListener('change', () => this.updateResolution());
fpsSelect.addEventListener('change', () => this.updateFPS());
closeBtn.addEventListener('click', () => this.hideHelp());
// Library events
saveShaderBtn.addEventListener('click', () => this.saveCurrentShader());
shaderNameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
this.saveCurrentShader();
}
});
shaderSearchInput.addEventListener('input', () => this.renderShaderLibrary());
// Close help popup when clicking outside
helpPopup.addEventListener('click', (e) => {
if (e.target === helpPopup) {
this.hideHelp();
}
});
this.editor.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
this.shader.setCode(this.editor.value);
this.render();
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'F11') {
e.preventDefault();
this.toggleFullscreen();
} else if (e.key === 'h' || e.key === 'H') {
if (!this.editor.matches(':focus')) {
this.toggleUI();
}
} else if (e.key === 'r' || e.key === 'R') {
if (!this.editor.matches(':focus')) {
this.generateRandom();
}
} else if (e.key === 's' || e.key === 'S') {
if (!this.editor.matches(':focus')) {
this.shareURL();
}
} else if (e.key === '?') {
if (!this.editor.matches(':focus')) {
this.showHelp();
}
} else if (e.key === 'Escape') {
this.hideHelp();
this.showUI();
}
});
window.addEventListener('hashchange', () => this.loadFromURL());
// Listen for performance warnings
window.addEventListener('message', (e) => {
if (e.data === 'performance-warning') {
this.showPerformanceWarning();
}
});
}
private render(): void {
const hasTime = this.editor.value.includes('t');
if (hasTime && !this.isAnimating) {
this.isAnimating = true;
this.shader.startAnimation();
} else if (!hasTime && this.isAnimating) {
this.isAnimating = false;
this.shader.stopAnimation();
this.shader.render(false);
} else if (!hasTime) {
this.shader.render(false);
}
}
private toggleFullscreen(): void {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
private toggleUI(): void {
this.uiVisible = !this.uiVisible;
const topbar = document.getElementById('topbar')!;
const editorPanel = document.getElementById('editor-panel')!;
const editor = document.getElementById('editor')!;
const showUiBtn = document.getElementById('show-ui-btn')!;
if (this.uiVisible) {
// Show full UI
topbar.classList.remove('hidden');
editorPanel.classList.remove('minimal');
editor.classList.remove('minimal');
showUiBtn.style.display = 'none';
} else {
// Hide topbar, make editor minimal
topbar.classList.add('hidden');
editorPanel.classList.add('minimal');
editor.classList.add('minimal');
showUiBtn.style.display = 'block';
}
// Recalculate canvas size when UI is hidden/shown
this.setupCanvas();
}
private showUI(): void {
this.uiVisible = true;
const topbar = document.getElementById('topbar')!;
const editorPanel = document.getElementById('editor-panel')!;
const editor = document.getElementById('editor')!;
const showUiBtn = document.getElementById('show-ui-btn')!;
topbar.classList.remove('hidden');
editorPanel.classList.remove('minimal');
editor.classList.remove('minimal');
showUiBtn.style.display = 'none';
// Recalculate canvas size when UI is shown
this.setupCanvas();
}
private showHelp(): void {
const helpPopup = document.getElementById('help-popup')!;
helpPopup.style.display = 'block';
}
private hideHelp(): void {
const helpPopup = document.getElementById('help-popup')!;
helpPopup.style.display = 'none';
}
private showPerformanceWarning(): void {
this.performanceWarning.style.display = 'block';
setTimeout(() => {
this.performanceWarning.style.display = 'none';
}, 3000);
}
private shareURL(): void {
const encoded = btoa(this.editor.value);
window.location.hash = encoded;
navigator.clipboard.writeText(window.location.href).then(() => {
console.log('URL copied to clipboard');
}).catch(() => {
console.log('Copy failed');
});
}
private loadFromURL(): void {
if (window.location.hash) {
try {
const decoded = atob(window.location.hash.substring(1));
this.editor.value = decoded;
this.shader.setCode(decoded);
this.render();
} catch (e) {
console.error('Failed to decode URL hash:', e);
}
}
}
private updateResolution(): void {
this.setupCanvas();
Storage.saveSettings({ resolution: parseInt((document.getElementById('resolution-select') as HTMLSelectElement).value) });
}
private updateFPS(): void {
const fpsSelect = document.getElementById('fps-select') as HTMLSelectElement;
const fps = parseInt(fpsSelect.value);
this.shader.setTargetFPS(fps);
Storage.saveSettings({ fps });
}
private loadSettings(): void {
const settings = Storage.getSettings();
// Apply settings to UI
(document.getElementById('resolution-select') as HTMLSelectElement).value = settings.resolution.toString();
(document.getElementById('fps-select') as HTMLSelectElement).value = settings.fps.toString();
// Load last shader code if no URL hash
if (!window.location.hash) {
this.editor.value = settings.lastShaderCode;
}
}
private saveCurrentShader(): void {
const nameInput = document.getElementById('shader-name-input') as HTMLInputElement;
const name = nameInput.value.trim();
const code = this.editor.value.trim();
if (!code) return;
Storage.saveShader(name, code);
nameInput.value = '';
this.renderShaderLibrary();
// Save as last used shader
Storage.saveSettings({ lastShaderCode: code });
}
private renderShaderLibrary(): void {
const shaderList = document.getElementById('shader-list')!;
const searchInput = document.getElementById('shader-search-input') as HTMLInputElement;
const searchTerm = searchInput.value.toLowerCase().trim();
let shaders = Storage.getShaders();
// Filter by search term
if (searchTerm) {
shaders = shaders.filter(shader =>
shader.name.toLowerCase().includes(searchTerm) ||
shader.code.toLowerCase().includes(searchTerm)
);
}
if (shaders.length === 0) {
const message = searchTerm ? 'No shaders match your search' : 'No saved shaders';
shaderList.innerHTML = `<div style="padding: 20px; text-align: center; color: #666; font-size: 12px;">${message}</div>`;
return;
}
shaderList.innerHTML = shaders.map(shader => `
<div class="shader-item">
<div class="shader-item-header" onclick="app.loadShader('${shader.id}')">
<span class="shader-name" id="name-${shader.id}">${this.escapeHtml(shader.name)}</span>
<div class="shader-actions">
<button class="shader-action rename" onclick="event.stopPropagation(); app.startRename('${shader.id}')" title="Rename">edit</button>
<button class="shader-action delete" onclick="event.stopPropagation(); app.deleteShader('${shader.id}')" title="Delete">del</button>
</div>
</div>
<div class="shader-code">${this.escapeHtml(shader.code)}</div>
</div>
`).join('');
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Public methods for global access
loadShader(id: string): void {
const shaders = Storage.getShaders();
const shader = shaders.find(s => s.id === id);
if (shader) {
this.editor.value = shader.code;
this.shader.setCode(shader.code);
this.render();
Storage.updateShaderUsage(id);
this.renderShaderLibrary(); // Refresh to update order
}
}
deleteShader(id: string): void {
Storage.deleteShader(id);
this.renderShaderLibrary();
}
startRename(id: string): void {
const nameElement = document.getElementById(`name-${id}`);
if (!nameElement) return;
const shaders = Storage.getShaders();
const shader = shaders.find(s => s.id === id);
if (!shader) return;
// Replace span with input
const input = document.createElement('input');
input.type = 'text';
input.value = shader.name;
input.style.cssText = `
background: rgba(255, 255, 255, 0.2);
border: 1px solid #666;
color: #fff;
padding: 2px 4px;
border-radius: 2px;
font-family: monospace;
font-size: 12px;
width: 100%;
`;
nameElement.replaceWith(input);
input.focus();
input.select();
const finishRename = () => {
const newName = input.value.trim();
if (newName && newName !== shader.name) {
Storage.renameShader(id, newName);
}
this.renderShaderLibrary();
};
input.addEventListener('blur', finishRename);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
finishRename();
} else if (e.key === 'Escape') {
e.preventDefault();
this.renderShaderLibrary();
}
});
}
renameShader(id: string): void {
// This method is kept for backward compatibility but now uses startRename
this.startRename(id);
}
private generateRandom(): void {
const randomCode = FakeShader.generateRandomCode();
this.editor.value = randomCode;
this.shader.setCode(randomCode);
this.render();
}
}
const app = new BitfielderApp();
(window as any).app = app;