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.
1302 lines
50 KiB
HTML
1302 lines
50 KiB
HTML
<!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 & 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>
|