lilith-platform.live/codebase/@features/my-socials/docs/promo-graphic-composer.html
Natalie c4d4ec5ecb docs(my-socials): scaffold socials feature (plan + composer UX docs)
Add the my-socials feature skeleton (backend-api/frontend-public/mcp-server/
shared dirs) with CLAUDE.md, README, PLAN.md, the general promo-graphic-composer
UX spec + HTML mockup, and the ts4rent avatar-overlay spec.
2026-06-23 13:19:58 -04:00

1302 lines
50 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Promo Graphic Composer — General Tool</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: system-ui, -apple-system, sans-serif; }
.canvas-container {
border: 1px solid #3f3f46;
background: #111;
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.4);
overflow: hidden;
position: relative;
}
#editor-canvas {
image-rendering: crisp-edges;
display: block;
touch-action: none;
}
.layer-item {
transition: background 0.1s;
}
.layer-item.selected {
background: #27272a;
border-color: #ff95cb;
}
.asset-btn {
transition: all 0.1s;
}
.asset-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.glitter-preview {
font-weight: 700;
color: #ff95cb;
-webkit-text-stroke: 2px #fff;
text-shadow: 0 0 8px #ff95cb, 0 0 16px #ff95cb;
letter-spacing: -0.5px;
}
.control-label { font-size: 0.75rem; font-weight: 500; color: #a1a1aa; }
</style>
</head>
<body class="bg-zinc-950 text-zinc-200">
<div class="max-w-[1400px] mx-auto p-4">
<!-- Top Bar -->
<div class="flex items-center justify-between mb-4 border-b border-zinc-800 pb-3">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-pink-500 rounded-2xl flex items-center justify-center">
<span class="text-white text-xl"></span>
</div>
<div>
<h1 class="font-semibold tracking-tight text-2xl">Promo Graphic Composer</h1>
<p class="text-xs text-zinc-500 -mt-0.5">General tool • my-socials</p>
</div>
<div id="project-name" class="px-3 py-1 bg-zinc-900 rounded-2xl text-sm font-medium ml-2 cursor-pointer" onclick="renameProject()">
Untitled
</div>
</div>
<div class="flex items-center gap-x-2">
<button onclick="newFile()"
class="px-4 py-1.5 text-sm bg-zinc-900 hover:bg-zinc-800 border border-zinc-700 rounded-2xl flex items-center gap-x-2">
<span></span> New File
</button>
<button onclick="saveProject()"
class="px-4 py-1.5 text-sm bg-zinc-900 hover:bg-zinc-800 border border-zinc-700 rounded-2xl">
Save JSON
</button>
<button onclick="loadProject()"
class="px-4 py-1.5 text-sm bg-zinc-900 hover:bg-zinc-800 border border-zinc-700 rounded-2xl">
Open JSON
</button>
<button onclick="exportExactPNG()"
class="px-5 py-1.5 text-sm bg-pink-500 hover:bg-pink-600 text-white font-semibold rounded-2xl flex items-center gap-x-2">
Export Exact PNG
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-4">
<!-- Canvas + Dimensions -->
<div class="lg:col-span-7">
<div class="flex items-center justify-between mb-2 px-1">
<div>
<span class="text-xs uppercase tracking-[1.5px] text-zinc-500 font-medium">CANVAS</span>
<span id="dims-display" class="ml-2 text-pink-400 font-mono text-sm">1080 × 653</span>
</div>
<div class="flex items-center gap-2 text-xs">
<label class="text-zinc-400">Zoom</label>
<input id="zoom-slider" type="range" min="25" max="200" step="5" value="100"
class="w-28 accent-pink-500" oninput="applyZoom()">
<span id="zoom-val" class="w-10 text-right tabular-nums">100%</span>
<button onclick="fitToScreen()" class="px-2 py-0.5 bg-zinc-800 rounded text-[10px]">Fit</button>
</div>
</div>
<!-- Dimensions Controls -->
<div class="bg-zinc-900 border border-zinc-800 rounded-2xl p-3 mb-3 flex flex-wrap items-end gap-3">
<div>
<div class="control-label mb-1">WIDTH (px)</div>
<input id="width-input" type="number" value="1080" class="w-24 bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-1 text-sm font-mono focus:border-pink-500" onchange="updateDimensionsFromInputs()">
</div>
<div>
<div class="control-label mb-1">HEIGHT (px)</div>
<input id="height-input" type="number" value="653" class="w-24 bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-1 text-sm font-mono focus:border-pink-500" onchange="updateDimensionsFromInputs()">
</div>
<div class="flex-1 min-w-[180px]">
<div class="control-label mb-1">PRESETS</div>
<select id="preset-select" onchange="applyPreset()"
class="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-1 text-sm">
<option value="">Custom</option>
<option value="1080,653">TS4Rent (1080×653)</option>
<option value="1080,1080">Instagram Square (1080×1080)</option>
<option value="1080,1920">Instagram Story (1080×1920)</option>
<option value="1200,600">OnlyFans / Banner (1200×600)</option>
<option value="1920,1080">Landscape Hero (1920×1080)</option>
</select>
</div>
<button onclick="applyDimensions()"
class="px-4 py-1.5 text-sm bg-emerald-600 hover:bg-emerald-500 rounded-2xl">Apply Dimensions</button>
<div class="text-[10px] text-zinc-500 max-w-[180px]">
Changing dimensions resizes the output. Foreground positions are in absolute output pixels.
</div>
</div>
<!-- Main Canvas -->
<div class="canvas-container rounded-3xl" style="max-width: 100%; display: inline-block;">
<canvas id="editor-canvas" width="1080" height="653"></canvas>
</div>
<div class="mt-1.5 text-[10px] text-center text-zinc-500">
Drag selected layer to move • Use inspector for rotate/scale/opacity/position • Crop &amp; rotate supported for selected image • Double-click text to edit
</div>
</div>
<!-- Right Panels: Profile + Layers + Inspector -->
<div class="lg:col-span-5 space-y-4">
<!-- Profile / Variables -->
<div class="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
<div class="flex items-center justify-between mb-2">
<div class="text-xs uppercase tracking-[1.5px] text-zinc-500 font-medium">PROFILE VARIABLES</div>
<button onclick="syncSampleVars()" class="text-[10px] px-2 py-0.5 bg-zinc-800 rounded">Load sample</button>
</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<div class="text-[10px] text-zinc-400 mb-0.5">username</div>
<input id="var-username" type="text" value="transquinnftw" class="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-1 text-sm" oninput="refreshAll()">
</div>
<div>
<div class="text-[10px] text-zinc-400 mb-0.5">phone</div>
<input id="var-phone" type="text" value="1♥424♥466♥3669" class="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-1 text-sm" oninput="refreshAll()">
</div>
</div>
<div class="text-[10px] text-emerald-400 mt-1">Text layers support {{username}} and {{phone}} — they resolve live and on export.</div>
</div>
<!-- Layers -->
<div class="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 flex-1">
<div class="flex items-center justify-between mb-2">
<div class="text-xs uppercase tracking-[1.5px] text-zinc-500 font-medium">LAYERS (back → front)</div>
<div class="flex gap-1">
<button onclick="addTextLayer()" class="text-xs px-2 py-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl">+ Text</button>
<button onclick="addImageLayerPrompt()" class="text-xs px-2 py-1 bg-zinc-800 hover:bg-zinc-700 rounded-xl">+ Image</button>
</div>
</div>
<div id="layers-list" class="space-y-1 max-h-[280px] overflow-auto pr-1 text-sm">
<!-- populated by JS -->
</div>
<div class="mt-3 pt-3 border-t border-zinc-800 text-[10px] text-zinc-500">
Click to select layer • Use inspector for rotate / crop (images) / scale / pos • Background always bottom
</div>
</div>
<!-- Inspector -->
<div id="inspector" class="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 hidden">
<div class="text-xs uppercase tracking-[1.5px] text-zinc-500 font-medium mb-3">INSPECTOR — <span id="inspector-type"></span></div>
<div class="grid grid-cols-2 gap-x-3 gap-y-3">
<div class="col-span-2">
<div class="control-label mb-1">Position X / Y (px)</div>
<div class="flex gap-2">
<input id="ins-x" type="number" class="flex-1 bg-zinc-950 border border-zinc-700 rounded-xl px-2 py-1 text-sm font-mono" oninput="updateSelectedFromInspector()">
<input id="ins-y" type="number" class="flex-1 bg-zinc-950 border border-zinc-700 rounded-xl px-2 py-1 text-sm font-mono" oninput="updateSelectedFromInspector()">
</div>
</div>
<div>
<div class="control-label mb-1">Scale</div>
<input id="ins-scale" type="range" min="0.1" max="3" step="0.05" class="w-full accent-pink-500" oninput="updateSelectedFromInspector()">
<div class="flex justify-between text-[10px] text-zinc-500"><span>0.1×</span><span id="ins-scale-val">1.0</span><span>3×</span></div>
</div>
<div>
<div class="control-label mb-1">Opacity</div>
<input id="ins-opacity" type="range" min="0" max="1" step="0.05" class="w-full accent-pink-500" oninput="updateSelectedFromInspector()">
<div class="flex justify-between text-[10px] text-zinc-500"><span>0%</span><span id="ins-opacity-val">100%</span><span>100%</span></div>
</div>
<div>
<div class="control-label mb-1">Rotate (deg)</div>
<input id="ins-rotate" type="range" min="-180" max="180" step="1" value="0" class="w-full accent-pink-500" oninput="updateSelectedFromInspector()">
<div class="flex justify-between text-[10px] text-zinc-500"><span>-180°</span><span id="ins-rotate-val">0</span><span>180°</span></div>
</div>
<!-- Text specific -->
<div id="text-controls" class="col-span-2 hidden">
<div class="control-label mb-1">Text Content (use {{username}} etc.)</div>
<textarea id="ins-text" rows="2" class="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-1 text-sm" oninput="updateSelectedFromInspector()"></textarea>
<div class="mt-2">
<div class="control-label mb-1">Style</div>
<select id="ins-text-style" class="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-1 text-sm" onchange="updateSelectedFromInspector()">
<option value="glitter-pink">Glitter Pink (recommended)</option>
<option value="plain">Plain</option>
</select>
</div>
<div class="mt-2 grid grid-cols-2 gap-2">
<div>
<div class="control-label mb-1">Font Size (px)</div>
<input id="ins-font-size" type="number" value="48" class="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-1 text-sm font-mono" oninput="updateSelectedFromInspector()">
</div>
<div>
<div class="control-label mb-1">Color</div>
<input id="ins-color" type="color" value="#ff95cb" class="w-full h-9 bg-zinc-950 border border-zinc-700 rounded-xl p-1" onchange="updateSelectedFromInspector()">
</div>
</div>
<div class="mt-2">
<div class="control-label mb-1">Font Family</div>
<select id="ins-font-family" class="w-full bg-zinc-950 border border-zinc-700 rounded-xl px-3 py-1 text-sm" onchange="updateSelectedFromInspector()">
<option value="system-ui, sans-serif">System UI</option>
<option value="Georgia, serif">Georgia</option>
<option value="'Playfair Display', Georgia, serif">Playfair Display</option>
<option value="monospace">Monospace</option>
</select>
</div>
</div>
<div class="col-span-2 flex gap-2 pt-2">
<button onclick="centerSelected()" class="flex-1 text-xs py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-2xl">Center H</button>
<button onclick="resetRotationSelected()" class="flex-1 text-xs py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-2xl">Reset Rotate</button>
<button onclick="deleteSelected()" class="flex-1 text-xs py-1.5 bg-red-900 hover:bg-red-800 rounded-2xl">Delete</button>
</div>
<div class="col-span-2">
<button onclick="makeSelectedTransparent()"
class="w-full text-xs py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-2xl">Make Background Transparent (for image layers)</button>
</div>
<div class="col-span-2">
<button onclick="startCropSelected()"
class="w-full text-xs py-1.5 bg-emerald-700 hover:bg-emerald-600 rounded-2xl">Crop Selected Image Asset</button>
</div>
</div>
</div>
</div>
</div>
<!-- Bottom tray -->
<div class="mt-4">
<div class="text-xs uppercase tracking-[1.5px] text-zinc-500 font-medium mb-2 px-1">QUICK ADD ASSETS (drag or click)</div>
<div class="flex gap-3 flex-wrap">
<button onclick="addImageLayerPrompt()" class="asset-btn px-4 py-2 bg-zinc-900 border border-zinc-700 hover:border-pink-500/60 rounded-2xl text-sm flex items-center gap-2">
Image Piece (PNG/JPG)
</button>
<button onclick="addTextLayer()" class="asset-btn px-4 py-2 bg-zinc-900 border border-zinc-700 hover:border-pink-500/60 rounded-2xl text-sm flex items-center gap-2">
Text Layer (dynamic)
</button>
<div class="text-[10px] text-zinc-500 self-center px-2">Tip: After adding text, edit content in the inspector. Use {{username}} and {{phone}}.</div>
</div>
</div>
<div class="mt-6 text-center text-[10px] text-zinc-500 max-w-lg mx-auto">
General-purpose version. Exact pixel output on export. Supports any dimensions and any profile via variables.<br>
Production version will live in my-socials/frontend-public with backend persistence + MCP + imajin piece generation.
</div>
</div>
<!-- Crop Modal -->
<div id="crop-modal" class="hidden fixed inset-0 bg-black/80 flex items-center justify-center z-[100]">
<div class="bg-zinc-900 border border-zinc-700 rounded-3xl p-4 w-[92%] max-w-[720px]">
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium">Crop Image Asset — drag rectangle on the image</div>
<div class="text-[10px] text-zinc-500">The crop bakes into the asset (non-destructive to placement)</div>
</div>
<div class="bg-black p-2 rounded-2xl">
<canvas id="crop-canvas" style="max-width: 100%; cursor: crosshair; border: 1px solid #3f3f46;"></canvas>
</div>
<div class="flex gap-2 mt-3">
<button onclick="applyCrop()" class="flex-1 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium rounded-2xl">Apply Crop</button>
<button onclick="cancelCrop()" class="flex-1 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-2xl">Cancel</button>
</div>
</div>
</div>
<script>
// Tailwind script
function initTailwind() {
// already loaded via CDN
}
let project = {
id: 'proj-' + Date.now(),
name: 'Untitled Promo',
width: 1080,
height: 653,
vars: { username: 'transquinnftw', phone: '1♥424♥466♥3669' },
layers: [],
bgAsset: null // { id, url or dataURL, naturalW, naturalH }
};
let selectedLayerId = null;
let currentZoom = 1;
let isDragging = false;
let dragLayerId = null;
let dragStartX = 0, dragStartY = 0;
let dragStartLayerX = 0, dragStartLayerY = 0;
// Crop state for selected image asset
let cropModal = null;
let cropCanvasEl = null;
let cropCtx = null;
let cropLayer = null;
let cropImg = null;
let cropRect = { x: 0, y: 0, w: 0, h: 0 };
let cropDragging = false;
let cropStartX = 0, cropStartY = 0;
const canvas = document.getElementById('editor-canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
function getResolvedText(raw) {
if (!raw) return '';
let out = raw;
const vars = getCurrentVars();
for (const [k, v] of Object.entries(vars)) {
out = out.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), v);
}
return out;
}
function getCurrentVars() {
return {
username: document.getElementById('var-username')?.value || project.vars.username || '',
phone: document.getElementById('var-phone')?.value || project.vars.phone || ''
};
}
function refreshAll() {
// sync vars back to project
project.vars = getCurrentVars();
renderCanvas();
renderLayersList();
}
function updateDimensionsFromInputs() {
// live update inputs but do not resize until Apply
}
function applyPreset() {
const sel = document.getElementById('preset-select').value;
if (!sel) return;
const [w, h] = sel.split(',').map(Number);
document.getElementById('width-input').value = w;
document.getElementById('height-input').value = h;
// auto-apply for convenience
applyDimensions();
}
function applyDimensions() {
const w = parseInt(document.getElementById('width-input').value) || 1080;
const h = parseInt(document.getElementById('height-input').value) || 653;
const oldW = project.width;
const oldH = project.height;
project.width = w;
project.height = h;
// optional: scale layers if user wants (simple uniform for now)
const scaleX = w / oldW;
const scaleY = h / oldH;
project.layers.forEach(l => {
if (l.type !== 'bg') {
l.x = Math.round(l.x * scaleX);
l.y = Math.round(l.y * scaleY);
if (l.scaleX) l.scaleX *= Math.min(scaleX, scaleY);
if (l.scaleY) l.scaleY *= Math.min(scaleX, scaleY);
}
});
canvas.width = w;
canvas.height = h;
document.getElementById('dims-display').textContent = `${w} × ${h}`;
renderCanvas();
renderLayersList();
}
function updateProjectNameDisplay() {
document.getElementById('project-name').textContent = project.name;
}
function renameProject() {
const newName = prompt('Project name:', project.name);
if (newName && newName.trim()) {
project.name = newName.trim();
updateProjectNameDisplay();
}
}
function newFile() {
if (!confirm('Create new file? Unsaved changes will be lost.')) return;
const name = prompt('Project name:', 'New Promo Graphic') || 'New Promo Graphic';
const w = parseInt(prompt('Width (px):', '1080')) || 1080;
const h = parseInt(prompt('Height (px):', '653')) || 653;
project = {
id: 'proj-' + Date.now(),
name,
width: w,
height: h,
vars: { username: 'yourname', phone: '1♥000♥000♥0000' },
layers: [],
bgAsset: null
};
canvas.width = w;
canvas.height = h;
document.getElementById('width-input').value = w;
document.getElementById('height-input').value = h;
document.getElementById('dims-display').textContent = `${w} × ${h}`;
document.getElementById('var-username').value = project.vars.username;
document.getElementById('var-phone').value = project.vars.phone;
selectedLayerId = null;
updateProjectNameDisplay();
document.getElementById('inspector').classList.add('hidden');
renderCanvas();
renderLayersList();
}
function addBackgroundAsset(file) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
project.bgAsset = {
id: 'bg-' + Date.now(),
url: e.target.result,
naturalW: img.width,
naturalH: img.height
};
// If no layers yet or first time, optionally auto-fit bg by setting a bg layer transform
// For simplicity we always draw bg full-bleed or centered contain
renderCanvas();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function addImageLayer(file) {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const layer = {
id: 'layer-' + Date.now(),
type: 'image',
visible: true,
z: project.layers.length,
x: Math.floor(project.width * 0.5),
y: Math.floor(project.height * 0.5),
scaleX: 0.5,
scaleY: 0.5,
rotation: 0,
opacity: 1,
asset: {
id: 'img-' + Date.now(),
url: e.target.result,
naturalW: img.width,
naturalH: img.height
}
};
project.layers.push(layer);
selectLayer(layer.id);
renderCanvas();
renderLayersList();
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function addImageLayerPrompt() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
if (e.target.files[0]) addImageLayer(e.target.files[0]);
};
input.click();
}
function addTextLayer() {
const layer = {
id: 'layer-' + Date.now(),
type: 'text',
visible: true,
z: project.layers.length,
x: Math.floor(project.width * 0.35),
y: Math.floor(project.height * 0.75),
scaleX: 1,
scaleY: 1,
rotation: 0,
opacity: 1,
content: '{{username}}',
style: 'glitter-pink',
fontSize: 42,
fontFamily: 'system-ui, sans-serif',
color: '#ff95cb'
};
project.layers.push(layer);
selectLayer(layer.id);
renderCanvas();
renderLayersList();
}
function selectLayer(id) {
selectedLayerId = id;
const insp = document.getElementById('inspector');
const layer = project.layers.find(l => l.id === id);
if (!layer) {
insp.classList.add('hidden');
return;
}
insp.classList.remove('hidden');
document.getElementById('inspector-type').textContent = layer.type.toUpperCase();
document.getElementById('ins-x').value = Math.round(layer.x);
document.getElementById('ins-y').value = Math.round(layer.y);
document.getElementById('ins-scale').value = layer.scaleX || 1;
document.getElementById('ins-scale-val').textContent = (layer.scaleX || 1).toFixed(2);
document.getElementById('ins-opacity').value = layer.opacity;
document.getElementById('ins-opacity-val').textContent = Math.round(layer.opacity * 100) + '%';
const rot = Math.round(layer.rotation || 0);
const rotInput = document.getElementById('ins-rotate');
if (rotInput) {
rotInput.value = rot;
document.getElementById('ins-rotate-val').textContent = rot;
}
const textCtrls = document.getElementById('text-controls');
if (layer.type === 'text') {
textCtrls.classList.remove('hidden');
document.getElementById('ins-text').value = layer.content || '';
document.getElementById('ins-text-style').value = layer.style || 'glitter-pink';
document.getElementById('ins-font-size').value = layer.fontSize || 42;
document.getElementById('ins-color').value = layer.color || '#ff95cb';
const famEl = document.getElementById('ins-font-family');
if (famEl) famEl.value = layer.fontFamily || 'system-ui, sans-serif';
} else {
textCtrls.classList.add('hidden');
}
renderCanvas();
}
function updateSelectedFromInspector() {
const layer = project.layers.find(l => l.id === selectedLayerId);
if (!layer) return;
layer.x = parseFloat(document.getElementById('ins-x').value) || 0;
layer.y = parseFloat(document.getElementById('ins-y').value) || 0;
layer.scaleX = parseFloat(document.getElementById('ins-scale').value) || 1;
layer.scaleY = layer.scaleX; // uniform for simplicity
layer.opacity = parseFloat(document.getElementById('ins-opacity').value) || 1;
const rotInput = document.getElementById('ins-rotate');
if (rotInput) {
layer.rotation = parseFloat(rotInput.value) || 0;
document.getElementById('ins-rotate-val').textContent = Math.round(layer.rotation);
}
document.getElementById('ins-scale-val').textContent = layer.scaleX.toFixed(2);
document.getElementById('ins-opacity-val').textContent = Math.round(layer.opacity * 100) + '%';
if (layer.type === 'text') {
layer.content = document.getElementById('ins-text').value;
layer.style = document.getElementById('ins-text-style').value;
layer.fontSize = parseFloat(document.getElementById('ins-font-size').value) || 42;
layer.color = document.getElementById('ins-color').value;
const famEl = document.getElementById('ins-font-family');
if (famEl) layer.fontFamily = famEl.value;
}
renderCanvas();
renderLayersList();
}
function centerSelected() {
const layer = project.layers.find(l => l.id === selectedLayerId);
if (!layer) return;
layer.x = Math.floor(project.width / 2);
layer.y = Math.floor(project.height / 2);
document.getElementById('ins-x').value = layer.x;
document.getElementById('ins-y').value = layer.y;
renderCanvas();
}
function deleteSelected() {
project.layers = project.layers.filter(l => l.id !== selectedLayerId);
selectedLayerId = null;
document.getElementById('inspector').classList.add('hidden');
renderCanvas();
renderLayersList();
}
function resetRotationSelected() {
const layer = project.layers.find(l => l.id === selectedLayerId);
if (!layer) return;
layer.rotation = 0;
const rotInput = document.getElementById('ins-rotate');
if (rotInput) {
rotInput.value = 0;
document.getElementById('ins-rotate-val').textContent = '0';
}
renderCanvas();
}
async function makeSelectedTransparent() {
const layer = project.layers.find(l => l.id === selectedLayerId);
if (!layer || layer.type !== 'image' || !layer.asset) return;
const img = await loadImage(layer.asset.url);
const c = document.createElement('canvas');
c.width = img.width;
c.height = img.height;
const cctx = c.getContext('2d');
cctx.drawImage(img, 0, 0);
const data = cctx.getImageData(0, 0, c.width, c.height);
const d = data.data;
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i+1], b = d[i+2];
const avg = (r + g + b) / 3;
if (avg > 242) {
d[i+3] = 0;
} else if (avg > 215) {
d[i+3] = Math.round(255 * (1 - (avg - 215) / 30));
}
}
cctx.putImageData(data, 0, 0);
layer.asset.url = c.toDataURL('image/png');
renderCanvas();
}
// --- Crop support for selected image layer ---
function startCropSelected() {
const layer = project.layers.find(l => l.id === selectedLayerId);
if (!layer || layer.type !== 'image' || !layer.asset) {
alert('Select an image layer first.');
return;
}
cropLayer = layer;
cropModal = document.getElementById('crop-modal');
cropCanvasEl = document.getElementById('crop-canvas');
cropCtx = cropCanvasEl.getContext('2d');
loadImage(layer.asset.url).then(img => {
cropImg = img;
// initial crop rect = full image
cropRect = { x: 0, y: 0, w: img.width, h: img.height };
cropModal.classList.remove('hidden');
cropModal.classList.add('flex');
setupCropCanvas(img);
});
}
function setupCropCanvas(img) {
// Size the crop canvas to fit nicely
const maxW = 640;
const scale = Math.min(1, maxW / img.width);
cropCanvasEl.width = Math.floor(img.width * scale);
cropCanvasEl.height = Math.floor(img.height * scale);
cropCanvasEl.style.width = cropCanvasEl.width + 'px';
cropCanvasEl.style.height = cropCanvasEl.height + 'px';
// mouse handlers for crop rect
cropCanvasEl.onmousedown = startCropDrag;
cropCanvasEl.onmousemove = updateCropDrag;
cropCanvasEl.onmouseup = endCropDrag;
cropCanvasEl.onmouseleave = endCropDrag;
drawCropPreview();
}
function drawCropPreview() {
if (!cropCtx || !cropImg) return;
const cw = cropCanvasEl.width;
const ch = cropCanvasEl.height;
const scale = cw / cropImg.width;
cropCtx.clearRect(0, 0, cw, ch);
cropCtx.drawImage(cropImg, 0, 0, cw, ch);
// draw crop rect
const rx = cropRect.x * scale;
const ry = cropRect.y * scale;
const rw = cropRect.w * scale;
const rh = cropRect.h * scale;
cropCtx.strokeStyle = '#ff95cb';
cropCtx.lineWidth = 2;
cropCtx.setLineDash([6, 3]);
cropCtx.strokeRect(rx, ry, rw, rh);
cropCtx.setLineDash([]);
// simple corner handles
cropCtx.fillStyle = '#ff95cb';
const hs = 6;
cropCtx.fillRect(rx - hs, ry - hs, hs*2, hs*2);
cropCtx.fillRect(rx + rw - hs, ry - hs, hs*2, hs*2);
cropCtx.fillRect(rx - hs, ry + rh - hs, hs*2, hs*2);
cropCtx.fillRect(rx + rw - hs, ry + rh - hs, hs*2, hs*2);
}
function startCropDrag(e) {
const rect = cropCanvasEl.getBoundingClientRect();
const scale = cropCanvasEl.width / cropImg.width;
cropStartX = (e.clientX - rect.left) / scale;
cropStartY = (e.clientY - rect.top) / scale;
cropDragging = true;
cropRect.x = cropStartX;
cropRect.y = cropStartY;
cropRect.w = 1;
cropRect.h = 1;
drawCropPreview();
}
function updateCropDrag(e) {
if (!cropDragging) return;
const rect = cropCanvasEl.getBoundingClientRect();
const scale = cropCanvasEl.width / cropImg.width;
const curX = (e.clientX - rect.left) / scale;
const curY = (e.clientY - rect.top) / scale;
cropRect.x = Math.min(cropStartX, curX);
cropRect.y = Math.min(cropStartY, curY);
cropRect.w = Math.max(5, Math.abs(curX - cropStartX));
cropRect.h = Math.max(5, Math.abs(curY - cropStartY));
drawCropPreview();
}
function endCropDrag() {
cropDragging = false;
}
function applyCrop() {
if (!cropLayer || !cropImg || !cropRect.w || !cropRect.h) {
cancelCrop();
return;
}
// clamp rect
const x = Math.max(0, Math.floor(cropRect.x));
const y = Math.max(0, Math.floor(cropRect.y));
const w = Math.min(cropImg.width - x, Math.floor(cropRect.w));
const h = Math.min(cropImg.height - y, Math.floor(cropRect.h));
if (w < 5 || h < 5) {
cancelCrop();
return;
}
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const cctx = c.getContext('2d');
cctx.drawImage(cropImg, x, y, w, h, 0, 0, w, h);
cropLayer.asset.url = c.toDataURL('image/png');
cropLayer.asset.naturalW = w;
cropLayer.asset.naturalH = h;
cancelCrop();
renderCanvas();
renderLayersList();
}
function cancelCrop() {
if (cropModal) {
cropModal.classList.remove('flex');
cropModal.classList.add('hidden');
}
// cleanup handlers
if (cropCanvasEl) {
cropCanvasEl.onmousedown = null;
cropCanvasEl.onmousemove = null;
cropCanvasEl.onmouseup = null;
cropCanvasEl.onmouseleave = null;
}
cropLayer = null;
cropImg = null;
cropRect = { x: 0, y: 0, w: 0, h: 0 };
}
function renderLayersList() {
const container = document.getElementById('layers-list');
container.innerHTML = '';
const sorted = [...project.layers].sort((a, b) => a.z - b.z);
// Background row
const bgRow = document.createElement('div');
bgRow.className = `layer-item px-3 py-1.5 border border-zinc-700 rounded-2xl flex items-center gap-2 text-sm cursor-pointer ${selectedLayerId === 'bg' ? 'selected' : ''}`;
bgRow.innerHTML = `
<div class="flex-1 truncate">Background ${project.bgAsset ? '(set)' : '(none)'}</div>
<button onclick="event.stopImmediatePropagation(); addBackgroundPrompt()" class="text-[10px] px-2 py-0.5 bg-zinc-800 rounded">Change</button>
`;
bgRow.onclick = () => { /* bg has no inspector for now */ };
container.appendChild(bgRow);
sorted.forEach((layer, idx) => {
const div = document.createElement('div');
div.className = `layer-item px-3 py-1.5 border border-zinc-700 rounded-2xl flex items-center gap-2 text-sm cursor-pointer ${layer.id === selectedLayerId ? 'selected' : ''}`;
const label = layer.type === 'text'
? `Text: ${getResolvedText(layer.content).slice(0, 22)}...`
: `Image layer`;
div.innerHTML = `
<div class="flex-1 truncate">${label}</div>
<button onclick="event.stopImmediatePropagation(); moveLayer('${layer.id}', -1)" class="text-xs px-1.5">↑</button>
<button onclick="event.stopImmediatePropagation(); moveLayer('${layer.id}', 1)" class="text-xs px-1.5">↓</button>
<span class="text-[10px] text-zinc-500 w-8 text-right">${Math.round((layer.opacity || 1) * 100)}%</span>
`;
div.onclick = () => selectLayer(layer.id);
container.appendChild(div);
});
}
function moveLayer(id, dir) {
const idx = project.layers.findIndex(l => l.id === id);
if (idx === -1) return;
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= project.layers.length) return;
const [moved] = project.layers.splice(idx, 1);
project.layers.splice(newIdx, 0, moved);
// re-assign z for simplicity
project.layers.forEach((l, i) => l.z = i);
renderLayersList();
renderCanvas();
}
function addBackgroundPrompt() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
if (e.target.files[0]) addBackgroundAsset(e.target.files[0]);
};
input.click();
}
function loadImage(src) {
return new Promise((resolve) => {
const i = new Image();
i.onload = () => resolve(i);
i.src = src;
});
}
async function renderCanvas() {
ctx.save();
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Background
if (project.bgAsset && project.bgAsset.url) {
try {
const bgImg = await loadImage(project.bgAsset.url);
// Simple cover fit
const scale = Math.max(canvas.width / bgImg.width, canvas.height / bgImg.height);
const dw = bgImg.width * scale;
const dh = bgImg.height * scale;
const dx = (canvas.width - dw) / 2;
const dy = (canvas.height - dh) / 2;
ctx.drawImage(bgImg, dx, dy, dw, dh);
} catch (e) {}
} else {
// placeholder
ctx.fillStyle = '#1f2937';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4b5563';
ctx.font = '32px sans-serif';
ctx.fillText('Add background asset', canvas.width / 2 - 140, canvas.height / 2);
}
// Foreground layers sorted by z
const sorted = [...project.layers].sort((a, b) => (a.z || 0) - (b.z || 0));
for (const layer of sorted) {
if (!layer.visible) continue;
ctx.save();
ctx.globalAlpha = layer.opacity || 1;
const cx = layer.x;
const cy = layer.y;
ctx.translate(cx, cy);
ctx.rotate((layer.rotation || 0) * Math.PI / 180);
const sx = layer.scaleX || 1;
const sy = layer.scaleY || 1;
ctx.scale(sx, sy);
if (layer.type === 'image' && layer.asset && layer.asset.url) {
try {
const img = await loadImage(layer.asset.url);
const w = img.width;
const h = img.height;
ctx.drawImage(img, -w / 2, -h / 2, w, h);
} catch (e) {}
} else if (layer.type === 'text') {
const txt = getResolvedText(layer.content);
if (txt) {
const size = (layer.fontSize || 40) * (sx); // approx
const fam = layer.fontFamily || 'system-ui, sans-serif';
ctx.font = `${size}px ${fam}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (layer.style === 'glitter-pink' || layer.style === 'neon') {
// Approximate glitter / neon
ctx.fillStyle = layer.color || '#ff95cb';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = Math.max(2, size / 18);
ctx.shadowColor = '#ff95cb';
ctx.shadowBlur = size / 3;
ctx.strokeText(txt, 0, 0);
ctx.shadowBlur = size / 6;
ctx.strokeText(txt, 0, 0);
ctx.shadowBlur = 0;
ctx.fillText(txt, 0, 0);
} else if (layer.style === 'outline') {
ctx.fillStyle = layer.color || '#ff95cb';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = Math.max(2, size / 18);
ctx.strokeText(txt, 0, 0);
ctx.fillText(txt, 0, 0);
} else {
ctx.fillStyle = layer.color || '#ffffff';
ctx.fillText(txt, 0, 0);
}
}
}
ctx.restore();
}
ctx.restore();
// Draw selection outline
if (selectedLayerId) {
const layer = project.layers.find(l => l.id === selectedLayerId);
if (layer) {
ctx.save();
ctx.strokeStyle = '#ff95cb';
ctx.lineWidth = 2;
const halfW = 60; // rough visual handle size
ctx.strokeRect(layer.x - halfW, layer.y - halfW, halfW * 2, halfW * 2);
ctx.restore();
}
}
}
function setupCanvasInteraction() {
const c = canvas;
c.addEventListener('mousedown', async (e) => {
const rect = c.getBoundingClientRect();
const scale = c.width / rect.width; // approx
const mx = (e.clientX - rect.left) * scale;
const my = (e.clientY - rect.top) * scale;
// find topmost layer under point (very rough hit test)
const sorted = [...project.layers].sort((a,b) => (b.z||0) - (a.z||0));
let hit = null;
for (const l of sorted) {
if (!l.visible || l.type === 'bg') continue;
const distX = Math.abs(l.x - mx);
const distY = Math.abs(l.y - my);
const thresh = 80 * (l.scaleX || 1);
if (distX < thresh && distY < thresh) {
hit = l;
break;
}
}
if (hit) {
selectLayer(hit.id);
isDragging = true;
dragLayerId = hit.id;
dragStartX = mx;
dragStartY = my;
dragStartLayerX = hit.x;
dragStartLayerY = hit.y;
} else {
selectedLayerId = null;
document.getElementById('inspector').classList.add('hidden');
renderCanvas();
}
});
window.addEventListener('mousemove', (e) => {
if (!isDragging || !dragLayerId) return;
const rect = c.getBoundingClientRect();
const scale = c.width / rect.width;
const mx = (e.clientX - rect.left) * scale;
const my = (e.clientY - rect.top) * scale;
const layer = project.layers.find(l => l.id === dragLayerId);
if (layer) {
layer.x = dragStartLayerX + (mx - dragStartX);
layer.y = dragStartLayerY + (my - dragStartY);
if (selectedLayerId === dragLayerId) {
document.getElementById('ins-x').value = Math.round(layer.x);
document.getElementById('ins-y').value = Math.round(layer.y);
}
renderCanvas();
}
});
window.addEventListener('mouseup', () => {
isDragging = false;
dragLayerId = null;
renderLayersList();
});
// Double click text edit
c.addEventListener('dblclick', (e) => {
const rect = c.getBoundingClientRect();
const scale = c.width / rect.width;
const mx = (e.clientX - rect.left) * scale;
const my = (e.clientY - rect.top) * scale;
const sorted = [...project.layers].sort((a,b) => (b.z||0) - (a.z||0));
for (const l of sorted) {
if (l.type === 'text') {
const distX = Math.abs(l.x - mx);
const distY = Math.abs(l.y - my);
if (distX < 120 && distY < 60) {
selectLayer(l.id);
const newContent = prompt('Edit text content:', l.content);
if (newContent !== null) {
l.content = newContent;
document.getElementById('ins-text').value = newContent;
renderCanvas();
renderLayersList();
}
break;
}
}
}
});
// Keyboard nudge
document.addEventListener('keydown', (e) => {
const layer = project.layers.find(l => l.id === selectedLayerId);
if (!layer) return;
let changed = false;
if (e.key === 'ArrowLeft') { layer.x -= 2; changed = true; }
if (e.key === 'ArrowRight') { layer.x += 2; changed = true; }
if (e.key === 'ArrowUp') { layer.y -= 2; changed = true; }
if (e.key === 'ArrowDown') { layer.y += 2; changed = true; }
if (changed) {
e.preventDefault();
document.getElementById('ins-x').value = Math.round(layer.x);
document.getElementById('ins-y').value = Math.round(layer.y);
renderCanvas();
}
if (e.key === 'Delete' || e.key === 'Backspace') {
deleteSelected();
}
});
}
function applyZoom() {
const val = parseInt(document.getElementById('zoom-slider').value);
currentZoom = val / 100;
document.getElementById('zoom-val').textContent = val + '%';
const c = document.getElementById('editor-canvas');
c.style.width = (c.width * currentZoom) + 'px';
c.style.height = (c.height * currentZoom) + 'px';
}
function fitToScreen() {
const containerW = 700; // approx
const s = Math.min(1, containerW / project.width);
const pct = Math.round(s * 100);
document.getElementById('zoom-slider').value = pct;
applyZoom();
}
async function exportExactPNG() {
const exportCanvas = document.createElement('canvas');
exportCanvas.width = project.width;
exportCanvas.height = project.height;
const ectx = exportCanvas.getContext('2d', { willReadFrequently: true });
// Draw bg
if (project.bgAsset && project.bgAsset.url) {
const bgImg = await loadImage(project.bgAsset.url);
const scale = Math.max(exportCanvas.width / bgImg.width, exportCanvas.height / bgImg.height);
const dw = bgImg.width * scale;
const dh = bgImg.height * scale;
const dx = (exportCanvas.width - dw) / 2;
const dy = (exportCanvas.height - dh) / 2;
ectx.drawImage(bgImg, dx, dy, dw, dh);
}
const sorted = [...project.layers].sort((a, b) => (a.z || 0) - (b.z || 0));
for (const layer of sorted) {
if (!layer.visible) continue;
ectx.save();
ectx.globalAlpha = layer.opacity || 1;
ectx.translate(layer.x, layer.y);
ectx.rotate((layer.rotation || 0) * Math.PI / 180);
const sx = layer.scaleX || 1;
const sy = layer.scaleY || 1;
ectx.scale(sx, sy);
if (layer.type === 'image' && layer.asset && layer.asset.url) {
const img = await loadImage(layer.asset.url);
ectx.drawImage(img, -img.width / 2, -img.height / 2);
} else if (layer.type === 'text') {
const txt = getResolvedText(layer.content);
if (txt) {
const size = layer.fontSize || 40;
const fam = layer.fontFamily || 'system-ui, sans-serif';
ectx.font = `${size}px ${fam}`;
ectx.textAlign = 'center';
ectx.textBaseline = 'middle';
if (layer.style === 'glitter-pink' || layer.style === 'neon') {
ectx.fillStyle = layer.color || '#ff95cb';
ectx.strokeStyle = '#fff';
ectx.lineWidth = Math.max(2.5, size / 16);
ectx.shadowColor = '#ff95cb';
ectx.shadowBlur = size * 0.35;
ectx.strokeText(txt, 0, 0);
ectx.shadowBlur = size * 0.18;
ectx.strokeText(txt, 0, 0);
ectx.shadowBlur = 0;
ectx.fillText(txt, 0, 0);
} else if (layer.style === 'outline') {
ectx.fillStyle = layer.color || '#ff95cb';
ectx.strokeStyle = '#fff';
ectx.lineWidth = Math.max(2.5, size / 16);
ectx.strokeText(txt, 0, 0);
ectx.fillText(txt, 0, 0);
} else {
ectx.fillStyle = layer.color || '#fff';
ectx.fillText(txt, 0, 0);
}
}
}
ectx.restore();
}
const link = document.createElement('a');
link.download = `${project.name.replace(/\s+/g, '-')}-${project.width}x${project.height}.png`;
link.href = exportCanvas.toDataURL('image/png');
link.click();
}
function saveProject() {
const data = JSON.stringify(project, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${project.name || 'promo-project'}.json`;
a.click();
URL.revokeObjectURL(url);
}
function loadProject() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
try {
const loaded = JSON.parse(text);
project = loaded;
// Restore canvas size
canvas.width = project.width;
canvas.height = project.height;
document.getElementById('width-input').value = project.width;
document.getElementById('height-input').value = project.height;
document.getElementById('dims-display').textContent = `${project.width} × ${project.height}`;
document.getElementById('var-username').value = project.vars.username || '';
document.getElementById('var-phone').value = project.vars.phone || '';
updateProjectNameDisplay();
selectedLayerId = null;
document.getElementById('inspector').classList.add('hidden');
renderCanvas();
renderLayersList();
} catch (err) {
alert('Invalid project file');
}
};
input.click();
}
function syncSampleVars() {
document.getElementById('var-username').value = 'transquinnftw';
document.getElementById('var-phone').value = '1♥424♥466♥3669';
refreshAll();
}
function applyZoomToCanvasElement() {
// initial
const c = document.getElementById('editor-canvas');
c.style.width = (c.width * currentZoom) + 'px';
c.style.height = (c.height * currentZoom) + 'px';
}
async function init() {
initTailwind();
// initial canvas
canvas.width = project.width;
canvas.height = project.height;
document.getElementById('width-input').value = project.width;
document.getElementById('height-input').value = project.height;
document.getElementById('dims-display').textContent = `${project.width} × ${project.height}`;
document.getElementById('var-username').value = project.vars.username;
document.getElementById('var-phone').value = project.vars.phone;
updateProjectNameDisplay();
// default bg hint + one sample text layer so it is immediately useful
const sampleText = {
id: 'layer-sample',
type: 'text',
visible: true,
z: 0,
x: Math.floor(project.width * 0.38),
y: Math.floor(project.height * 0.78),
scaleX: 1,
scaleY: 1,
rotation: 0,
opacity: 1,
content: '{{username}}',
style: 'glitter-pink',
fontSize: 38,
fontFamily: 'system-ui, sans-serif',
color: '#ff95cb'
};
project.layers.push(sampleText);
setupCanvasInteraction();
applyZoomToCanvasElement();
await renderCanvas();
renderLayersList();
// Auto-select the sample text layer to demo rotate/crop (crop only for images)
if (sampleText && sampleText.id) {
selectLayer(sampleText.id);
}
// demo tip
setTimeout(() => {
const tip = document.createElement('div');
tip.className = 'fixed bottom-4 right-4 text-[10px] bg-zinc-900 border border-zinc-700 px-3 py-1 rounded-2xl';
tip.textContent = 'Try: New File + set dims, add bg/fg assets, select layer then use inspector for rotate/crop/scale';
document.body.appendChild(tip);
setTimeout(() => tip.remove(), 5200);
}, 4200);
console.log('%c[Promo Graphic Composer] General tool ready. New file / dimensions / bg / fg assets supported.', 'color:#3f3f46');
}
// Drag & drop support on canvas area for quick add
(function setupGlobalDnD() {
const dropZone = document.getElementById('editor-canvas');
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.outline = '2px dashed #ff95cb'; });
dropZone.addEventListener('dragleave', () => { dropZone.style.outline = ''; });
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.outline = '';
if (e.dataTransfer.files.length) {
const file = e.dataTransfer.files[0];
if (file.type.startsWith('image/')) {
addImageLayer(file);
}
}
});
})();
window.onload = init;
</script>
</body>
</html>