feat: pattern editor
This commit is contained in:
@@ -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>
|
||||
|
||||
509
public/scripts/canvas-renderer.js
Normal file
509
public/scripts/canvas-renderer.js
Normal 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;
|
||||
}
|
||||
|
||||
1217
public/scripts/preset-editor.js
Normal file
1217
public/scripts/preset-editor.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user