text-behind-image-cp1 / index.html
ysharma's picture
ysharma HF Staff
Update index.html
a953463
Raw History Blame Contribute Delete
43.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Text Behind Image</title>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Bebas+Neue&family=Dancing+Script&family=Lobster&family=Montserrat:wght@100;400;700;900&family=Open+Sans:wght@300;400;700&family=Oswald:wght@400;700&family=Pacifico&family=Permanent+Marker&family=Playfair+Display:wght@400;700;900&family=Raleway:wght@400;700;900&family=Roboto:wght@100;400;700;900&family=Abril+Fatface&family=Righteous&family=Bangers&family=Fredoka+One&family=Press+Start+2P&family=Satisfy" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--accent: #e94560;
--accent2: #533483;
--text: #eee;
--text-dim: #999;
--border: #2a2a4a;
--input-bg: #0d1b2a;
--radius: 8px;
}
body {
font-family: 'Roboto', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
/* ─── Upload Screen ─── */
#upload-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
#upload-screen h1 {
font-family: 'Montserrat', sans-serif;
font-size: 2.5rem;
font-weight: 900;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#upload-screen .subtitle {
color: var(--text-dim);
margin-bottom: 2rem;
font-size: 1.1rem;
}
#drop-zone {
width: 500px;
max-width: 90vw;
height: 300px;
border: 3px dashed var(--border);
border-radius: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
background: var(--surface);
}
#drop-zone:hover, #drop-zone.drag-over {
border-color: var(--accent);
background: rgba(233, 69, 96, 0.05);
}
#drop-zone svg { width: 64px; height: 64px; fill: var(--text-dim); margin-bottom: 1rem; }
#drop-zone p { color: var(--text-dim); font-size: 1.1rem; }
#drop-zone .hint { font-size: 0.85rem; margin-top: 0.5rem; }
#file-input { display: none; }
/* ─── Loading Overlay ─── */
#loading-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
z-index: 9999;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1.5rem;
}
#loading-overlay.visible { display: flex; }
.spinner {
width: 60px; height: 60px;
border: 5px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#loading-overlay p { font-size: 1.2rem; color: var(--text-dim); }
/* ─── Editor Screen ─── */
#editor-screen { display: none; height: 100vh; flex-direction: column; }
#editor-screen.visible { display: flex; }
/* Top bar */
#topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.2rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#topbar .logo {
font-family: 'Montserrat', sans-serif;
font-weight: 900;
font-size: 1.2rem;
background: linear-gradient(135deg, var(--accent), var(--accent2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
#topbar .actions { display: flex; gap: 0.5rem; }
/* Buttons */
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { background: #d63851; }
.btn-secondary { background: var(--surface2); color: var(--text); }
.btn-secondary:hover { background: #1a4a7a; }
.btn-danger { background: #c0392b; color: #fff; }
.btn-danger:hover { background: #a93226; }
.btn-sm { padding: 0.35rem 0.7rem; font-size: 0.8rem; }
/* Main editor layout */
#editor-main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Canvas area */
#canvas-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: #111;
overflow: auto;
position: relative;
}
#canvas-container {
position: relative;
display: inline-block;
box-shadow: 0 4px 30px rgba(0,0,0,0.5);
overflow: hidden;
}
#canvas-container img {
display: block;
max-width: 70vw;
max-height: 80vh;
user-select: none;
-webkit-user-drag: none;
}
#background-layer { position: relative; z-index: 1; }
#text-layer {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: all;
}
#foreground-layer {
position: absolute;
inset: 0;
z-index: 3;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
}
/* Text elements */
.text-element {
position: absolute;
cursor: grab;
white-space: pre;
user-select: none;
line-height: 1.2;
transition: outline 0.15s;
outline: 2px solid transparent;
outline-offset: 4px;
}
.text-element:hover { outline-color: rgba(233,69,96,0.4); }
.text-element.selected { outline-color: var(--accent); }
.text-element.dragging { cursor: grabbing; }
/* ─── Controls Panel ─── */
#controls-panel {
width: 320px;
flex-shrink: 0;
background: var(--surface);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 0;
}
.panel-section {
border-bottom: 1px solid var(--border);
padding: 0.8rem 1rem;
}
.panel-section h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-dim);
margin-bottom: 0.6rem;
display: flex;
align-items: center;
gap: 0.4rem;
}
.control-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.control-row label {
font-size: 0.8rem;
color: var(--text-dim);
min-width: 65px;
flex-shrink: 0;
}
.control-row input[type="text"],
.control-row input[type="number"],
.control-row select,
.control-row textarea {
flex: 1;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
font-family: inherit;
}
.control-row textarea {
resize: vertical;
min-height: 50px;
}
.control-row input[type="range"] {
flex: 1;
accent-color: var(--accent);
}
.control-row input[type="color"] {
width: 32px;
height: 28px;
border: 1px solid var(--border);
border-radius: 4px;
background: none;
cursor: pointer;
padding: 1px;
}
.range-value {
font-size: 0.75rem;
color: var(--text-dim);
min-width: 35px;
text-align: right;
}
.control-row .checkbox-label {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
cursor: pointer;
min-width: auto;
color: var(--text);
}
.control-row .checkbox-label input { accent-color: var(--accent); }
/* Text list */
#text-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.text-list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.6rem;
background: var(--input-bg);
border-radius: 4px;
font-size: 0.82rem;
cursor: pointer;
border: 1px solid transparent;
}
.text-list-item:hover { border-color: var(--border); }
.text-list-item.active { border-color: var(--accent); background: rgba(233,69,96,0.1); }
.text-list-item .delete-text-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 1rem;
padding: 0 0.2rem;
}
.text-list-item .delete-text-btn:hover { color: var(--accent); }
#no-text-msg { color: var(--text-dim); font-size: 0.82rem; font-style: italic; }
/* Disabled state */
#controls-panel.disabled .panel-section:not(.always-active) {
opacity: 0.4;
pointer-events: none;
}
/* Export canvas (hidden) */
#export-canvas { display: none; }
</style>
</head>
<body>
<!-- Upload Screen -->
<div id="upload-screen">
<h1>Text Behind Image</h1>
<p class="subtitle">Add stunning text effects behind your image's subject</p>
<div id="drop-zone">
<svg viewBox="0 0 24 24"><path d="M19 7v2.99s-1.99.01-2 0V7h-3s.01-1.99 0-2h3V2h2v3h3v2h-3zm-3 4V8h-3V5H5c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-8h-3zM5 19l3-4 2 3 3-4 4 5H5z"/></svg>
<p>Drop an image here or click to upload</p>
<p class="hint">Supports JPG, PNG, WebP</p>
</div>
<input type="file" id="file-input" accept="image/*">
</div>
<!-- Loading Overlay -->
<div id="loading-overlay">
<div class="spinner"></div>
<p>Removing background...</p>
</div>
<!-- Editor Screen -->
<div id="editor-screen">
<div id="topbar">
<div class="logo">Text Behind Image</div>
<div class="actions">
<button class="btn btn-secondary btn-sm" onclick="addText()">+ Add Text</button>
<button class="btn btn-primary btn-sm" onclick="exportImage()">Export PNG</button>
<button class="btn btn-secondary btn-sm" onclick="resetApp()">New Image</button>
</div>
</div>
<div id="editor-main">
<div id="canvas-area">
<div id="canvas-container">
<img id="background-layer" />
<div id="text-layer"></div>
<img id="foreground-layer" />
</div>
</div>
<div id="controls-panel" class="disabled">
<!-- Text List -->
<div class="panel-section always-active">
<h3>Text Layers</h3>
<div style="display:flex;gap:0.4rem;margin-bottom:0.5rem;">
<button class="btn btn-primary btn-sm" style="flex:1" onclick="addText()">+ Add Text</button>
<button class="btn btn-danger btn-sm" onclick="deleteSelected()">Delete</button>
</div>
<div id="text-list">
<p id="no-text-msg">No text added yet</p>
</div>
</div>
<!-- Content -->
<div class="panel-section">
<h3>Content</h3>
<div class="control-row">
<textarea id="ctrl-text" placeholder="Type your text..." oninput="updateProp('text', this.value)"></textarea>
</div>
</div>
<!-- Font -->
<div class="panel-section">
<h3>Font</h3>
<div class="control-row">
<label>Family</label>
<select id="ctrl-font" onchange="updateProp('fontFamily', this.value)">
<option value="Arial">Arial</option>
<option value="Helvetica">Helvetica</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Courier New">Courier New</option>
<option value="Impact" selected>Impact</option>
<option value="Verdana">Verdana</option>
<option value="Roboto">Roboto</option>
<option value="Open Sans">Open Sans</option>
<option value="Montserrat">Montserrat</option>
<option value="Playfair Display">Playfair Display</option>
<option value="Oswald">Oswald</option>
<option value="Raleway">Raleway</option>
<option value="Dancing Script">Dancing Script</option>
<option value="Permanent Marker">Permanent Marker</option>
<option value="Bebas Neue">Bebas Neue</option>
<option value="Pacifico">Pacifico</option>
<option value="Anton">Anton</option>
<option value="Lobster">Lobster</option>
<option value="Abril Fatface">Abril Fatface</option>
<option value="Righteous">Righteous</option>
<option value="Bangers">Bangers</option>
<option value="Fredoka One">Fredoka One</option>
<option value="Press Start 2P">Press Start 2P</option>
<option value="Satisfy">Satisfy</option>
<option value="Comic Sans MS">Comic Sans MS</option>
</select>
</div>
<div class="control-row">
<label>Size</label>
<input type="range" id="ctrl-size" min="12" max="300" value="72" oninput="updateProp('fontSize', this.value)">
<span class="range-value" id="ctrl-size-val">72px</span>
</div>
<div class="control-row">
<label>Weight</label>
<input type="range" id="ctrl-weight" min="100" max="900" step="100" value="900" oninput="updateProp('fontWeight', this.value)">
<span class="range-value" id="ctrl-weight-val">900</span>
</div>
<div class="control-row">
<label>Spacing</label>
<input type="range" id="ctrl-spacing" min="-10" max="50" value="0" oninput="updateProp('letterSpacing', this.value)">
<span class="range-value" id="ctrl-spacing-val">0px</span>
</div>
<div class="control-row">
<label>Line H.</label>
<input type="range" id="ctrl-lineheight" min="0.5" max="3" step="0.1" value="1.2" oninput="updateProp('lineHeight', this.value)">
<span class="range-value" id="ctrl-lineheight-val">1.2</span>
</div>
</div>
<!-- Color -->
<div class="panel-section">
<h3>Color & Fill</h3>
<div class="control-row">
<label>Color</label>
<input type="color" id="ctrl-color" value="#ffffff" oninput="updateProp('color', this.value)">
<label style="min-width:auto">Opacity</label>
<input type="range" id="ctrl-opacity" min="0" max="1" step="0.05" value="1" oninput="updateProp('opacity', this.value)">
<span class="range-value" id="ctrl-opacity-val">1</span>
</div>
<div class="control-row">
<label class="checkbox-label"><input type="checkbox" id="ctrl-bg-on" onchange="updateProp('bgEnabled', this.checked)"> Background</label>
<input type="color" id="ctrl-bg-color" value="#000000" oninput="updateProp('bgColor', this.value)">
<input type="range" id="ctrl-bg-opacity" min="0" max="1" step="0.05" value="0.5" oninput="updateProp('bgOpacity', this.value)">
<span class="range-value" id="ctrl-bg-opacity-val">0.5</span>
</div>
<div class="control-row">
<label>Padding</label>
<input type="range" id="ctrl-bg-padding" min="0" max="40" value="8" oninput="updateProp('bgPadding', this.value)">
<span class="range-value" id="ctrl-bg-padding-val">8px</span>
</div>
</div>
<!-- Stroke / Outline -->
<div class="panel-section">
<h3>Stroke / Outline</h3>
<div class="control-row">
<label class="checkbox-label"><input type="checkbox" id="ctrl-stroke-on" onchange="updateProp('strokeEnabled', this.checked)"> Enable</label>
<input type="color" id="ctrl-stroke-color" value="#000000" oninput="updateProp('strokeColor', this.value)">
</div>
<div class="control-row">
<label>Width</label>
<input type="range" id="ctrl-stroke-width" min="0" max="10" step="0.5" value="2" oninput="updateProp('strokeWidth', this.value)">
<span class="range-value" id="ctrl-stroke-width-val">2px</span>
</div>
</div>
<!-- Shadow -->
<div class="panel-section">
<h3>Shadow</h3>
<div class="control-row">
<label class="checkbox-label"><input type="checkbox" id="ctrl-shadow-on" onchange="updateProp('shadowEnabled', this.checked)"> Enable</label>
<input type="color" id="ctrl-shadow-color" value="#000000" oninput="updateProp('shadowColor', this.value)">
</div>
<div class="control-row">
<label>X</label>
<input type="range" id="ctrl-shadow-x" min="-30" max="30" value="3" oninput="updateProp('shadowX', this.value)">
<span class="range-value" id="ctrl-shadow-x-val">3px</span>
</div>
<div class="control-row">
<label>Y</label>
<input type="range" id="ctrl-shadow-y" min="-30" max="30" value="3" oninput="updateProp('shadowY', this.value)">
<span class="range-value" id="ctrl-shadow-y-val">3px</span>
</div>
<div class="control-row">
<label>Blur</label>
<input type="range" id="ctrl-shadow-blur" min="0" max="30" value="5" oninput="updateProp('shadowBlur', this.value)">
<span class="range-value" id="ctrl-shadow-blur-val">5px</span>
</div>
</div>
<!-- 3D Effect -->
<div class="panel-section">
<h3>3D Extrusion</h3>
<div class="control-row">
<label class="checkbox-label"><input type="checkbox" id="ctrl-3d-on" onchange="updateProp('threeDEnabled', this.checked)"> Enable</label>
<input type="color" id="ctrl-3d-color" value="#333333" oninput="updateProp('threeDColor', this.value)">
</div>
<div class="control-row">
<label>Depth</label>
<input type="range" id="ctrl-3d-depth" min="1" max="30" value="8" oninput="updateProp('threeDDepth', this.value)">
<span class="range-value" id="ctrl-3d-depth-val">8</span>
</div>
<div class="control-row">
<label>Angle</label>
<input type="range" id="ctrl-3d-angle" min="0" max="360" value="135" oninput="updateProp('threeDAngle', this.value)">
<span class="range-value" id="ctrl-3d-angle-val">135&deg;</span>
</div>
</div>
<!-- Transform -->
<div class="panel-section">
<h3>Transform</h3>
<div class="control-row">
<label>Rotate</label>
<input type="range" id="ctrl-rotate" min="-180" max="180" value="0" oninput="updateProp('rotate', this.value)">
<span class="range-value" id="ctrl-rotate-val">0&deg;</span>
</div>
<div class="control-row">
<label>Skew X</label>
<input type="range" id="ctrl-skewx" min="-60" max="60" value="0" oninput="updateProp('skewX', this.value)">
<span class="range-value" id="ctrl-skewx-val">0&deg;</span>
</div>
<div class="control-row">
<label>Skew Y</label>
<input type="range" id="ctrl-skewy" min="-60" max="60" value="0" oninput="updateProp('skewY', this.value)">
<span class="range-value" id="ctrl-skewy-val">0&deg;</span>
</div>
</div>
<!-- Perspective (lay text on ground, etc.) -->
<div class="panel-section">
<h3>Perspective</h3>
<div class="control-row">
<label>Distance</label>
<input type="range" id="ctrl-persp" min="50" max="2000" value="500" oninput="updateProp('perspective', this.value)">
<span class="range-value" id="ctrl-persp-val">500px</span>
</div>
<div class="control-row">
<label>Tilt X</label>
<input type="range" id="ctrl-rotx" min="-85" max="85" value="0" oninput="updateProp('rotateX', this.value)">
<span class="range-value" id="ctrl-rotx-val">0&deg;</span>
</div>
<div class="control-row">
<label>Tilt Y</label>
<input type="range" id="ctrl-roty" min="-85" max="85" value="0" oninput="updateProp('rotateY', this.value)">
<span class="range-value" id="ctrl-roty-val">0&deg;</span>
</div>
<div class="control-row">
<label>Origin</label>
<select id="ctrl-persp-origin" onchange="updateProp('perspOrigin', this.value)">
<option value="center center">Center</option>
<option value="center top">Top</option>
<option value="center bottom" selected>Bottom</option>
<option value="left center">Left</option>
<option value="right center">Right</option>
<option value="left top">Top Left</option>
<option value="right top">Top Right</option>
<option value="left bottom">Bottom Left</option>
<option value="right bottom">Bottom Right</option>
</select>
</div>
</div>
</div>
</div>
</div>
<canvas id="export-canvas"></canvas>
<script>
// ─── State ───
let textElements = [];
let selectedId = null;
let nextId = 1;
let originalImageData = null; // base64 data URL of the original image
let foregroundImageData = null; // base64 data URL of the foreground (transparent)
let naturalWidth = 0;
let naturalHeight = 0;
const defaults = {
text: 'YOUR TEXT',
fontFamily: 'Impact',
fontSize: 72,
fontWeight: 900,
letterSpacing: 0,
lineHeight: 1.2,
color: '#ffffff',
opacity: 1,
bgEnabled: false,
bgColor: '#000000',
bgOpacity: 0.5,
bgPadding: 8,
strokeEnabled: false,
strokeColor: '#000000',
strokeWidth: 2,
shadowEnabled: false,
shadowColor: '#000000',
shadowX: 3,
shadowY: 3,
shadowBlur: 5,
threeDEnabled: false,
threeDColor: '#333333',
threeDDepth: 8,
threeDAngle: 135,
rotate: 0,
skewX: 0,
skewY: 0,
perspective: 500,
rotateX: 0,
rotateY: 0,
perspOrigin: 'center bottom',
x: 50, // percentage
y: 50,
};
// ─── Upload ───
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('drag-over');
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', e => { if (e.target.files.length) handleFile(e.target.files[0]); });
function handleFile(file) {
if (!file.type.startsWith('image/')) return;
// Read as data URL for display
const reader = new FileReader();
reader.onload = e => {
originalImageData = e.target.result;
// Get natural dimensions
const img = new Image();
img.onload = () => {
naturalWidth = img.naturalWidth;
naturalHeight = img.naturalHeight;
uploadAndProcess(file);
};
img.src = originalImageData;
};
reader.readAsDataURL(file);
}
async function uploadAndProcess(file) {
const overlay = document.getElementById('loading-overlay');
overlay.classList.add('visible');
try {
const form = new FormData();
form.append('file', file);
const resp = await fetch('/api/remove-background', { method: 'POST', body: form });
if (!resp.ok) throw new Error('Background removal failed');
const data = await resp.json();
foregroundImageData = data.foreground;
enterEditor();
} catch (err) {
alert('Error: ' + err.message);
} finally {
overlay.classList.remove('visible');
}
}
// ─── Editor Setup ───
function enterEditor() {
document.getElementById('upload-screen').style.display = 'none';
document.getElementById('editor-screen').classList.add('visible');
const bgImg = document.getElementById('background-layer');
const fgImg = document.getElementById('foreground-layer');
bgImg.src = originalImageData;
fgImg.src = foregroundImageData;
// Click on text layer background to deselect
document.getElementById('text-layer').addEventListener('mousedown', e => {
if (e.target === e.currentTarget) selectText(null);
});
}
function resetApp() {
textElements = [];
selectedId = null;
nextId = 1;
originalImageData = null;
foregroundImageData = null;
document.getElementById('text-layer').innerHTML = '';
document.getElementById('editor-screen').classList.remove('visible');
document.getElementById('upload-screen').style.display = '';
fileInput.value = '';
updateTextList();
}
// ─── Text Management ───
function addText() {
const id = nextId++;
const te = { ...defaults, id, x: 30 + Math.random() * 40, y: 40 + Math.random() * 20 };
textElements.push(te);
createTextDom(te);
selectText(id);
updateTextList();
}
function createTextDom(te) {
const el = document.createElement('div');
el.className = 'text-element';
el.dataset.id = te.id;
el.textContent = te.text;
applyStyles(el, te);
// Positioning
el.style.left = te.x + '%';
el.style.top = te.y + '%';
el.style.transform = buildTransform(te);
// Drag
let dragging = false, startX, startY, startLeft, startTop;
el.addEventListener('mousedown', e => {
e.preventDefault();
selectText(te.id);
dragging = true;
el.classList.add('dragging');
const container = document.getElementById('canvas-container');
const rect = container.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = te.x;
startTop = te.y;
function onMove(e) {
if (!dragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
te.x = startLeft + (dx / rect.width) * 100;
te.y = startTop + (dy / rect.height) * 100;
el.style.left = te.x + '%';
el.style.top = te.y + '%';
}
function onUp() {
dragging = false;
el.classList.remove('dragging');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// Touch drag
el.addEventListener('touchstart', e => {
e.preventDefault();
selectText(te.id);
dragging = true;
const container = document.getElementById('canvas-container');
const rect = container.getBoundingClientRect();
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
startLeft = te.x;
startTop = te.y;
function onTouchMove(e) {
if (!dragging) return;
const t = e.touches[0];
const dx = t.clientX - startX;
const dy = t.clientY - startY;
te.x = startLeft + (dx / rect.width) * 100;
te.y = startTop + (dy / rect.height) * 100;
el.style.left = te.x + '%';
el.style.top = te.y + '%';
}
function onTouchEnd() {
dragging = false;
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
}
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}, { passive: false });
document.getElementById('text-layer').appendChild(el);
}
function applyStyles(el, te) {
el.style.fontFamily = `"${te.fontFamily}", sans-serif`;
el.style.fontSize = te.fontSize + 'px';
el.style.fontWeight = te.fontWeight;
el.style.letterSpacing = te.letterSpacing + 'px';
el.style.lineHeight = te.lineHeight;
el.style.color = te.color;
el.style.opacity = te.opacity;
// Background
if (te.bgEnabled) {
const r = parseInt(te.bgColor.slice(1, 3), 16);
const g = parseInt(te.bgColor.slice(3, 5), 16);
const b = parseInt(te.bgColor.slice(5, 7), 16);
el.style.backgroundColor = `rgba(${r},${g},${b},${te.bgOpacity})`;
el.style.padding = te.bgPadding + 'px';
} else {
el.style.backgroundColor = 'transparent';
el.style.padding = '0';
}
// Stroke
if (te.strokeEnabled) {
el.style.webkitTextStroke = `${te.strokeWidth}px ${te.strokeColor}`;
} else {
el.style.webkitTextStroke = '';
}
// Shadow + 3D
const shadows = [];
if (te.threeDEnabled) {
const rad = (te.threeDAngle * Math.PI) / 180;
const dx = Math.cos(rad);
const dy = Math.sin(rad);
for (let i = 1; i <= te.threeDDepth; i++) {
shadows.push(`${Math.round(dx * i)}px ${Math.round(dy * i)}px 0 ${te.threeDColor}`);
}
}
if (te.shadowEnabled) {
shadows.push(`${te.shadowX}px ${te.shadowY}px ${te.shadowBlur}px ${te.shadowColor}`);
}
el.style.textShadow = shadows.length ? shadows.join(', ') : 'none';
// Transform
el.style.transform = buildTransform(te);
el.style.transformOrigin = te.perspOrigin;
}
function buildTransform(te) {
const parts = [];
parts.push(`translate(-50%, -50%)`);
// Perspective must come first for rotateX/rotateY to work
if (te.rotateX != 0 || te.rotateY != 0) {
parts.push(`perspective(${te.perspective}px)`);
if (te.rotateX != 0) parts.push(`rotateX(${te.rotateX}deg)`);
if (te.rotateY != 0) parts.push(`rotateY(${te.rotateY}deg)`);
}
if (te.rotate != 0) parts.push(`rotate(${te.rotate}deg)`);
if (te.skewX != 0) parts.push(`skewX(${te.skewX}deg)`);
if (te.skewY != 0) parts.push(`skewY(${te.skewY}deg)`);
return parts.join(' ');
}
function selectText(id) {
selectedId = id;
// Update DOM selection
document.querySelectorAll('.text-element').forEach(el => {
el.classList.toggle('selected', parseInt(el.dataset.id) === id);
});
// Update panel
const panel = document.getElementById('controls-panel');
if (id === null) {
panel.classList.add('disabled');
updateTextList();
return;
}
panel.classList.remove('disabled');
const te = textElements.find(t => t.id === id);
if (!te) return;
// Sync controls to selected element's properties
document.getElementById('ctrl-text').value = te.text;
document.getElementById('ctrl-font').value = te.fontFamily;
document.getElementById('ctrl-size').value = te.fontSize;
document.getElementById('ctrl-size-val').textContent = te.fontSize + 'px';
document.getElementById('ctrl-weight').value = te.fontWeight;
document.getElementById('ctrl-weight-val').textContent = te.fontWeight;
document.getElementById('ctrl-spacing').value = te.letterSpacing;
document.getElementById('ctrl-spacing-val').textContent = te.letterSpacing + 'px';
document.getElementById('ctrl-lineheight').value = te.lineHeight;
document.getElementById('ctrl-lineheight-val').textContent = te.lineHeight;
document.getElementById('ctrl-color').value = te.color;
document.getElementById('ctrl-opacity').value = te.opacity;
document.getElementById('ctrl-opacity-val').textContent = te.opacity;
document.getElementById('ctrl-bg-on').checked = te.bgEnabled;
document.getElementById('ctrl-bg-color').value = te.bgColor;
document.getElementById('ctrl-bg-opacity').value = te.bgOpacity;
document.getElementById('ctrl-bg-opacity-val').textContent = te.bgOpacity;
document.getElementById('ctrl-bg-padding').value = te.bgPadding;
document.getElementById('ctrl-bg-padding-val').textContent = te.bgPadding + 'px';
document.getElementById('ctrl-stroke-on').checked = te.strokeEnabled;
document.getElementById('ctrl-stroke-color').value = te.strokeColor;
document.getElementById('ctrl-stroke-width').value = te.strokeWidth;
document.getElementById('ctrl-stroke-width-val').textContent = te.strokeWidth + 'px';
document.getElementById('ctrl-shadow-on').checked = te.shadowEnabled;
document.getElementById('ctrl-shadow-color').value = te.shadowColor;
document.getElementById('ctrl-shadow-x').value = te.shadowX;
document.getElementById('ctrl-shadow-x-val').textContent = te.shadowX + 'px';
document.getElementById('ctrl-shadow-y').value = te.shadowY;
document.getElementById('ctrl-shadow-y-val').textContent = te.shadowY + 'px';
document.getElementById('ctrl-shadow-blur').value = te.shadowBlur;
document.getElementById('ctrl-shadow-blur-val').textContent = te.shadowBlur + 'px';
document.getElementById('ctrl-3d-on').checked = te.threeDEnabled;
document.getElementById('ctrl-3d-color').value = te.threeDColor;
document.getElementById('ctrl-3d-depth').value = te.threeDDepth;
document.getElementById('ctrl-3d-depth-val').textContent = te.threeDDepth;
document.getElementById('ctrl-3d-angle').value = te.threeDAngle;
document.getElementById('ctrl-3d-angle-val').innerHTML = te.threeDAngle + '&deg;';
document.getElementById('ctrl-rotate').value = te.rotate;
document.getElementById('ctrl-rotate-val').innerHTML = te.rotate + '&deg;';
document.getElementById('ctrl-skewx').value = te.skewX;
document.getElementById('ctrl-skewx-val').innerHTML = te.skewX + '&deg;';
document.getElementById('ctrl-skewy').value = te.skewY;
document.getElementById('ctrl-skewy-val').innerHTML = te.skewY + '&deg;';
document.getElementById('ctrl-persp').value = te.perspective;
document.getElementById('ctrl-persp-val').textContent = te.perspective + 'px';
document.getElementById('ctrl-rotx').value = te.rotateX;
document.getElementById('ctrl-rotx-val').innerHTML = te.rotateX + '&deg;';
document.getElementById('ctrl-roty').value = te.rotateY;
document.getElementById('ctrl-roty-val').innerHTML = te.rotateY + '&deg;';
document.getElementById('ctrl-persp-origin').value = te.perspOrigin;
updateTextList();
}
function updateProp(prop, value) {
if (selectedId === null) return;
const te = textElements.find(t => t.id === selectedId);
if (!te) return;
// Cast numerics
const numerics = ['fontSize','fontWeight','letterSpacing','lineHeight','opacity',
'bgOpacity','bgPadding','strokeWidth','shadowX','shadowY','shadowBlur',
'threeDDepth','threeDAngle','rotate','skewX','skewY',
'perspective','rotateX','rotateY'];
if (numerics.includes(prop)) value = parseFloat(value);
te[prop] = value;
// Update range value display
const valDisplays = {
fontSize: ['ctrl-size-val', v => v + 'px'],
fontWeight: ['ctrl-weight-val', v => v],
letterSpacing: ['ctrl-spacing-val', v => v + 'px'],
lineHeight: ['ctrl-lineheight-val', v => v],
opacity: ['ctrl-opacity-val', v => v],
bgOpacity: ['ctrl-bg-opacity-val', v => v],
bgPadding: ['ctrl-bg-padding-val', v => v + 'px'],
strokeWidth: ['ctrl-stroke-width-val', v => v + 'px'],
shadowX: ['ctrl-shadow-x-val', v => v + 'px'],
shadowY: ['ctrl-shadow-y-val', v => v + 'px'],
shadowBlur: ['ctrl-shadow-blur-val', v => v + 'px'],
threeDDepth: ['ctrl-3d-depth-val', v => v],
threeDAngle: ['ctrl-3d-angle-val', v => v + 'Β°'],
rotate: ['ctrl-rotate-val', v => v + 'Β°'],
skewX: ['ctrl-skewx-val', v => v + 'Β°'],
skewY: ['ctrl-skewy-val', v => v + 'Β°'],
perspective: ['ctrl-persp-val', v => v + 'px'],
rotateX: ['ctrl-rotx-val', v => v + 'Β°'],
rotateY: ['ctrl-roty-val', v => v + 'Β°'],
};
if (valDisplays[prop]) {
const [elId, fmt] = valDisplays[prop];
document.getElementById(elId).textContent = fmt(value);
}
// Update DOM element
const el = document.querySelector(`.text-element[data-id="${te.id}"]`);
if (!el) return;
if (prop === 'text') {
el.textContent = value;
updateTextList();
}
applyStyles(el, te);
}
function deleteSelected() {
if (selectedId === null) return;
const el = document.querySelector(`.text-element[data-id="${selectedId}"]`);
if (el) el.remove();
textElements = textElements.filter(t => t.id !== selectedId);
selectText(null);
updateTextList();
}
function updateTextList() {
const list = document.getElementById('text-list');
const noMsg = document.getElementById('no-text-msg');
// Clear old items (keep noMsg)
list.querySelectorAll('.text-list-item').forEach(el => el.remove());
if (textElements.length === 0) {
noMsg.style.display = '';
return;
}
noMsg.style.display = 'none';
textElements.forEach(te => {
const item = document.createElement('div');
item.className = 'text-list-item' + (te.id === selectedId ? ' active' : '');
const preview = te.text.length > 20 ? te.text.slice(0, 20) + '...' : te.text;
item.innerHTML = `
<span>${preview}</span>
<button class="delete-text-btn" title="Delete">&times;</button>
`;
item.addEventListener('click', e => {
if (e.target.classList.contains('delete-text-btn')) {
const old = selectedId;
selectedId = te.id;
deleteSelected();
return;
}
selectText(te.id);
});
list.appendChild(item);
});
}
// ─── Export Helpers ───
/** Render a text element (with all effects) onto a temporary offscreen canvas. */
function renderTextToTempCanvas(te, fontSize, scaleX, scaleY) {
// Measure text to determine canvas size
const measureCanvas = document.createElement('canvas');
const mCtx = measureCanvas.getContext('2d');
mCtx.font = `${te.fontWeight} ${fontSize}px "${te.fontFamily}", sans-serif`;
if (te.letterSpacing != 0) mCtx.letterSpacing = (te.letterSpacing * scaleX) + 'px';
const lines = te.text.split('\n');
const lineH = fontSize * te.lineHeight;
const totalH = lineH * lines.length;
let maxW = 0;
lines.forEach(line => {
const m = mCtx.measureText(line);
if (m.width > maxW) maxW = m.width;
});
// Add generous padding for shadow, 3D, stroke, bg
const extraDepth = te.threeDEnabled ? te.threeDDepth * scaleX : 0;
const extraShadow = te.shadowEnabled ? Math.max(Math.abs(te.shadowX * scaleX) + te.shadowBlur * scaleX, Math.abs(te.shadowY * scaleY) + te.shadowBlur * scaleX) : 0;
const extraStroke = te.strokeEnabled ? te.strokeWidth * scaleX : 0;
const extraBg = te.bgEnabled ? te.bgPadding * scaleX : 0;
const pad = Math.ceil(Math.max(extraDepth, extraShadow, extraStroke, extraBg) + 20 * scaleX);
const cw = Math.ceil(maxW + pad * 2);
const ch = Math.ceil(totalH + pad * 2);
const tc = document.createElement('canvas');
tc.width = cw;
tc.height = ch;
const tCtx = tc.getContext('2d');
// Center the drawing
tCtx.translate(cw / 2, ch / 2);
tCtx.font = `${te.fontWeight} ${fontSize}px "${te.fontFamily}", sans-serif`;
tCtx.textAlign = 'center';
tCtx.textBaseline = 'middle';
if (te.letterSpacing != 0) tCtx.letterSpacing = (te.letterSpacing * scaleX) + 'px';
const startY = -totalH / 2 + lineH / 2;
// Background
if (te.bgEnabled) {
const bgPad = te.bgPadding * scaleX;
const r = parseInt(te.bgColor.slice(1, 3), 16);
const g = parseInt(te.bgColor.slice(3, 5), 16);
const b = parseInt(te.bgColor.slice(5, 7), 16);
tCtx.fillStyle = `rgba(${r},${g},${b},${te.bgOpacity})`;
tCtx.fillRect(-maxW / 2 - bgPad, startY - lineH / 2 - bgPad, maxW + bgPad * 2, totalH + bgPad * 2);
}
// 3D extrusion
if (te.threeDEnabled) {
const rad = (te.threeDAngle * Math.PI) / 180;
const dx = Math.cos(rad);
const dy = Math.sin(rad);
tCtx.fillStyle = te.threeDColor;
for (let i = Math.ceil(te.threeDDepth * scaleX); i > 0; i--) {
lines.forEach((line, li) => {
tCtx.fillText(line, Math.round(dx * i), startY + li * lineH + Math.round(dy * i));
});
}
}
// Shadow
if (te.shadowEnabled) {
tCtx.shadowColor = te.shadowColor;
tCtx.shadowOffsetX = te.shadowX * scaleX;
tCtx.shadowOffsetY = te.shadowY * scaleY;
tCtx.shadowBlur = te.shadowBlur * scaleX;
}
// Stroke
if (te.strokeEnabled) {
tCtx.strokeStyle = te.strokeColor;
tCtx.lineWidth = te.strokeWidth * scaleX;
tCtx.lineJoin = 'round';
lines.forEach((line, li) => {
tCtx.strokeText(line, 0, startY + li * lineH);
});
tCtx.shadowColor = 'transparent';
}
// Fill text
tCtx.fillStyle = te.color;
if (te.strokeEnabled) tCtx.shadowColor = 'transparent';
lines.forEach((line, li) => {
tCtx.fillText(line, 0, startY + li * lineH);
});
return tc;
}
/** Draw a temp canvas onto the main ctx with perspective warp (scanline approach). */
function drawPerspectiveWarp(ctx, srcCanvas, te) {
const w = srcCanvas.width;
const h = srcCanvas.height;
const p = te.perspective;
const radX = (te.rotateX * Math.PI) / 180;
const radY = (te.rotateY * Math.PI) / 180;
// Compute transform origin relative to the temp canvas center
let originY = 0; // center
if (te.perspOrigin.includes('bottom')) originY = h / 2;
else if (te.perspOrigin.includes('top')) originY = -h / 2;
let originX = 0;
if (te.perspOrigin.includes('right')) originX = w / 2;
else if (te.perspOrigin.includes('left')) originX = -w / 2;
// Scanline warp: process each row
for (let row = 0; row < h; row++) {
const relY = row - h / 2 - originY;
const relYNorm = relY;
// Perspective projection for rotateX
let zY = 0;
let projY = relYNorm;
if (radX !== 0) {
zY = relYNorm * Math.sin(radX);
projY = relYNorm * Math.cos(radX);
}
// For each column scanline for rotateY
let scaleRow = p / (p + zY);
if (scaleRow <= 0.01) continue; // behind camera
const destY = originY + projY * scaleRow + h / 2 - h / 2;
// rotateY affects horizontal scaling per column β€” approximate as uniform row scale
let zX = 0;
if (radY !== 0) {
// Apply average horizontal perspective
zX = 0; // center column has zero z from rotateY
}
const destW = w * scaleRow;
const destX = -(destW / 2);
ctx.drawImage(srcCanvas,
0, row, w, 1,
destX, destY, destW, 1
);
}
// Handle rotateY with a column-based pass if needed
if (radY !== 0) {
// Create another temp canvas with the rotateX result, then warp columns
const midCanvas = document.createElement('canvas');
// For simplicity, approximate rotateY with a horizontal skew
// This gives a good visual approximation
const skewAmount = Math.tan(radY) * 0.5;
ctx.transform(1, 0, skewAmount, 1, 0, 0);
}
}
// ─── Export ───
function exportImage() {
const canvas = document.getElementById('export-canvas');
const ctx = canvas.getContext('2d');
canvas.width = naturalWidth;
canvas.height = naturalHeight;
const bgImg = new Image();
bgImg.crossOrigin = 'anonymous';
bgImg.onload = () => {
// 1. Draw background
ctx.drawImage(bgImg, 0, 0, naturalWidth, naturalHeight);
// 2. Draw text elements
const container = document.getElementById('canvas-container');
const displayW = container.clientWidth;
const displayH = container.clientHeight;
const scaleX = naturalWidth / displayW;
const scaleY = naturalHeight / displayH;
textElements.forEach(te => {
const cx = (te.x / 100) * naturalWidth;
const cy = (te.y / 100) * naturalHeight;
const fontSize = te.fontSize * scaleX;
// Render text to a temporary canvas first
const tmpCanvas = renderTextToTempCanvas(te, fontSize, scaleX, scaleY);
ctx.save();
ctx.globalAlpha = te.opacity;
ctx.translate(cx, cy);
// Apply rotate + skew
if (te.rotate != 0) {
ctx.rotate((te.rotate * Math.PI) / 180);
}
if (te.skewX != 0 || te.skewY != 0) {
const sx = Math.tan((te.skewX * Math.PI) / 180);
const sy = Math.tan((te.skewY * Math.PI) / 180);
ctx.transform(1, sy, sx, 1, 0, 0);
}
if (te.rotateX != 0 || te.rotateY != 0) {
// Perspective warp via scanline rendering
drawPerspectiveWarp(ctx, tmpCanvas, te);
} else {
ctx.drawImage(tmpCanvas, -tmpCanvas.width / 2, -tmpCanvas.height / 2);
}
ctx.restore();
});
// 3. Draw foreground on top
const fgImg = new Image();
fgImg.crossOrigin = 'anonymous';
fgImg.onload = () => {
ctx.drawImage(fgImg, 0, 0, naturalWidth, naturalHeight);
// Download
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'text-behind-image.png';
a.click();
URL.revokeObjectURL(url);
}, 'image/png');
};
fgImg.src = foregroundImageData;
};
bgImg.src = originalImageData;
}
</script>
</body>
</html>