feat: pattern editor

This commit is contained in:
2025-10-12 12:28:33 +02:00
parent 9a75b23169
commit cb172595b3
16 changed files with 4816 additions and 3 deletions

View File

@@ -12,6 +12,7 @@
<div class="main-navigation">
<div class="nav-left">
<button class="nav-tab active" data-view="stream">📺 Stream</button>
<button class="nav-tab" data-view="editor">🎨 Editor</button>
<button class="nav-tab" data-view="settings">⚙️ Settings</button>
</div>
<div class="nav-right">
@@ -83,6 +84,90 @@
</main>
</div>
<!-- Editor View -->
<div id="editor-view" class="view-content">
<div class="editor-container-modern">
<div class="editor-layout-modern">
<!-- Left Panel: Preset Info & Layers -->
<div class="editor-left-panel">
<!-- Preset Info -->
<div class="editor-section-compact">
<h3 class="section-title-compact">Preset Info</h3>
<div class="editor-input-wrapper">
<label>Name</label>
<input type="text" id="editor-preset-name" value="New Custom Preset" />
</div>
<div class="editor-input-wrapper">
<label>Description</label>
<textarea id="editor-preset-desc" rows="3">A custom configurable preset</textarea>
</div>
</div>
<!-- Add Layer Button -->
<div class="editor-section-compact">
<button class="btn btn-primary btn-full-width" id="editor-add-layer"> Add Layer</button>
</div>
<!-- Layers List -->
<div class="editor-section-compact editor-layers-section">
<h3 class="section-title-compact">Layers</h3>
<div id="editor-layer-list" class="editor-layer-list-expandable">
<p class="editor-empty-state">No layers yet. Click "Add Layer" to get started!</p>
</div>
</div>
<!-- Manage Section -->
<div class="editor-section-compact">
<h3 class="section-title-compact">Manage</h3>
<div class="editor-actions">
<button class="btn btn-primary" id="editor-new-preset">📄 New</button>
<button class="btn btn-success" id="editor-save-preset">💾 Save</button>
<button class="btn btn-danger" id="editor-delete-preset">🗑️ Delete</button>
</div>
<div class="editor-actions">
<select class="preset-select" id="editor-load-preset">
<option value="">Load saved preset...</option>
</select>
</div>
<div class="editor-actions">
<button class="btn btn-secondary" id="editor-export-json">📤 Export JSON</button>
<label class="btn btn-secondary" style="margin: 0; text-align: center;">
📥 Import JSON
<input type="file" id="editor-import-json" accept=".json" style="display: none;" />
</label>
</div>
</div>
</div>
<!-- Right Panel: Preview & Controls -->
<div class="editor-right-panel">
<!-- Preview Controls Bar -->
<div class="editor-preview-bar">
<div class="preview-bar-controls">
<select id="editor-node-select" class="node-select-compact">
<option value="">Canvas Only</option>
</select>
<button class="btn-preview" id="editor-preview-toggle">
<span class="preview-icon">▶️</span>
<span class="preview-text">Start</span>
</button>
<span class="preview-status-compact" id="editor-preview-status">Ready</span>
</div>
<div class="preview-bar-info">
<span id="editor-canvas-size" class="info-badge">16×16</span>
<span id="editor-canvas-fps" class="info-badge">0 fps</span>
</div>
</div>
<!-- Canvas Preview -->
<div class="editor-preview-main">
<canvas id="editor-preview-canvas" class="editor-canvas-modern"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Settings View -->
<div id="settings-view" class="view-content">
<div class="settings-section">
@@ -130,6 +215,8 @@
<script src="scripts/matrix-display.js"></script>
<script src="scripts/node-canvas-grid.js"></script>
<script src="scripts/preset-controls.js"></script>
<script src="scripts/canvas-renderer.js"></script>
<script src="scripts/preset-editor.js"></script>
<script src="scripts/ledlab-app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,509 @@
// Client-side Canvas Renderer for Preset Editor
// Simplified implementation of building blocks for browser canvas rendering
class CanvasRenderer {
constructor(canvas, width = 16, height = 16) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d', { alpha: false });
this.width = width;
this.height = height;
// Disable image smoothing for pixel-perfect rendering
this.ctx.imageSmoothingEnabled = false;
// Set canvas resolution
this.canvas.width = width;
this.canvas.height = height;
this.frame = [];
this.time = 0;
this.animationStates = new Map();
this.lastFrameTime = Date.now();
}
setSize(width, height) {
this.width = width;
this.height = height;
this.canvas.width = width;
this.canvas.height = height;
this.frame = [];
// Re-apply image smoothing setting after canvas resize
this.ctx.imageSmoothingEnabled = false;
}
createFrame(fill = '000000') {
return new Array(this.width * this.height).fill(fill);
}
hexToRgb(hex) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return { r, g, b };
}
rgbToHex(rgb) {
const r = Math.max(0, Math.min(255, Math.round(rgb.r))).toString(16).padStart(2, '0');
const g = Math.max(0, Math.min(255, Math.round(rgb.g))).toString(16).padStart(2, '0');
const b = Math.max(0, Math.min(255, Math.round(rgb.b))).toString(16).padStart(2, '0');
return r + g + b;
}
lerpRgb(rgb1, rgb2, t) {
return {
r: rgb1.r + (rgb2.r - rgb1.r) * t,
g: rgb1.g + (rgb2.g - rgb1.g) * t,
b: rgb1.b + (rgb2.b - rgb1.b) * t
};
}
clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
toIndex(col, row) {
// Serpentine wiring
if (row % 2 === 0) {
return row * this.width + col;
}
return row * this.width + (this.width - 1 - col);
}
setPixelColor(frame, col, row, color, intensity = 1.0) {
const index = this.toIndex(col, row);
if (index < 0 || index >= frame.length) return;
if (intensity >= 1.0) {
frame[index] = color;
} else {
const currentRgb = this.hexToRgb(frame[index]);
const newRgb = this.hexToRgb(color);
const blended = {
r: currentRgb.r + (newRgb.r - currentRgb.r) * intensity,
g: currentRgb.g + (newRgb.g - currentRgb.g) * intensity,
b: currentRgb.b + (newRgb.b - currentRgb.b) * intensity
};
frame[index] = this.rgbToHex(blended);
}
}
// Color generators
createColorGenerator(colorConfig) {
if (!colorConfig) {
return () => 'ff0000';
}
const type = colorConfig.type || 'solid';
switch (type) {
case 'solid':
return () => colorConfig.value || 'ff0000';
case 'gradient': {
const rgb1 = this.hexToRgb(colorConfig.color1 || 'ff0000');
const rgb2 = this.hexToRgb(colorConfig.color2 || '0000ff');
return (t) => {
const clamped = this.clamp(t, 0, 1);
return this.rgbToHex(this.lerpRgb(rgb1, rgb2, clamped));
};
}
case 'rainbow': {
return (t) => {
const pos = Math.floor(this.clamp(t, 0, 1) * 255);
const wheelPos = 255 - pos;
let r, g, b;
if (wheelPos < 85) {
r = 255 - wheelPos * 3;
g = 0;
b = wheelPos * 3;
} else if (wheelPos < 170) {
const adjusted = wheelPos - 85;
r = 0;
g = adjusted * 3;
b = 255 - adjusted * 3;
} else {
const adjusted = wheelPos - 170;
r = adjusted * 3;
g = 255 - adjusted * 3;
b = 0;
}
return this.rgbToHex({ r, g, b });
};
}
default:
return () => 'ff0000';
}
}
// Shape rendering
renderCircle(frame, centerX, centerY, radius, color, intensity = 1.0) {
for (let row = 0; row < this.height; row++) {
for (let col = 0; col < this.width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
if (distance <= radius) {
const falloff = 1 - (distance / radius);
const pixelIntensity = falloff * intensity;
this.setPixelColor(frame, col, row, color, pixelIntensity);
}
}
}
}
renderRectangle(frame, x, y, width, height, color, intensity = 1.0) {
for (let row = Math.floor(y); row < Math.min(this.height, y + height); row++) {
for (let col = Math.floor(x); col < Math.min(this.width, x + width); col++) {
if (row >= 0 && col >= 0) {
this.setPixelColor(frame, col, row, color, intensity);
}
}
}
}
renderBlob(frame, centerX, centerY, radius, color, falloffPower = 2) {
for (let row = 0; row < this.height; row++) {
for (let col = 0; col < this.width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
const falloff = Math.max(0, 1 - distance / radius);
const intensity = Math.pow(falloff, falloffPower);
if (intensity > 0.01) {
this.setPixelColor(frame, col, row, color, intensity);
}
}
}
}
renderPoint(frame, x, y, color, intensity = 1.0) {
const col = Math.round(x);
const row = Math.round(y);
if (col >= 0 && col < this.width && row >= 0 && row < this.height) {
this.setPixelColor(frame, col, row, color, intensity);
}
}
renderTriangle(frame, x1, y1, x2, y2, x3, y3, color, intensity = 1.0) {
// Simple filled triangle using barycentric coordinates
const minX = Math.max(0, Math.floor(Math.min(x1, x2, x3)));
const maxX = Math.min(this.width - 1, Math.ceil(Math.max(x1, x2, x3)));
const minY = Math.max(0, Math.floor(Math.min(y1, y2, y3)));
const maxY = Math.min(this.height - 1, Math.ceil(Math.max(y1, y2, y3)));
for (let row = minY; row <= maxY; row++) {
for (let col = minX; col <= maxX; col++) {
if (this.isPointInTriangle(col, row, x1, y1, x2, y2, x3, y3)) {
this.setPixelColor(frame, col, row, color, intensity);
}
}
}
}
isPointInTriangle(px, py, x1, y1, x2, y2, x3, y3) {
const d1 = this.sign(px, py, x1, y1, x2, y2);
const d2 = this.sign(px, py, x2, y2, x3, y3);
const d3 = this.sign(px, py, x3, y3, x1, y1);
const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0);
const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(hasNeg && hasPos);
}
sign(px, py, x1, y1, x2, y2) {
return (px - x2) * (y1 - y2) - (x1 - x2) * (py - y2);
}
// Animation generators
createAnimation(animConfig, layerIndex) {
const type = animConfig.type;
const params = animConfig.params || {};
const startTime = Date.now();
switch (type) {
case 'move':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const duration = params.duration || 2.0;
const t = this.clamp((elapsed % duration) / duration, 0, 1);
return {
x: (params.startX || 0) + ((params.endX || this.width) - (params.startX || 0)) * t,
y: (params.startY || 0) + ((params.endY || this.height) - (params.startY || 0)) * t,
t
};
};
case 'rotate':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const speed = params.speed || 1.0;
return { angle: elapsed * speed * Math.PI * 2, elapsed };
};
case 'pulse':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const frequency = params.frequency || 1.0;
const t = (Math.sin(2 * Math.PI * frequency * elapsed) + 1) / 2;
const minScale = params.minScale || 0.5;
const maxScale = params.maxScale || 1.5;
return { scale: minScale + (maxScale - minScale) * t, t, elapsed };
};
case 'oscillateX':
case 'oscillateY':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const frequency = params.frequency || 0.5;
const center = params.center || (type === 'oscillateX' ? this.width / 2 : this.height / 2);
const amplitude = params.amplitude || 4;
const phase = params.phase || 0;
const value = center + amplitude * Math.sin(2 * Math.PI * frequency * elapsed + phase);
return { value, elapsed };
};
case 'fade':
return () => {
const elapsed = (Date.now() - startTime) / 1000;
const duration = params.duration || 2.0;
const t = this.clamp(elapsed / duration, 0, 1);
const fadeIn = params.fadeIn !== false;
return { intensity: fadeIn ? t : (1 - t), t, elapsed };
};
default:
return () => ({});
}
}
// Render a complete preset configuration
renderConfiguration(configuration, speed = 1.0, brightness = 1.0) {
const now = Date.now();
const deltaTime = (now - this.lastFrameTime) / 1000;
this.lastFrameTime = now;
this.time += deltaTime * speed;
let frame = this.createFrame('000000');
// Render each layer
configuration.layers?.forEach((layer, index) => {
this.renderLayer(frame, layer, index, brightness);
});
return frame;
}
renderLayer(frame, layer, layerIndex, globalBrightness) {
const type = layer.type;
if (type === 'shape') {
this.renderShapeLayer(frame, layer, layerIndex, globalBrightness);
} else if (type === 'pattern') {
this.renderPatternLayer(frame, layer, layerIndex, globalBrightness);
}
}
renderShapeLayer(frame, layer, layerIndex, globalBrightness) {
let position = layer.position || { x: this.width / 2, y: this.height / 2 };
let size = layer.size || { radius: 3 };
let rotation = 0;
let scale = 1.0;
let intensity = (layer.intensity || 1.0) * globalBrightness;
// Initialize animation if needed
if (layer.animation && !this.animationStates.has(layerIndex)) {
this.animationStates.set(layerIndex, this.createAnimation(layer.animation, layerIndex));
}
// Apply animation
if (this.animationStates.has(layerIndex)) {
const animation = this.animationStates.get(layerIndex);
const animState = animation();
if (animState.x !== undefined && animState.y !== undefined) {
position = { x: animState.x, y: animState.y };
}
if (animState.angle !== undefined) {
rotation = animState.angle;
}
if (animState.scale !== undefined) {
scale = animState.scale;
}
if (animState.intensity !== undefined) {
intensity *= animState.intensity;
}
if (animState.value !== undefined && layer.animation?.axis === 'x') {
position.x = animState.value;
} else if (animState.value !== undefined && layer.animation?.axis === 'y') {
position.y = animState.value;
}
}
// Create color generator
const colorGen = this.createColorGenerator(layer.color);
const color = typeof colorGen === 'function' ? colorGen(0.5) : colorGen;
// Apply scale to size
const scaledSize = {
radius: (size.radius || 3) * scale,
width: (size.width || 5) * scale,
height: (size.height || 5) * scale
};
// Render shape
const shapeType = layer.shape || 'circle';
switch (shapeType) {
case 'circle':
this.renderCircle(frame, position.x, position.y, scaledSize.radius, color, intensity);
break;
case 'rectangle':
this.renderRectangle(
frame,
position.x - scaledSize.width / 2,
position.y - scaledSize.height / 2,
scaledSize.width,
scaledSize.height,
color,
intensity
);
break;
case 'triangle': {
// Triangle with rotation support
const triSize = scaledSize.radius || 3;
const points = [
{ x: 0, y: -triSize },
{ x: -triSize, y: triSize },
{ x: triSize, y: triSize }
];
// Apply rotation if there's an animation with angle
const cos = Math.cos(rotation);
const sin = Math.sin(rotation);
const rotated = points.map(p => ({
x: p.x * cos - p.y * sin,
y: p.x * sin + p.y * cos
}));
this.renderTriangle(
frame,
position.x + rotated[0].x,
position.y + rotated[0].y,
position.x + rotated[1].x,
position.y + rotated[1].y,
position.x + rotated[2].x,
position.y + rotated[2].y,
color,
intensity
);
break;
}
case 'blob':
this.renderBlob(frame, position.x, position.y, scaledSize.radius, color, layer.falloffPower || 2);
break;
case 'point':
this.renderPoint(frame, position.x, position.y, color, intensity);
break;
}
}
renderPatternLayer(frame, layer, layerIndex, globalBrightness) {
// Simplified pattern rendering for canvas
const patternType = layer.pattern;
const intensity = (layer.intensity || 1.0) * globalBrightness;
if (patternType === 'radial' || patternType === 'spiral') {
const params = layer.params || {};
const centerX = params.centerX || this.width / 2;
const centerY = params.centerY || this.height / 2;
const maxRadius = Math.hypot(this.width / 2, this.height / 2);
const colorGen = this.createColorGenerator(layer.color);
for (let row = 0; row < this.height; row++) {
for (let col = 0; col < this.width; col++) {
const dx = col - centerX;
const dy = row - centerY;
const distance = Math.hypot(dx, dy);
const angle = Math.atan2(dy, dx);
let t;
if (patternType === 'spiral') {
const arms = params.arms || 5;
const rotationSpeed = params.rotationSpeed || 1.0;
const spiralValue = (Math.sin(arms * (angle + this.time * rotationSpeed)) + 1) / 2;
const radiusValue = distance / maxRadius;
t = this.clamp(spiralValue * 0.5 + radiusValue * 0.5, 0, 1);
} else {
t = this.clamp(distance / maxRadius, 0, 1);
}
const color = colorGen(t);
// Use setPixelColor for proper layer compositing
this.setPixelColor(frame, col, row, color, intensity);
}
}
}
}
// Draw frame to canvas
drawFrame(frame) {
// Clear canvas first
this.ctx.fillStyle = '#000000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const pixelWidth = this.canvas.width / this.width;
const pixelHeight = this.canvas.height / this.height;
for (let row = 0; row < this.height; row++) {
for (let col = 0; col < this.width; col++) {
const index = this.toIndex(col, row);
const color = frame[index];
if (color) {
const rgb = this.hexToRgb(color);
this.ctx.fillStyle = `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
this.ctx.fillRect(col * pixelWidth, row * pixelHeight, pixelWidth, pixelHeight);
}
}
}
}
clear() {
this.ctx.fillStyle = '#000000';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
reset() {
this.animationStates.clear();
this.time = 0;
this.lastFrameTime = Date.now();
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = CanvasRenderer;
}

File diff suppressed because it is too large Load Diff

View File

@@ -685,7 +685,7 @@ body {
/* Preset controls */
.preset-select {
width: 100%;
background: var(--bg-tertiary);
background: var(--bg-secondary);
border: 1px solid var(--border-secondary);
border-radius: 6px;
padding: 0.5rem;
@@ -698,7 +698,7 @@ body {
}
.preset-select option {
background: var(--bg-secondary);
background: #1a252f;
color: var(--text-primary);
padding: 0.5rem;
}
@@ -754,6 +754,19 @@ body {
font-weight: 600;
}
.preset-label:has(input[type="checkbox"]) {
text-transform: none;
font-size: 0.8rem;
display: flex;
align-items: center;
cursor: pointer;
}
.preset-label input[type="checkbox"] {
cursor: pointer;
accent-color: var(--accent-primary);
}
.preset-input {
width: 100%;
background: var(--bg-tertiary);
@@ -1264,3 +1277,900 @@ body {
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
/* ============================================
PRESET EDITOR STYLES
============================================ */
.editor-container {
padding: 2rem;
max-width: 100%;
margin: 0 auto;
}
/* Modern Editor Container - Full Screen */
.editor-container-modern {
padding: 0;
margin: 0;
height: calc(100vh - 70px);
width: 100vw;
max-width: 100vw;
overflow: hidden;
}
.editor-header {
margin-bottom: 2rem;
text-align: center;
}
.editor-header h2 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.editor-subtitle {
color: var(--text-secondary);
font-size: 0.95rem;
margin-bottom: 1.5rem;
}
.editor-preview-controls {
display: flex;
justify-content: center;
align-items: center;
gap: 2rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-primary);
margin-top: 1rem;
}
.preview-control-group {
display: flex;
align-items: center;
gap: 0.75rem;
}
.preview-control-group label {
color: var(--text-secondary);
font-size: 0.9rem;
font-weight: 500;
}
.node-select {
padding: 0.6rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.9rem;
min-width: 200px;
cursor: pointer;
transition: all 0.2s ease;
}
.node-select option {
background: #1a252f;
color: var(--text-primary);
padding: 0.5rem;
}
.node-select:focus {
outline: none;
border-color: var(--accent-primary);
}
.preview-status {
padding: 0.4rem 0.8rem;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-secondary);
border: 1px solid var(--border-primary);
}
.preview-status.active {
background: rgba(74, 222, 128, 0.1);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.preview-status.streaming {
background: rgba(96, 165, 250, 0.1);
border-color: var(--accent-secondary);
color: var(--accent-secondary);
}
.editor-layout {
display: grid;
grid-template-columns: 300px 1fr 350px;
gap: 1.5rem;
min-height: calc(100vh - 300px);
}
/* Modern Layout - Full Screen (50/50 Split) */
.editor-layout-modern {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
height: 100%;
width: 100%;
background: var(--bg-primary);
}
/* Live Preview Canvas */
.editor-preview-container {
position: relative;
background: var(--bg-tertiary);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.editor-canvas {
border: 2px solid var(--border-primary);
border-radius: 4px;
background: #000000;
image-rendering: pixelated;
image-rendering: crisp-edges;
width: 100%;
max-width: 400px;
height: auto;
aspect-ratio: 1;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.editor-canvas-info {
display: flex;
justify-content: space-between;
width: 100%;
max-width: 400px;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-secondary);
}
.editor-canvas-info span {
font-family: 'Monaco', 'Courier New', monospace;
}
/* Modern Preview Bar */
.editor-preview-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-primary);
gap: 1rem;
flex-shrink: 0;
}
.preview-bar-controls {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
}
.preview-bar-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.node-select-compact {
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
min-width: 150px;
cursor: pointer;
transition: all 0.2s ease;
}
.node-select-compact option {
background: #1a252f;
color: var(--text-primary);
padding: 0.5rem;
}
.node-select-compact:focus {
outline: none;
border-color: var(--accent-primary);
}
.btn-preview {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, var(--accent-primary) 0%, #22c55e 100%);
border: none;
border-radius: 6px;
color: white;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-preview:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.3);
}
.btn-preview.active {
background: linear-gradient(135deg, var(--accent-error) 0%, #dc2626 100%);
}
.btn-preview.active:hover {
box-shadow: 0 4px 12px rgba(248, 113, 113, 0.3);
}
.preview-icon {
font-size: 1rem;
}
.preview-text {
font-weight: 600;
}
.preview-status-compact {
padding: 0.35rem 0.75rem;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 0.8rem;
color: var(--text-secondary);
border: 1px solid var(--border-primary);
font-weight: 500;
}
.preview-status-compact.active {
background: rgba(74, 222, 128, 0.1);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.preview-status-compact.streaming {
background: rgba(96, 165, 250, 0.1);
border-color: var(--accent-secondary);
color: var(--accent-secondary);
}
.info-badge {
padding: 0.35rem 0.75rem;
background: var(--bg-tertiary);
border-radius: 6px;
font-size: 0.8rem;
color: var(--text-secondary);
font-family: 'Monaco', 'Courier New', monospace;
font-weight: 500;
}
/* Preview Container with Layers */
.editor-preview-container-modern {
flex: 1;
display: flex;
gap: 0;
overflow: hidden;
min-height: 0;
}
/* Layers Sidebar (Left of Preview) */
.editor-layers-sidebar {
width: 240px;
background: var(--bg-secondary);
border-right: 1px solid var(--border-primary);
padding: 1rem;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
/* Modern Canvas Preview - Full Right Panel */
.editor-preview-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
padding: 2rem;
overflow: hidden;
min-height: 0;
min-width: 0;
}
/* Full width button utility */
.btn-full-width {
width: 100%;
}
.editor-canvas-modern {
background: #000000;
image-rendering: pixelated;
image-rendering: crisp-edges;
border: 2px solid var(--border-primary);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
width: min(95%, 90vh);
height: min(95%, 90vh);
aspect-ratio: 1;
max-width: 1000px;
max-height: 1000px;
}
/* Layers Section (deprecated - keeping for backward compatibility) */
.layers-section {
border-top: 1px solid var(--border-primary);
padding: 1rem;
max-height: 250px;
min-height: 150px;
overflow-y: auto;
overflow-x: hidden;
flex-shrink: 0;
}
.layer-details-content {
padding: 0;
}
/* Ensure scrolling works */
.editor-left-panel::-webkit-scrollbar,
.editor-layer-list-expandable::-webkit-scrollbar,
.layers-section::-webkit-scrollbar {
width: 6px;
}
.editor-left-panel::-webkit-scrollbar-track,
.editor-layer-list-expandable::-webkit-scrollbar-track,
.layers-section::-webkit-scrollbar-track {
background: transparent;
}
.editor-left-panel::-webkit-scrollbar-thumb,
.editor-layer-list-expandable::-webkit-scrollbar-thumb,
.layers-section::-webkit-scrollbar-thumb {
background: var(--border-primary);
border-radius: 3px;
}
.editor-left-panel::-webkit-scrollbar-thumb:hover,
.editor-layer-list-expandable::-webkit-scrollbar-thumb:hover,
.layers-section::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
.editor-sidebar,
.editor-main,
.editor-details {
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 12px;
padding: 1.5rem;
backdrop-filter: var(--backdrop-blur);
box-shadow: var(--shadow-secondary);
overflow-y: auto;
max-height: calc(100vh - 200px);
}
/* Modern Panels - Two Panel Layout */
.editor-left-panel {
background: var(--bg-secondary);
border-right: 1px solid var(--border-primary);
padding: 1rem;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1rem;
}
.editor-right-panel {
background: var(--bg-primary);
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
min-width: 0;
}
/* Compact Sections */
.editor-section-compact {
margin-bottom: 0;
}
.editor-layers-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.editor-layers-section .section-title-compact {
flex-shrink: 0;
}
.section-title-compact {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.65rem;
color: var(--text-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.85;
}
.editor-section {
margin-bottom: 1.5rem;
}
.editor-section:last-child {
margin-bottom: 0;
}
.editor-section h3 {
font-size: 1.1rem;
margin-bottom: 1rem;
color: var(--text-primary);
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-primary);
}
.editor-input-wrapper {
margin-bottom: 0.75rem;
}
.editor-input-wrapper label {
display: block;
margin-bottom: 0.35rem;
color: var(--text-secondary);
font-size: 0.8rem;
font-weight: 500;
}
.editor-input-wrapper input[type="text"],
.editor-input-wrapper input[type="number"],
.editor-input-wrapper textarea,
.editor-input-wrapper select {
width: 100%;
padding: 0.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
transition: all 0.2s ease;
}
.editor-input-wrapper input[type="text"]:focus,
.editor-input-wrapper input[type="number"]:focus,
.editor-input-wrapper textarea:focus,
.editor-input-wrapper select:focus {
outline: none;
border-color: var(--accent-primary);
background: var(--bg-hover);
}
.editor-input-wrapper select option {
background: #1a252f;
color: var(--text-primary);
padding: 0.5rem;
}
.editor-input-wrapper input[type="color"] {
width: 100%;
height: 40px;
padding: 0.2rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
border-radius: 6px;
cursor: pointer;
}
.editor-input-wrapper textarea {
resize: vertical;
min-height: 60px;
}
.editor-type-options {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 6px;
border: 1px solid var(--border-primary);
}
.editor-type-options.hidden {
display: none;
}
.editor-actions {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.editor-actions button,
.editor-actions label {
flex: 1;
}
.editor-actions select {
width: 100%;
padding: 0.6rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
}
.editor-actions select option {
background: #1a252f;
color: var(--text-primary);
padding: 0.5rem;
}
.editor-actions select:focus {
outline: none;
border-color: var(--accent-primary);
}
/* Layer List - Expandable Style */
.editor-layer-list-expandable {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
padding-right: 0.25rem;
}
/* Old Layer List (deprecated) */
.editor-layer-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex: 1;
overflow-y: auto;
}
/* Expandable Layer Item */
.editor-layer-item {
background: var(--bg-tertiary);
border: 2px solid var(--border-primary);
border-radius: 8px;
overflow: visible;
transition: all 0.2s ease;
flex-shrink: 0;
}
.editor-layer-item:hover {
border-color: var(--border-hover);
}
.editor-layer-item.expanded {
border-color: var(--accent-primary);
background: rgba(74, 222, 128, 0.05);
}
/* Layer Header (Always Visible) */
.editor-layer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
cursor: pointer;
user-select: none;
transition: background 0.2s ease;
}
.editor-layer-header:hover {
background: var(--bg-hover);
}
.editor-layer-header-left {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
}
.editor-layer-expand-icon {
font-size: 0.7rem;
color: var(--text-secondary);
transition: transform 0.2s ease;
}
.editor-layer-item.expanded .editor-layer-expand-icon {
transform: rotate(90deg);
}
.editor-layer-info {
flex: 1;
}
.editor-layer-title {
font-weight: 500;
color: var(--text-primary);
font-size: 0.9rem;
}
.editor-layer-subtitle {
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.15rem;
}
.editor-layer-buttons {
display: flex;
gap: 0.3rem;
}
/* Layer Content (Expandable) */
.editor-layer-content {
display: none;
padding: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-primary);
background: var(--bg-secondary);
max-height: none;
overflow: visible;
}
.editor-layer-item.expanded .editor-layer-content {
display: block;
}
.btn-small {
padding: 0.4rem 0.6rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s ease;
}
.btn-small:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--text-primary);
}
.btn-small:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-small.btn-danger:hover:not(:disabled) {
border-color: var(--accent-error);
color: var(--accent-error);
}
/* Layer Details Panel */
.editor-group {
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 6px;
border: 1px solid var(--border-primary);
}
.editor-group h4 {
font-size: 0.85rem;
margin-bottom: 0.65rem;
color: var(--accent-primary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.animation-params {
margin-top: 0.5rem;
}
.color-stops-container {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.color-stop {
padding: 0.8rem;
background: var(--bg-secondary);
border-radius: 4px;
border: 1px solid var(--border-primary);
}
.editor-empty-state {
text-align: center;
color: var(--text-muted);
padding: 2rem;
font-size: 0.95rem;
}
/* Button Variants */
.btn-success {
background: linear-gradient(135deg, var(--accent-success) 0%, #45a049 100%);
color: white;
}
.btn-success:hover {
background: linear-gradient(135deg, #45a049 0%, #3d8b40 100%);
}
.btn-danger {
background: linear-gradient(135deg, var(--accent-error) 0%, #dc2626 100%);
color: white;
}
.btn-danger:hover {
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
}
/* Notifications */
.notification {
position: fixed;
top: 100px;
right: 20px;
padding: 1rem 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 8px;
box-shadow: var(--shadow-primary);
backdrop-filter: var(--backdrop-blur);
color: var(--text-primary);
font-size: 0.95rem;
z-index: 10000;
opacity: 0;
transform: translateX(400px);
transition: all 0.3s ease;
}
.notification.show {
opacity: 1;
transform: translateX(0);
}
.notification-success {
border-left: 4px solid var(--accent-success);
}
.notification-error {
border-left: 4px solid var(--accent-error);
}
.notification-info {
border-left: 4px solid var(--accent-secondary);
}
/* Responsive Design */
@media (max-width: 1800px) {
.editor-layout {
grid-template-columns: 280px 1fr 320px;
}
}
@media (max-width: 1400px) {
.editor-canvas-modern {
width: min(85%, 85vh);
height: min(85%, 85vh);
}
}
@media (max-width: 1200px) {
.editor-layout {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
/* Stack vertically on mobile */
.editor-layout-modern {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.editor-sidebar,
.editor-main,
.editor-details {
max-height: none;
}
.editor-left-panel {
border: none;
border-bottom: 1px solid var(--border-primary);
max-height: 50vh;
}
.editor-right-panel {
min-height: 50vh;
}
.editor-preview-controls {
flex-direction: column;
gap: 1rem;
}
.preview-control-group {
width: 100%;
justify-content: center;
}
.node-select {
min-width: 250px;
}
.preview-bar-controls {
flex-wrap: wrap;
}
}
@media (max-width: 768px) {
.editor-container {
padding: 1rem;
}
.editor-header h2 {
font-size: 1.5rem;
}
.editor-subtitle {
font-size: 0.85rem;
}
.editor-canvas {
max-width: 100%;
}
.editor-container-modern {
height: calc(100vh - 60px);
}
.editor-preview-bar {
flex-direction: column;
align-items: stretch;
}
.preview-bar-controls {
justify-content: stretch;
}
.node-select-compact {
flex: 1;
min-width: 0;
}
.btn-preview {
flex: 1;
}
.preview-bar-info {
justify-content: center;
}
}
/* Hidden utility */
.hidden {
display: none !important;
}