Adding manual image background
This commit is contained in:
parent
f8d45f957a
commit
0d09907da1
7 changed files with 233 additions and 1328 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -330,6 +330,75 @@
|
|||
cursor: pointer;
|
||||
accent-color: #0096ff;
|
||||
}
|
||||
|
||||
// Custom file input styling
|
||||
input[type="file"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid rgba(0, 150, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: rgba(5, 15, 30, 0.7);
|
||||
color: rgba(200, 220, 255, 0.95);
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
|
||||
&::file-selector-button {
|
||||
padding: 0.5rem 1rem;
|
||||
margin-right: 1rem;
|
||||
border: 1px solid rgba(0, 150, 255, 0.5);
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 120, 200, 0.3) 0%,
|
||||
rgba(0, 100, 180, 0.2) 100%
|
||||
);
|
||||
color: #66ccff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 150, 255, 0.4) 0%,
|
||||
rgba(255, 100, 0, 0.3) 100%
|
||||
);
|
||||
border-color: rgba(0, 150, 255, 0.8);
|
||||
box-shadow:
|
||||
0 0 15px rgba(0, 150, 255, 0.4),
|
||||
0 0 20px rgba(255, 120, 0, 0.2);
|
||||
color: #fff;
|
||||
text-shadow: 0 0 10px rgba(0, 150, 255, 0.6);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(0, 150, 255, 0.7);
|
||||
box-shadow:
|
||||
0 0 15px rgba(0, 150, 255, 0.3),
|
||||
inset 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
background: rgba(5, 15, 30, 0.9);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: rgba(0, 150, 255, 0.7);
|
||||
box-shadow:
|
||||
0 0 15px rgba(0, 150, 255, 0.3),
|
||||
inset 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
background: rgba(5, 15, 30, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-group-header {
|
||||
|
|
|
|||
|
|
@ -149,6 +149,18 @@ export class ButtonGenerator {
|
|||
values[id] = element.checked;
|
||||
} else if (element.type === 'range' || element.type === 'number') {
|
||||
values[id] = parseFloat(element.value);
|
||||
} else if (element.type === 'file') {
|
||||
// For file inputs, return an object with file metadata and blob URL
|
||||
if (element.dataset.blobUrl) {
|
||||
values[id] = {
|
||||
fileName: element.dataset.fileName,
|
||||
blobUrl: element.dataset.blobUrl,
|
||||
fileSize: parseInt(element.dataset.fileSize),
|
||||
fileType: element.dataset.fileType
|
||||
};
|
||||
} else {
|
||||
values[id] = null;
|
||||
}
|
||||
} else {
|
||||
values[id] = element.value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,12 +37,13 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
|
|||
defineControls() {
|
||||
return [
|
||||
{
|
||||
id: 'bg-image-url',
|
||||
type: 'text',
|
||||
label: 'Image URL',
|
||||
id: 'bg-image-file',
|
||||
type: 'file',
|
||||
label: 'Image File',
|
||||
defaultValue: '',
|
||||
accept: 'image/*',
|
||||
showWhen: 'bg-type',
|
||||
description: 'URL of the image to use as background'
|
||||
description: 'Select an image file from your computer'
|
||||
},
|
||||
{
|
||||
id: 'bg-image-fit',
|
||||
|
|
@ -53,11 +54,45 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
|
|||
{ value: 'cover', label: 'Cover (fill, crop if needed)' },
|
||||
{ value: 'contain', label: 'Contain (fit inside)' },
|
||||
{ value: 'stretch', label: 'Stretch (may distort)' },
|
||||
{ value: 'center', label: 'Center (actual size)' }
|
||||
{ value: 'center', label: 'Center (actual size)' },
|
||||
{ value: 'manual', label: 'Manual (custom zoom & position)' }
|
||||
],
|
||||
showWhen: 'bg-type',
|
||||
description: 'How the image should fit in the canvas'
|
||||
},
|
||||
{
|
||||
id: 'bg-image-zoom',
|
||||
type: 'range',
|
||||
label: 'Image Zoom',
|
||||
defaultValue: 100,
|
||||
min: 10,
|
||||
max: 500,
|
||||
step: 5,
|
||||
showWhen: 'bg-image-fit',
|
||||
description: 'Zoom level for manual positioning (percentage)'
|
||||
},
|
||||
{
|
||||
id: 'bg-image-offset-x',
|
||||
type: 'range',
|
||||
label: 'Horizontal Position',
|
||||
defaultValue: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
showWhen: 'bg-image-fit',
|
||||
description: 'Horizontal position of the image (percentage)'
|
||||
},
|
||||
{
|
||||
id: 'bg-image-offset-y',
|
||||
type: 'range',
|
||||
label: 'Vertical Position',
|
||||
defaultValue: 50,
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
showWhen: 'bg-image-fit',
|
||||
description: 'Vertical position of the image (percentage)'
|
||||
},
|
||||
{
|
||||
id: 'bg-image-opacity',
|
||||
type: 'range',
|
||||
|
|
@ -77,24 +112,24 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
|
|||
}
|
||||
|
||||
/**
|
||||
* Start loading an image from a URL with caching
|
||||
* Start loading an image from a blob URL with caching
|
||||
* Triggers async loading but returns immediately
|
||||
* @param {string} url - Image URL
|
||||
* @param {string} blobUrl - Blob URL created from uploaded file
|
||||
*/
|
||||
startLoadingImage(url) {
|
||||
startLoadingImage(blobUrl) {
|
||||
// Skip if already cached or loading
|
||||
if (this.imageCache.has(url) || this.loadingImages.has(url)) {
|
||||
if (this.imageCache.has(blobUrl) || this.loadingImages.has(blobUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as loading to prevent duplicate requests
|
||||
this.loadingImages.set(url, true);
|
||||
this.loadingImages.set(blobUrl, true);
|
||||
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
this.imageCache.set(url, img);
|
||||
this.loadingImages.delete(url);
|
||||
this.imageCache.set(blobUrl, img);
|
||||
this.loadingImages.delete(blobUrl);
|
||||
// Trigger a redraw when image loads
|
||||
const event = new CustomEvent('imageLoaded');
|
||||
window.dispatchEvent(event);
|
||||
|
|
@ -102,29 +137,29 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
|
|||
|
||||
img.onerror = () => {
|
||||
// Cache the error state to prevent retry spam
|
||||
this.imageCache.set(url, 'ERROR');
|
||||
this.loadingImages.delete(url);
|
||||
this.imageCache.set(blobUrl, 'ERROR');
|
||||
this.loadingImages.delete(blobUrl);
|
||||
// Trigger a redraw to show error state
|
||||
const event = new CustomEvent('imageLoaded');
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
img.src = blobUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached image if available
|
||||
* @param {string} url - Image URL
|
||||
* @param {string} blobUrl - Blob URL
|
||||
* @returns {HTMLImageElement|string|null} Image element, 'ERROR', or null
|
||||
*/
|
||||
getCachedImage(url) {
|
||||
return this.imageCache.get(url) || null;
|
||||
getCachedImage(blobUrl) {
|
||||
return this.imageCache.get(blobUrl) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw image with the specified fit mode
|
||||
*/
|
||||
drawImage(context, img, fitMode, opacity, width, height) {
|
||||
drawImage(context, img, fitMode, opacity, width, height, zoom, offsetX, offsetY) {
|
||||
context.globalAlpha = opacity;
|
||||
|
||||
const imgRatio = img.width / img.height;
|
||||
|
|
@ -174,6 +209,31 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
|
|||
drawY = (height - drawHeight) / 2;
|
||||
break;
|
||||
|
||||
case 'manual':
|
||||
// Manual positioning with zoom and offset controls
|
||||
const zoomFactor = zoom / 100;
|
||||
|
||||
// Start with the larger dimension to ensure coverage
|
||||
if (imgRatio > canvasRatio) {
|
||||
// Image is wider relative to canvas
|
||||
drawHeight = height * zoomFactor;
|
||||
drawWidth = drawHeight * imgRatio;
|
||||
} else {
|
||||
// Image is taller relative to canvas
|
||||
drawWidth = width * zoomFactor;
|
||||
drawHeight = drawWidth / imgRatio;
|
||||
}
|
||||
|
||||
// Calculate the range of movement (how far can we move the image)
|
||||
const maxOffsetX = drawWidth - width;
|
||||
const maxOffsetY = drawHeight - height;
|
||||
|
||||
// Convert percentage (0-100) to actual position
|
||||
// 0% = image fully left/top, 100% = image fully right/bottom
|
||||
drawX = -(maxOffsetX * offsetX / 100);
|
||||
drawY = -(maxOffsetY * offsetY / 100);
|
||||
break;
|
||||
|
||||
case 'stretch':
|
||||
// Stretch to fill (default values already set)
|
||||
break;
|
||||
|
|
@ -184,12 +244,15 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
|
|||
}
|
||||
|
||||
apply(context, controlValues, animState, renderData) {
|
||||
const url = controlValues['bg-image-url'] || '';
|
||||
const file = controlValues['bg-image-file'];
|
||||
const fitMode = controlValues['bg-image-fit'] || 'cover';
|
||||
const opacity = controlValues['bg-image-opacity'] ?? 1;
|
||||
const zoom = controlValues['bg-image-zoom'] ?? 100;
|
||||
const offsetX = controlValues['bg-image-offset-x'] ?? 50;
|
||||
const offsetY = controlValues['bg-image-offset-y'] ?? 50;
|
||||
|
||||
// If no URL provided, fill with a placeholder color
|
||||
if (!url.trim()) {
|
||||
// If no file selected, fill with a placeholder color
|
||||
if (!file || !file.blobUrl) {
|
||||
context.fillStyle = '#cccccc';
|
||||
context.fillRect(0, 0, renderData.width, renderData.height);
|
||||
|
||||
|
|
@ -198,16 +261,16 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
|
|||
context.font = '8px Arial';
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText('Enter image URL', renderData.centerX, renderData.centerY);
|
||||
context.fillText('Select an image', renderData.centerX, renderData.centerY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start loading if not already cached or loading
|
||||
const cachedImage = this.getCachedImage(url);
|
||||
const cachedImage = this.getCachedImage(file.blobUrl);
|
||||
|
||||
if (!cachedImage) {
|
||||
// Not cached yet - start loading
|
||||
this.startLoadingImage(url);
|
||||
this.startLoadingImage(file.blobUrl);
|
||||
|
||||
// Draw loading state
|
||||
context.fillStyle = '#3498db';
|
||||
|
|
@ -236,7 +299,7 @@ export class ExternalImageBackgroundEffect extends ButtonEffect {
|
|||
}
|
||||
|
||||
// Image is loaded - draw it
|
||||
this.drawImage(context, cachedImage, fitMode, opacity, renderData.width, renderData.height);
|
||||
this.drawImage(context, cachedImage, fitMode, opacity, renderData.width, renderData.height, zoom, offsetX, offsetY);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export class SolidBackgroundEffect extends ButtonEffect {
|
|||
{ value: "gradient", label: "Gradient" },
|
||||
{ value: "texture", label: "Texture" },
|
||||
{ value: "emoji-wallpaper", label: "Emoji Wallpaper" },
|
||||
// { value: 'external-image', label: 'External Image' }
|
||||
{ value: 'external-image', label: 'Image Upload' }
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ async function setupApp() {
|
|||
gradientBg.register(generator);
|
||||
textureBg.register(generator);
|
||||
emojiWallpaper.register(generator);
|
||||
//externalImage.register(generator);
|
||||
externalImage.register(generator);
|
||||
rainbowBg.register(generator);
|
||||
rain.register(generator);
|
||||
starfield.register(generator);
|
||||
|
|
|
|||
|
|
@ -261,6 +261,9 @@ export class UIBuilder {
|
|||
case 'text':
|
||||
return this.createTextInput(id, label, defaultValue, showWhen, description);
|
||||
|
||||
case 'file':
|
||||
return this.createFileInput(id, label, defaultValue, showWhen, description, controlDef.accept);
|
||||
|
||||
default:
|
||||
console.warn(`Unknown control type: ${type}`);
|
||||
return null;
|
||||
|
|
@ -438,6 +441,57 @@ export class UIBuilder {
|
|||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file input control
|
||||
*/
|
||||
createFileInput(id, label, defaultValue, showWhen, description, accept) {
|
||||
const container = document.createElement('div');
|
||||
|
||||
const labelEl = document.createElement('label');
|
||||
labelEl.htmlFor = id;
|
||||
labelEl.textContent = label;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.id = id;
|
||||
if (accept) {
|
||||
input.accept = accept;
|
||||
}
|
||||
|
||||
// Store the file data on the input element
|
||||
input.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Create a blob URL for the file
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
// Store file metadata on the input element
|
||||
input.dataset.fileName = file.name;
|
||||
input.dataset.blobUrl = blobUrl;
|
||||
input.dataset.fileSize = file.size;
|
||||
input.dataset.fileType = file.type;
|
||||
} else {
|
||||
// Clear the data if no file is selected
|
||||
delete input.dataset.fileName;
|
||||
delete input.dataset.blobUrl;
|
||||
delete input.dataset.fileSize;
|
||||
delete input.dataset.fileType;
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(labelEl);
|
||||
container.appendChild(input);
|
||||
|
||||
if (showWhen) {
|
||||
container.style.display = 'none';
|
||||
container.dataset.showWhen = showWhen;
|
||||
}
|
||||
|
||||
// Add tooltip handlers to the label
|
||||
this.addTooltipHandlers(labelEl, description);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup conditional visibility for controls
|
||||
* Should be called after all controls are created
|
||||
|
|
@ -468,10 +522,16 @@ export class UIBuilder {
|
|||
control.style.display = triggerControl.value === 'texture' ? 'block' : 'none';
|
||||
} else if (controlId && (controlId.startsWith('emoji-') || controlId === 'emoji-text')) {
|
||||
control.style.display = triggerControl.value === 'emoji-wallpaper' ? 'block' : 'none';
|
||||
} else if (controlId && (controlId.startsWith('bg-image-') || controlId === 'bg-image-url' || controlId === 'bg-image-fit' || controlId === 'bg-image-opacity')) {
|
||||
} else if (controlId && (controlId.startsWith('bg-image-') || controlId === 'bg-image-file' || controlId === 'bg-image-fit' || controlId === 'bg-image-opacity')) {
|
||||
control.style.display = triggerControl.value === 'external-image' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
// For image fit controls (zoom and position only show when manual mode)
|
||||
else if (triggerControlId === 'bg-image-fit') {
|
||||
if (controlId && (controlId === 'bg-image-zoom' || controlId === 'bg-image-offset-x' || controlId === 'bg-image-offset-y')) {
|
||||
control.style.display = triggerControl.value === 'manual' ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
// For text color controls
|
||||
else if (triggerControlId === 'text-color-type') {
|
||||
if (controlId === 'text-color') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue