405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
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; |