|
/** |
|
* app.js - Main application logic |
|
* Enhanced Shadertoy Local Lite with file management, safeguards, and recording features |
|
*/ |
|
|
|
// ============================================================================= |
|
// DOM Elements |
|
// ============================================================================= |
|
|
|
const canvas = document.getElementById('glcanvas'); |
|
const editorContainer = document.getElementById('editor'); |
|
const errorsEl = document.getElementById('errors'); |
|
const fpsEl = document.getElementById('fps'); |
|
const resEl = document.getElementById('res'); |
|
const timeEl = document.getElementById('time'); |
|
const hudEl = document.getElementById('hud'); |
|
const toastEl = document.getElementById('toast'); |
|
|
|
// File controls |
|
const shaderSelect = document.getElementById('shaderSelect'); |
|
const newShaderBtn = document.getElementById('newShaderBtn'); |
|
const deleteShaderBtn = document.getElementById('deleteShaderBtn'); |
|
|
|
// Action buttons |
|
const runBtn = document.getElementById('runBtn'); |
|
const saveBtn = document.getElementById('saveBtn'); |
|
const reloadBtn = document.getElementById('reloadBtn'); |
|
const pauseBtn = document.getElementById('pauseBtn'); |
|
const restartBtn = document.getElementById('restartBtn'); |
|
const hudBtn = document.getElementById('hudBtn'); |
|
|
|
// Viewer controls |
|
const openViewerBtn = document.getElementById('openViewerBtn'); |
|
const viewerMenu = document.getElementById('viewerMenu'); |
|
|
|
// Layout controls |
|
const layoutSplit = document.getElementById('layoutSplit'); |
|
const layoutCanvas = document.getElementById('layoutCanvas'); |
|
const layoutEditor = document.getElementById('layoutEditor'); |
|
const mainEl = document.querySelector('main'); |
|
const resizer = document.getElementById('resizer'); |
|
|
|
// Status |
|
const editorStatusEl = document.getElementById('editorStatus'); |
|
const viewerStatusEl = document.getElementById('viewerStatus'); |
|
const editorFilenameEl = document.getElementById('editorFilename'); |
|
|
|
// Modals |
|
const newShaderModal = document.getElementById('newShaderModal'); |
|
const newShaderInput = document.getElementById('newShaderInput'); |
|
const newShaderCreate = document.getElementById('newShaderCreate'); |
|
const newShaderCancel = document.getElementById('newShaderCancel'); |
|
|
|
// ============================================================================= |
|
// WebGL Setup |
|
// ============================================================================= |
|
|
|
const gl = canvas.getContext('webgl2', { |
|
antialias: true, |
|
preserveDrawingBuffer: true, |
|
alpha: false |
|
}); |
|
|
|
if (!gl) { |
|
showError('WebGL2 not available'); |
|
throw new Error('WebGL2 required'); |
|
} |
|
|
|
// ============================================================================= |
|
// State |
|
// ============================================================================= |
|
|
|
const state = new ShaderState(); |
|
let program = null; |
|
let raf = 0; |
|
let paused = false; |
|
let startTime = performance.now() / 1000; |
|
let prevTime = startTime; |
|
let frame = 0; |
|
let frameStart = startTime; |
|
const mouse = [0, 0, 0, 0]; |
|
const DPR = Math.min(window.devicePixelRatio || 1, 2); |
|
let hudVisible = true; |
|
let viewerWindows = []; |
|
|
|
// BroadcastChannel for syncing with popup viewers |
|
let broadcastChannel = null; |
|
try { |
|
broadcastChannel = new BroadcastChannel('shadertoy-sync'); |
|
} catch (e) { |
|
console.warn('BroadcastChannel not available:', e); |
|
} |
|
|
|
// ============================================================================= |
|
// WebGL Helper Functions |
|
// ============================================================================= |
|
|
|
function compile(type, src) { |
|
const sh = gl.createShader(type); |
|
gl.shaderSource(sh, src); |
|
gl.compileShader(sh); |
|
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { |
|
const log = gl.getShaderInfoLog(sh) || ''; |
|
gl.deleteShader(sh); |
|
throw new Error(log); |
|
} |
|
return sh; |
|
} |
|
|
|
function link(vs, fs) { |
|
const p = gl.createProgram(); |
|
gl.attachShader(p, vs); |
|
gl.attachShader(p, fs); |
|
gl.linkProgram(p); |
|
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) { |
|
const log = gl.getProgramInfoLog(p) || ''; |
|
gl.deleteProgram(p); |
|
throw new Error(log); |
|
} |
|
return p; |
|
} |
|
|
|
const vsSrc = `#version 300 es |
|
precision highp float; |
|
const vec2 verts[3] = vec2[3](vec2(-1.,-1.), vec2(3.,-1.), vec2(-1.,3.)); |
|
void main(){ gl_Position = vec4(verts[gl_VertexID], 0., 1.); } |
|
`; |
|
|
|
function buildFrag(user) { |
|
const hasMain = /\bvoid\s+main\s*\(/.test(user); |
|
const header = `#version 300 es |
|
precision highp float; |
|
out vec4 outColor; |
|
|
|
uniform vec3 iResolution; |
|
uniform float iTime; |
|
uniform float iTimeDelta; |
|
uniform int iFrame; |
|
uniform float iFrameRate; |
|
uniform vec4 iMouse; |
|
uniform vec4 iDate; |
|
`; |
|
if (hasMain) { |
|
return header + '\n' + user.replace('gl_FragColor', 'outColor'); |
|
} |
|
return header + ` |
|
${user} |
|
void main(){ |
|
vec4 c = vec4(0.0); |
|
mainImage(c, gl_FragCoord.xy); |
|
outColor = c; |
|
}`; |
|
} |
|
|
|
function createProgramFromEditor() { |
|
const fsSrc = buildFrag(getEditorContent()); |
|
const vs = compile(gl.VERTEX_SHADER, vsSrc); |
|
const fs = compile(gl.FRAGMENT_SHADER, fsSrc); |
|
const p = link(vs, fs); |
|
gl.deleteShader(vs); |
|
gl.deleteShader(fs); |
|
return p; |
|
} |
|
|
|
let u = {}; |
|
function bindUniforms() { |
|
u.res = gl.getUniformLocation(program, 'iResolution'); |
|
u.time = gl.getUniformLocation(program, 'iTime'); |
|
u.dt = gl.getUniformLocation(program, 'iTimeDelta'); |
|
u.ifr = gl.getUniformLocation(program, 'iFrame'); |
|
u.fps = gl.getUniformLocation(program, 'iFrameRate'); |
|
u.mouse = gl.getUniformLocation(program, 'iMouse'); |
|
u.date = gl.getUniformLocation(program, 'iDate'); |
|
} |
|
|
|
function setDateUniform(t) { |
|
const d = new Date(); |
|
const secs = d.getHours() * 3600 + d.getMinutes() * 60 + d.getSeconds() + d.getMilliseconds() / 1000; |
|
gl.uniform4f(u.date, d.getFullYear(), d.getMonth() + 1, d.getDate(), secs); |
|
} |
|
|
|
// ============================================================================= |
|
// Rendering |
|
// ============================================================================= |
|
|
|
function resize() { |
|
const w = Math.floor(canvas.clientWidth * DPR); |
|
const h = Math.floor(canvas.clientHeight * DPR); |
|
if (canvas.width !== w || canvas.height !== h) { |
|
canvas.width = w; |
|
canvas.height = h; |
|
gl.viewport(0, 0, w, h); |
|
resEl.textContent = `${w}×${h}`; |
|
} |
|
} |
|
|
|
function draw(now) { |
|
const t = now / 1000; |
|
const dt = Math.max(0.00001, t - prevTime); |
|
|
|
gl.uniform3f(u.res, canvas.width, canvas.height, 1.0); |
|
gl.uniform1f(u.time, t - startTime); |
|
gl.uniform1f(u.dt, dt); |
|
gl.uniform1i(u.ifr, frame++); |
|
gl.uniform1f(u.fps, 1.0 / dt); |
|
gl.uniform4f(u.mouse, mouse[0], mouse[1], mouse[2], mouse[3]); |
|
setDateUniform(t); |
|
|
|
gl.drawArrays(gl.TRIANGLES, 0, 3); |
|
|
|
// Update HUD |
|
if (t - frameStart > 0.25) { |
|
fpsEl.textContent = `${(1 / dt).toFixed(1)} fps`; |
|
timeEl.textContent = `T: ${(t - startTime).toFixed(1)}s`; |
|
frameStart = t; |
|
} |
|
|
|
prevTime = t; |
|
} |
|
|
|
function tick() { |
|
if (paused) return; |
|
resize(); |
|
|
|
try { |
|
gl.useProgram(program); |
|
draw(performance.now()); |
|
errorsEl.textContent = ''; |
|
} catch (e) { |
|
errorsEl.textContent = e.message; |
|
} |
|
|
|
raf = requestAnimationFrame(tick); |
|
} |
|
|
|
// ============================================================================= |
|
// Shader Compilation with Safeguards |
|
// ============================================================================= |
|
|
|
function compileShader() { |
|
try { |
|
const content = getEditorContent(); |
|
|
|
// Don't compile if editor is empty (not yet initialized) |
|
if (!content || content.trim() === '') { |
|
console.warn('Editor content is empty, skipping compilation'); |
|
return false; |
|
} |
|
|
|
const newProgram = createProgramFromEditor(); |
|
|
|
// Success! Update viewer |
|
if (program) gl.deleteProgram(program); |
|
program = newProgram; |
|
bindUniforms(); |
|
|
|
// Mark as successful in state |
|
state.setCompileSuccess(content); |
|
|
|
// Sync to viewer windows |
|
if (broadcastChannel) { |
|
broadcastChannel.postMessage({ |
|
type: 'shader-update', |
|
code: content |
|
}); |
|
} |
|
|
|
return true; |
|
} catch (e) { |
|
// Compilation failed - viewer keeps old shader |
|
state.setCompileError(e.message); |
|
errorsEl.textContent = e.message; |
|
|
|
// Highlight error line if possible |
|
if (state.errorLine) { |
|
highlightErrorLine(state.errorLine); |
|
} |
|
|
|
return false; |
|
} |
|
} |
|
|
|
function runShader() { |
|
cancelAnimationFrame(raf); |
|
|
|
if (compileShader()) { |
|
clearErrorLine(); |
|
prevTime = performance.now() / 1000; |
|
startTime = prevTime; |
|
frame = 0; |
|
paused = false; |
|
pauseBtn.classList.remove('active'); |
|
pauseBtn.textContent = '⏸ Pause'; |
|
tick(); |
|
updateStatus(); |
|
showToast('Shader compiled successfully', 'success'); |
|
} else { |
|
showToast('Compilation failed', 'error'); |
|
} |
|
} |
|
|
|
async function saveShader() { |
|
try { |
|
const success = await state.saveToDisk(); |
|
if (success) { |
|
showToast('Saved to disk', 'success'); |
|
updateStatus(); |
|
} |
|
} catch (e) { |
|
showToast(e.message, 'error'); |
|
} |
|
} |
|
|
|
async function reloadShader() { |
|
try { |
|
const content = await state.loadFromDisk(state.currentFile); |
|
setEditorContent(content); |
|
updateStatus(); |
|
|
|
// Try to compile loaded shader |
|
if (compileShader()) { |
|
clearErrorLine(); |
|
showToast('Reloaded from disk', 'success'); |
|
} else { |
|
showToast('Reloaded, but has errors', 'warning'); |
|
} |
|
} catch (e) { |
|
showToast(e.message, 'error'); |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// File Management |
|
// ============================================================================= |
|
|
|
async function fetchShaderList() { |
|
try { |
|
const res = await fetch('/api/shaders'); |
|
const data = await res.json(); |
|
return data.shaders || []; |
|
} catch (e) { |
|
console.error('Failed to fetch shader list:', e); |
|
return []; |
|
} |
|
} |
|
|
|
async function populateDropdown() { |
|
const shaders = await fetchShaderList(); |
|
|
|
shaderSelect.innerHTML = ''; |
|
|
|
if (shaders.length === 0) { |
|
shaderSelect.innerHTML = '<option>No shaders found</option>'; |
|
return; |
|
} |
|
|
|
shaders.forEach(filename => { |
|
const option = document.createElement('option'); |
|
option.value = filename; |
|
option.textContent = filename; |
|
if (filename === state.currentFile) { |
|
option.selected = true; |
|
} |
|
shaderSelect.appendChild(option); |
|
}); |
|
} |
|
|
|
async function switchShader(filename) { |
|
if (filename === state.currentFile) return; |
|
|
|
// Check if safe to switch |
|
if (!state.canSwitchFile()) return; |
|
|
|
try { |
|
const content = await state.loadFromDisk(filename); |
|
setEditorContent(content); |
|
editorFilenameEl.textContent = filename; |
|
updateStatus(); |
|
|
|
// Try to compile |
|
if (compileShader()) { |
|
clearErrorLine(); |
|
} |
|
|
|
// Update URL |
|
const url = new URL(window.location); |
|
url.searchParams.set('shader', filename); |
|
window.history.pushState({}, '', url); |
|
} catch (e) { |
|
showToast(e.message, 'error'); |
|
} |
|
} |
|
|
|
function showNewShaderModal() { |
|
newShaderModal.classList.add('show'); |
|
newShaderInput.value = ''; |
|
newShaderInput.focus(); |
|
} |
|
|
|
function hideNewShaderModal() { |
|
newShaderModal.classList.remove('show'); |
|
} |
|
|
|
async function createNewShader() { |
|
let filename = newShaderInput.value.trim(); |
|
|
|
if (!filename) { |
|
showToast('Enter a filename', 'error'); |
|
return; |
|
} |
|
|
|
// Add .frag extension if missing |
|
if (!filename.endsWith('.frag')) { |
|
filename += '.frag'; |
|
} |
|
|
|
// Validate filename |
|
if (!/^[\w\-\.]+\.frag$/.test(filename)) { |
|
showToast('Invalid filename', 'error'); |
|
return; |
|
} |
|
|
|
try { |
|
const res = await fetch('/api/create', { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ filename }) |
|
}); |
|
|
|
if (!res.ok) { |
|
const data = await res.json(); |
|
throw new Error(data.error || 'Create failed'); |
|
} |
|
|
|
hideNewShaderModal(); |
|
await populateDropdown(); |
|
|
|
// Switch to new shader |
|
shaderSelect.value = filename; |
|
await switchShader(filename); |
|
|
|
showToast(`Created ${filename}`, 'success'); |
|
} catch (e) { |
|
showToast(e.message, 'error'); |
|
} |
|
} |
|
|
|
async function deleteCurrentShader() { |
|
const filename = state.currentFile; |
|
|
|
if (!confirm(`Delete ${filename}?\n\nThis cannot be undone.`)) { |
|
return; |
|
} |
|
|
|
try { |
|
const res = await fetch('/api/delete', { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ filename }) |
|
}); |
|
|
|
if (!res.ok) { |
|
const data = await res.json(); |
|
throw new Error(data.error || 'Delete failed'); |
|
} |
|
|
|
// Refresh list |
|
const shaders = await fetchShaderList(); |
|
await populateDropdown(); |
|
|
|
// Switch to first available shader |
|
if (shaders.length > 0) { |
|
const nextShader = shaders[0]; |
|
shaderSelect.value = nextShader; |
|
await switchShader(nextShader); |
|
} |
|
|
|
showToast(`Deleted ${filename}`, 'success'); |
|
} catch (e) { |
|
showToast(e.message, 'error'); |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// Viewer Window Management |
|
// ============================================================================= |
|
|
|
function openViewerWindow(width, height) { |
|
const url = `viewer.html?shader=${state.currentFile}&w=${width}&h=${height}`; |
|
|
|
const viewer = window.open( |
|
url, |
|
`viewer_${Date.now()}`, |
|
`width=${width},height=${height},menubar=no,toolbar=no,location=no,status=no` |
|
); |
|
|
|
if (viewer) { |
|
viewerWindows.push(viewer); |
|
showToast(`Opened ${width}×${height} viewer`, 'success'); |
|
} else { |
|
showToast('Failed to open viewer (popup blocked?)', 'error'); |
|
} |
|
} |
|
|
|
// ============================================================================= |
|
// Layout Management |
|
// ============================================================================= |
|
|
|
function setLayout(mode) { |
|
mainEl.className = `layout-${mode}`; |
|
|
|
layoutSplit.classList.toggle('active', mode === 'split'); |
|
layoutCanvas.classList.toggle('active', mode === 'canvas'); |
|
layoutEditor.classList.toggle('active', mode === 'editor'); |
|
|
|
// Resize canvas after layout change |
|
setTimeout(resize, 100); |
|
} |
|
|
|
// Resizable splitter |
|
let isResizing = false; |
|
let startX = 0; |
|
let startWidth = 0; |
|
|
|
function initResizer() { |
|
resizer.addEventListener('mousedown', e => { |
|
isResizing = true; |
|
startX = e.clientX; |
|
const editorPane = document.querySelector('.editor-pane'); |
|
startWidth = editorPane.offsetWidth; |
|
document.body.style.cursor = 'col-resize'; |
|
e.preventDefault(); |
|
}); |
|
|
|
document.addEventListener('mousemove', e => { |
|
if (!isResizing) return; |
|
|
|
const editorPane = document.querySelector('.editor-pane'); |
|
const delta = e.clientX - startX; |
|
const newWidth = startWidth + delta; |
|
const minWidth = 300; |
|
const maxWidth = window.innerWidth - 400; |
|
|
|
if (newWidth >= minWidth && newWidth <= maxWidth) { |
|
const percentage = (newWidth / window.innerWidth) * 100; |
|
mainEl.style.gridTemplateColumns = `${percentage}% auto 1fr`; |
|
} |
|
}); |
|
|
|
document.addEventListener('mouseup', () => { |
|
if (isResizing) { |
|
isResizing = false; |
|
document.body.style.cursor = ''; |
|
} |
|
}); |
|
} |
|
|
|
// ============================================================================= |
|
// UI Functions |
|
// ============================================================================= |
|
|
|
function updateStatus() { |
|
const status = state.getStatusText(); |
|
editorStatusEl.textContent = status.editorStatus; |
|
viewerStatusEl.textContent = status.viewerStatus; |
|
} |
|
|
|
function showToast(message, type = 'info') { |
|
toastEl.textContent = message; |
|
toastEl.className = `toast ${type} show`; |
|
|
|
setTimeout(() => { |
|
toastEl.classList.remove('show'); |
|
}, 3000); |
|
} |
|
|
|
function showError(message) { |
|
errorsEl.textContent = message; |
|
} |
|
|
|
function togglePause() { |
|
paused = !paused; |
|
pauseBtn.classList.toggle('active', paused); |
|
pauseBtn.textContent = paused ? '▶ Resume' : '⏸ Pause'; |
|
if (!paused) tick(); |
|
} |
|
|
|
function restartTime() { |
|
startTime = performance.now() / 1000; |
|
prevTime = startTime; |
|
frame = 0; |
|
frameStart = startTime; |
|
showToast('Time restarted', 'success'); |
|
} |
|
|
|
function toggleHud() { |
|
hudVisible = !hudVisible; |
|
hudEl.classList.toggle('hidden', !hudVisible); |
|
hudBtn.classList.toggle('active', !hudVisible); |
|
} |
|
|
|
// ============================================================================= |
|
// Event Listeners |
|
// ============================================================================= |
|
|
|
// File controls |
|
shaderSelect.addEventListener('change', () => { |
|
switchShader(shaderSelect.value); |
|
}); |
|
|
|
newShaderBtn.addEventListener('click', showNewShaderModal); |
|
deleteShaderBtn.addEventListener('click', deleteCurrentShader); |
|
|
|
// New shader modal |
|
newShaderCreate.addEventListener('click', createNewShader); |
|
newShaderCancel.addEventListener('click', hideNewShaderModal); |
|
newShaderInput.addEventListener('keydown', e => { |
|
if (e.key === 'Enter') createNewShader(); |
|
if (e.key === 'Escape') hideNewShaderModal(); |
|
}); |
|
|
|
// Click outside modal to close |
|
newShaderModal.addEventListener('click', e => { |
|
if (e.target === newShaderModal) hideNewShaderModal(); |
|
}); |
|
|
|
// Action buttons |
|
runBtn.addEventListener('click', runShader); |
|
saveBtn.addEventListener('click', saveShader); |
|
reloadBtn.addEventListener('click', reloadShader); |
|
pauseBtn.addEventListener('click', togglePause); |
|
restartBtn.addEventListener('click', restartTime); |
|
hudBtn.addEventListener('click', toggleHud); |
|
|
|
// Viewer button dropdown |
|
openViewerBtn.addEventListener('click', e => { |
|
e.stopPropagation(); |
|
viewerMenu.classList.toggle('show'); |
|
}); |
|
|
|
viewerMenu.addEventListener('click', e => { |
|
const btn = e.target.closest('button[data-size]'); |
|
if (btn) { |
|
const [w, h] = btn.dataset.size.split(',').map(Number); |
|
openViewerWindow(w, h); |
|
viewerMenu.classList.remove('show'); |
|
} |
|
}); |
|
|
|
// Close dropdown when clicking outside |
|
document.addEventListener('click', () => { |
|
viewerMenu.classList.remove('show'); |
|
}); |
|
|
|
// Layout buttons |
|
layoutSplit.addEventListener('click', () => setLayout('split')); |
|
layoutCanvas.addEventListener('click', () => setLayout('canvas')); |
|
layoutEditor.addEventListener('click', () => setLayout('editor')); |
|
|
|
// Canvas mouse events |
|
window.addEventListener('resize', resize); |
|
|
|
['mousemove', 'mousedown', 'mouseup'].forEach(evt => { |
|
canvas.addEventListener(evt, e => { |
|
const r = canvas.getBoundingClientRect(); |
|
const x = (e.clientX - r.left) * DPR; |
|
const y = (r.height - (e.clientY - r.top)) * DPR; |
|
if (evt === 'mousedown') { mouse[2] = x; mouse[3] = y; } |
|
if (evt !== 'mouseup') { mouse[0] = x; mouse[1] = y; } |
|
}); |
|
}); |
|
|
|
// ============================================================================= |
|
// Keyboard Shortcuts |
|
// ============================================================================= |
|
|
|
document.addEventListener('keydown', e => { |
|
const ctrl = e.ctrlKey || e.metaKey; |
|
|
|
// Ctrl+Enter - Run shader |
|
if (ctrl && e.key === 'Enter') { |
|
e.preventDefault(); |
|
runShader(); |
|
} |
|
|
|
// Ctrl+S - Save |
|
if (ctrl && e.key === 's') { |
|
e.preventDefault(); |
|
saveShader(); |
|
} |
|
|
|
// Ctrl+R - Reload |
|
if (ctrl && e.key === 'r') { |
|
e.preventDefault(); |
|
reloadShader(); |
|
} |
|
|
|
// Ctrl+P - Open viewer |
|
if (ctrl && e.key === 'p') { |
|
e.preventDefault(); |
|
openViewerWindow(1920, 1080); |
|
} |
|
|
|
// Ctrl+H - Toggle HUD |
|
if (ctrl && e.key === 'h') { |
|
e.preventDefault(); |
|
toggleHud(); |
|
} |
|
|
|
// Ctrl+T - Restart time |
|
if (ctrl && e.key === 't') { |
|
e.preventDefault(); |
|
restartTime(); |
|
} |
|
|
|
// Ctrl+1/2/3 - Layout modes |
|
if (ctrl && e.key === '1') { |
|
e.preventDefault(); |
|
setLayout('split'); |
|
} |
|
if (ctrl && e.key === '2') { |
|
e.preventDefault(); |
|
setLayout('canvas'); |
|
} |
|
if (ctrl && e.key === '3') { |
|
e.preventDefault(); |
|
setLayout('editor'); |
|
} |
|
}); |
|
|
|
// ============================================================================= |
|
// Initialization |
|
// ============================================================================= |
|
|
|
async function init() { |
|
// Parse URL for initial shader |
|
const urlParams = new URLSearchParams(window.location.search); |
|
const shaderParam = urlParams.get('shader'); |
|
if (shaderParam) { |
|
state.currentFile = shaderParam; |
|
} |
|
|
|
// Populate shader dropdown |
|
await populateDropdown(); |
|
|
|
// Initialize CodeMirror editor |
|
await initializeEditor(editorContainer, content => { |
|
state.setEditorContent(content); |
|
updateStatus(); |
|
}); |
|
|
|
// Load initial shader |
|
try { |
|
const content = await state.loadFromDisk(state.currentFile); |
|
setEditorContent(content); |
|
editorFilenameEl.textContent = state.currentFile; |
|
|
|
// Ensure state has the content before compiling |
|
state.setEditorContent(content); |
|
} catch (e) { |
|
showToast(`Failed to load ${state.currentFile}`, 'error'); |
|
} |
|
|
|
// Initialize resizer |
|
initResizer(); |
|
|
|
// Setup canvas resize observer |
|
requestAnimationFrame(() => { |
|
const vp = document.querySelector('.viewer-pane'); |
|
const ro = new ResizeObserver(() => resize()); |
|
ro.observe(vp); |
|
resize(); |
|
}); |
|
|
|
// Check for recovery backup |
|
const backup = state.hasRecoveryBackup(); |
|
if (backup) { |
|
const age = Math.floor((Date.now() - backup.timestamp) / 1000); |
|
if (confirm(`Found unsaved changes from ${age}s ago.\n\nRestore backup?`)) { |
|
state.restoreFromBackup(backup); |
|
setEditorContent(state.editorContent); |
|
updateStatus(); |
|
showToast('Backup restored', 'success'); |
|
} else { |
|
state.clearBackup(); |
|
} |
|
} |
|
|
|
// Start auto-backup |
|
state.startAutoBackup(30000); |
|
|
|
// Compile and run initial shader |
|
runShader(); |
|
|
|
// Update status initially and every second to show age |
|
updateStatus(); |
|
setInterval(updateStatus, 1000); |
|
|
|
// Warn before unload if dirty |
|
window.addEventListener('beforeunload', e => { |
|
if (state.isDirty) { |
|
e.preventDefault(); |
|
e.returnValue = 'You have unsaved changes. Leave anyway?'; |
|
} |
|
}); |
|
|
|
console.log('Shadertoy Local Lite initialized'); |
|
} |
|
|
|
// Start when DOM and CodeMirror are ready |
|
async function waitForCodeMirror() { |
|
// Wait for CodeMirror to be loaded from CDN |
|
let attempts = 0; |
|
while (typeof CodeMirror === 'undefined' && attempts < 50) { |
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
attempts++; |
|
} |
|
|
|
if (typeof CodeMirror === 'undefined') { |
|
console.error('CodeMirror failed to load after 5 seconds'); |
|
showError('CodeMirror editor failed to load. Please refresh the page.'); |
|
return false; |
|
} |
|
|
|
return true; |
|
} |
|
|
|
if (document.readyState === 'loading') { |
|
document.addEventListener('DOMContentLoaded', async () => { |
|
if (await waitForCodeMirror()) { |
|
init(); |
|
} |
|
}); |
|
} else { |
|
waitForCodeMirror().then(ready => { |
|
if (ready) init(); |
|
}); |
|
} |