PolyPathFrontEnd / index.html
5m4ck3r's picture
Update index.html
45adf42 verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>LogicSpine PolyPath v2.0</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/@phosphor-icons/web"></script>
<script src="https://cdn.jsdelivr.net/npm/@jaames/iro@5"></script>
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<style>
body { margin: 0; overflow: hidden; background-color: #0a0a0a; color: #fff; touch-action: none; }
/* The Viewport holds the camera */
#viewport {
position: relative; width: 100%; height: 100%; overflow: hidden;
background: radial-gradient(circle, #1a1a1a 0%, #000000 100%);
/* Checkerboard for transparency indication */
background-image: linear-gradient(45deg, #111 25%, transparent 25%),
linear-gradient(-45deg, #111 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #111 75%),
linear-gradient(-45deg, transparent 75%, #111 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
cursor: grab;
}
#viewport:active { cursor: grabbing; }
#viewport.drawing-mode { cursor: crosshair; }
/* The Camera Container that actually moves and scales */
#camera {
position: absolute; top: 0; left: 0;
transform-origin: 0 0;
box-shadow: 0 0 100px rgba(0,0,0,0.8);
}
canvas { display: block; image-rendering: pixelated; } /* Keeps pixels sharp when zoomed */
#overlay-layer { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
/* DOM Objects (Text/Images) */
.draggable-obj {
position: absolute; pointer-events: auto; cursor: move;
border: 2px dashed transparent; box-sizing: border-box;
transform-origin: center center;
}
.draggable-obj:hover, .draggable-obj.active { border-color: #6366f1; }
.editable-text {
outline: none; min-width: 50px; font-family: sans-serif; white-space: nowrap;
}
/* Handles */
.handle {
position: absolute; width: 14px; height: 14px; background: #fff;
border: 2px solid #6366f1; border-radius: 50%; display: none;
}
.draggable-obj.active .handle { display: block; }
.resize-handle { bottom: -7px; right: -7px; cursor: nwse-resize; }
.rotate-handle { top: -25px; left: calc(50% - 7px); cursor: crosshair; }
/* Line connecting rotate handle to box */
.draggable-obj.active::before {
content: ''; position: absolute; top: -18px; left: calc(50% - 1px);
width: 2px; height: 18px; background: #6366f1;
}
/* Color Picker Popover */
#colorPickerUI {
position: absolute; top: 20%; left: 100px; z-index: 100;
display: none; box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
/* Gradient Text Support */
.gradient-text {
background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; color: transparent;
}
/* Loading Screen */
#globalLoader {
backdrop-filter: blur(10px);
transition: opacity 0.3s ease;
}
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #111; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; }
</style>
</head>
<body class="flex flex-col h-screen font-sans selection:bg-indigo-500 selection:text-white" oncontextmenu="return false;">
<header class="h-16 bg-neutral-900 border-b border-neutral-800 flex items-center justify-between px-6 z-30 shrink-0">
<div class="flex items-center gap-3">
<h1 class="text-xl font-bold tracking-widest text-white transition-all"><a href="https://logicspine.in" target="_blank" class="hover:opacity-80 transition-opacity cursor-pointer">LOGIC<span class="text-indigo-500">SPINE</span></a><span class="ml-2 px-2 py-0.5 bg-indigo-500/10 border border-indigo-500/20 rounded text-indigo-400 text-sm align-middle font-mono">PolyPath</span></h1>
<span class="px-2 py-1 text-xs bg-neutral-800 text-neutral-400 rounded-md font-mono"> v2.0</span>
</div>
<div class="flex items-center gap-4">
<button onclick="document.getElementById('fileUpload').click()" class="flex items-center gap-2 hover:text-indigo-400 text-sm font-medium transition-colors">
<i class="ph ph-upload-simple text-lg"></i> Open Image
</button>
<input type="file" id="fileUpload" class="hidden" accept="image/*" onchange="loadImage(event)">
<div class="h-6 w-px bg-neutral-700"></div>
<button onclick="undo()" class="hover:text-white text-neutral-400 transition-colors" title="Undo"><i class="ph ph-arrow-u-up-left text-xl"></i></button>
<button onclick="redo()" class="hover:text-white text-neutral-400 transition-colors" title="Redo"><i class="ph ph-arrow-u-up-right text-xl"></i></button>
<div class="h-6 w-px bg-neutral-700"></div>
<span class="text-xs text-neutral-500 font-mono" id="zoomLevelIndicator">100%</span>
<button onclick="resetCamera()" class="hover:text-white text-neutral-400 text-xs" title="Reset View"><i class="ph ph-corners-out"></i></button>
<div class="h-6 w-px bg-neutral-700 mx-2"></div>
<button onclick="openModal('vectorModal')" class="flex items-center gap-2 px-4 py-2 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg text-sm font-medium transition-all">
<i class="ph ph-bezier-curve"></i> Vector Art
</button>
<button onclick="openModal('exportModal')" class="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 rounded-lg text-sm font-medium shadow-[0_0_15px_rgba(79,70,229,0.3)] transition-all">
<i class="ph ph-download-simple"></i> Export UHD
</button>
<button onclick="openModal('pdfModal')" class="flex items-center gap-2 px-4 py-2 bg-red-600 hover:bg-red-500 rounded-lg text-sm font-medium shadow-[0_0_15px_rgba(220,38,38,0.3)] transition-all">
<i class="ph ph-file-pdf"></i> Export PDF
</button>
</div>
</header>
<div class="flex flex-1 overflow-hidden relative">
<aside class="w-20 bg-neutral-900 border-r border-neutral-800 flex flex-col items-center py-6 gap-6 z-30 shrink-0">
<button class="tool-btn text-indigo-400" data-tool="select" title="Select & Move Objects" onclick="setTool('select')">
<i class="ph ph-cursor text-2xl mb-1"></i><span class="text-[10px]">Select</span>
</button>
<button class="tool-btn text-neutral-400 hover:text-white" data-tool="erase" title="Freehand Eraser" onclick="setTool('erase')">
<i class="ph ph-eraser text-2xl mb-1"></i><span class="text-[10px]">Erase</span>
</button>
<button class="tool-btn text-neutral-400 hover:text-white" data-tool="replaceColor" title="Replace Specific Color" onclick="setTool('replaceColor')">
<i class="ph ph-palette text-2xl mb-1"></i><span class="text-[10px]">Replace</span>
</button>
<button class="tool-btn text-neutral-400 hover:text-white" data-tool="removeColor" title="Remove Color to Transparent" onclick="setTool('removeColor')">
<i class="ph ph-drop text-2xl mb-1"></i><span class="text-[10px]">Remove</span>
</button>
<button class="tool-btn text-neutral-400 hover:text-white" data-tool="canny" title="Area Fill Eraser" onclick="setTool('canny')">
<i class="ph ph-paint-bucket text-2xl mb-1"></i><span class="text-[10px]">Area Fill</span>
</button>
<div class="w-10 h-px bg-neutral-800"></div>
<button class="tool-btn text-neutral-400 hover:text-white" onclick="addTextObject()">
<i class="ph ph-text-t text-2xl mb-1"></i><span class="text-[10px]">Text</span>
</button>
<button class="tool-btn text-neutral-400 hover:text-white" onclick="document.getElementById('addOverlayImage').click()">
<i class="ph ph-image text-2xl mb-1"></i><span class="text-[10px]">Image</span>
</button>
<input type="file" id="addOverlayImage" class="hidden" accept="image/*" onchange="addOverlayImage(event)">
</aside>
<main id="viewport" class="flex-1">
<div id="camera">
<canvas id="mainCanvas"></canvas>
<canvas id="edgeOverlay" style="position:absolute; top:0; left:0; pointer-events:none; opacity:0.8; image-rendering:pixelated;"></canvas>
<div id="overlay-layer"></div>
</div>
</main>
<aside id="propertiesPanel" class="w-64 bg-neutral-900 border-l border-neutral-800 p-4 hidden flex-col gap-4 z-30 shrink-0">
<h3 class="text-xs font-bold text-neutral-500 uppercase tracking-wider mb-2">Properties</h3>
<div id="dynamicProperties" class="flex flex-col gap-4"></div>
</aside>
</div>
<div id="colorPickerUI" class="bg-neutral-900 border border-neutral-700 p-4 rounded-xl w-72">
<div class="flex justify-between items-center mb-4">
<span class="text-sm font-bold text-white">Color Settings</span>
<button onclick="closeColorPicker()" class="text-neutral-500 hover:text-white"><i class="ph ph-x"></i></button>
</div>
<div id="pickerContainer" class="flex justify-center mb-4"></div>
<div class="grid grid-cols-2 gap-2 mb-4">
<div>
<label class="text-xs text-neutral-500">HEX</label>
<input type="text" id="hexInput" class="w-full bg-neutral-800 border border-neutral-700 rounded p-1 text-sm text-center font-mono text-white outline-none focus:border-indigo-500">
</div>
<div>
<label class="text-xs text-neutral-500">RGB</label>
<input type="text" id="rgbInput" class="w-full bg-neutral-800 border border-neutral-700 rounded p-1 text-sm text-center font-mono text-white outline-none focus:border-indigo-500">
</div>
</div>
<button id="eyedropperBtn" onclick="activateEyedropper()" class="w-full py-2 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded flex items-center justify-center gap-2 text-sm transition-colors">
<i class="ph ph-eyedropper text-indigo-400"></i> Pick from Canvas
</button>
</div>
<div id="globalLoader" class="fixed inset-0 bg-black/60 z-50 flex-col items-center justify-center hidden pointer-events-none">
<i class="ph ph-spinner-gap animate-spin text-5xl text-indigo-500 mb-4"></i>
<h2 class="text-xl font-bold tracking-widest text-white" id="loaderText">PROCESSING...</h2>
<p class="text-sm text-neutral-400 mt-2">Computing neural pathways and vector math.</p>
</div>
<div id="exportModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm hidden items-center justify-center z-50 px-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md shadow-2xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold">Export UHD PNG</h2>
<button onclick="closeModal('exportModal')" class="text-neutral-500 hover:text-white"><i class="ph ph-x text-xl"></i></button>
</div>
<label class="block text-sm text-neutral-400 mb-2">Resolution Preset (Width)</label>
<select id="exportPreset" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white mb-4 outline-none focus:border-indigo-500" onchange="document.getElementById('customResDiv').style.display = this.value === 'custom' ? 'block' : 'none'">
<option value="original">Original Size</option>
<option value="2000">High (2000px)</option>
<option value="4000">Ultra (4000px)</option>
<option value="6000">UHD Print (6000px)</option>
<option value="custom">Custom Width...</option>
</select>
<div id="customResDiv" class="hidden mb-4">
<label class="block text-sm text-neutral-400 mb-2">Custom Max Width (px)</label>
<input type="number" id="customWidth" value="8000" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white outline-none focus:border-indigo-500">
</div>
<p class="text-xs text-neutral-500 mb-6">Note: This will bake all text and layers into a single high-resolution PNG image.</p>
<button onclick="executeExport()" class="w-full py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-bold transition-colors">Download Now</button>
</div>
</div>
<div id="vectorModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm hidden items-center justify-center z-50 px-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md shadow-2xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold">Create Vector Art</h2>
<button onclick="closeModal('vectorModal')" class="text-neutral-500 hover:text-white"><i class="ph ph-x text-xl"></i></button>
</div>
<label class="block text-sm text-neutral-400 mb-2">Vector Quality Level</label>
<select id="vectorQuality" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white mb-6 outline-none focus:border-indigo-500">
<option value="low">Low (Fastest, Flat Colors)</option>
<option value="mid">Mid (Good for Logos)</option>
<option value="high">High (Detailed Artwork)</option>
<option value="super" selected>Super High (Max Detail, Heavy File)</option>
</select>
<button onclick="executeVectorize()" class="w-full py-3 bg-indigo-600 hover:bg-indigo-500 rounded-lg font-bold transition-colors">Generate SVG</button>
</div>
</div>
<div id="pdfModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm hidden items-center justify-center z-50 px-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-xl p-6 w-full max-w-md shadow-2xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold">Export Project Report (PDF)</h2>
<button onclick="closeModal('pdfModal')" class="text-neutral-500 hover:text-white"><i class="ph ph-x text-xl"></i></button>
</div>
<label class="block text-sm text-neutral-400 mb-2">Vector Quality</label>
<select id="pdfQuality" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white mb-4 outline-none focus:border-indigo-500">
<option value="low">Low (Fast Generation)</option>
<option value="super" selected>Super High (Max Detail)</option>
</select>
<label class="block text-sm text-neutral-400 mb-2">UHD Render Width (px)</label>
<input type="number" id="pdfWidth" value="6000" class="w-full bg-neutral-800 border border-neutral-700 rounded-lg p-3 text-white mb-6 outline-none focus:border-indigo-500">
<p class="text-xs text-neutral-500 mb-6">Note: This generates a massive 3-page PDF containing the SVG render, UHD render, and Original workspace.</p>
<button onclick="executePDFExport()" class="w-full py-3 bg-red-600 hover:bg-red-500 rounded-lg font-bold transition-colors">Generate PDF</button>
</div>
</div>
<script>
// --- Core Elements ---
const viewport = document.getElementById('viewport');
const camera = document.getElementById('camera');
const canvas = document.getElementById('mainCanvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const overlayLayer = document.getElementById('overlay-layer');
const propsPanel = document.getElementById('propertiesPanel');
const dynProps = document.getElementById('dynamicProperties');
let currentImage = null;
let currentTool = 'select'; // select, erase, replaceColor, removeColor, canny
// --- History ---
let history = []; let historyStep = -1;
function saveState() {
if(!currentImage) return;
historyStep++; history.length = historyStep;
history.push(ctx.getImageData(0, 0, canvas.width, canvas.height));
}
function undo() { if (historyStep > 0) { historyStep--; ctx.putImageData(history[historyStep], 0, 0); } }
function redo() { if (historyStep < history.length - 1) { historyStep++; ctx.putImageData(history[historyStep], 0, 0); } }
// --- Loading System ---
function showLoading(msg = "PROCESSING...") {
document.getElementById('loaderText').innerText = msg;
document.getElementById('globalLoader').style.display = 'flex';
}
function hideLoading() { document.getElementById('globalLoader').style.display = 'none'; }
function openModal(id) { document.getElementById(id).style.display = 'flex'; }
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
// --- Color Picker Logic (iro.js) ---
let iroPicker = new iro.ColorPicker("#pickerContainer", {
width: 200, color: "#ff0000",
layoutDirection: "vertical",
layout: [
{ component: iro.ui.Wheel, options: {} },
{ component: iro.ui.Slider, options: { sliderType: 'value' } },
{ component: iro.ui.Slider, options: { sliderType: 'alpha' } } // Transparency slider
]
});
// Current target to update when color changes (e.g., 'replacementColor', 'textColorActive')
let activeColorTarget = null;
let customReplacementColor = {r: 255, g: 0, b: 0, a: 255};
iroPicker.on('color:change', function(color) {
document.getElementById('hexInput').value = color.hexString;
document.getElementById('rgbInput').value = `rgb(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b})`;
if(activeColorTarget === 'replacement') {
customReplacementColor = {r: color.rgb.r, g: color.rgb.g, b: color.rgb.b, a: Math.round(color.alpha * 255)};
document.getElementById('replaceColorPreview').style.backgroundColor = color.hex8String;
} else if (activeColorTarget === 'text-solid' && activeDOMObject) {
activeDOMObject.querySelector('.editable-text').style.color = color.hex8String;
} else if (activeColorTarget === 'text-grad-1' && activeDOMObject) {
updateTextGradient(activeDOMObject, 1, color.hex8String);
} else if (activeColorTarget === 'text-grad-2' && activeDOMObject) {
updateTextGradient(activeDOMObject, 2, color.hex8String);
}
});
// Input syncing
document.getElementById('hexInput').addEventListener('change', (e) => iroPicker.color.hexString = e.target.value);
function openColorPicker(target, buttonEl) {
activeColorTarget = target;
const ui = document.getElementById('colorPickerUI');
const rect = buttonEl.getBoundingClientRect();
ui.style.display = 'block';
ui.style.top = rect.top + 'px';
ui.style.right = '280px'; // Pop out left of properties panel
ui.style.left = 'auto';
}
function closeColorPicker() { document.getElementById('colorPickerUI').style.display = 'none'; }
let isPickingColor = false;
function activateEyedropper() {
if (!window.EyeDropper) { alert("Your browser doesn't support the native Eyedropper API. Click on the canvas instead."); return; }
const eyeDropper = new EyeDropper();
eyeDropper.open().then(result => {
iroPicker.color.hexString = result.sRGBHex;
}).catch(e => console.log(e));
}
// --- Camera System (Pan & Zoom) ---
let scale = 1, panX = 0, panY = 0;
let isPanning = false, startPanX, startPanY;
function updateCamera() {
camera.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
document.getElementById('zoomLevelIndicator').innerText = Math.round(scale * 100) + '%';
}
function generateEdgeMap() {
if (!currentImage) return;
const eCanvas = document.getElementById('edgeOverlay');
eCanvas.width = canvas.width; eCanvas.height = canvas.height;
const eCtx = eCanvas.getContext('2d');
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imgData.data;
const edgeData = eCtx.createImageData(canvas.width, canvas.height);
const eData = edgeData.data;
// Fast boundary detection
for (let y = 0; y < canvas.height - 1; y++) {
for (let x = 0; x < canvas.width - 1; x++) {
let idx = (y * canvas.width + x) * 4;
let rightIdx = (y * canvas.width + (x + 1)) * 4;
let bottomIdx = ((y + 1) * canvas.width + x) * 4;
if (data[idx+3] === 0) continue; // Skip already transparent areas
// Check difference with neighbor pixels
let diffRight = Math.abs(data[idx]-data[rightIdx]) + Math.abs(data[idx+1]-data[rightIdx+1]) + Math.abs(data[idx+2]-data[rightIdx+2]);
let diffBottom = Math.abs(data[idx]-data[bottomIdx]) + Math.abs(data[idx+1]-data[bottomIdx+1]) + Math.abs(data[idx+2]-data[bottomIdx+2]);
// If color difference is greater than tolerance, draw an outline pixel
if (diffRight > colorTolerance || diffBottom > colorTolerance) {
eData[idx] = 99; // Logic Spine Indigo R
eData[idx+1] = 102; // G
eData[idx+2] = 241; // B
eData[idx+3] = 255; // Solid Alpha
}
}
}
eCtx.putImageData(edgeData, 0, 0);
}
function clearEdgeMap() {
const eCanvas = document.getElementById('edgeOverlay');
if (eCanvas) {
const eCtx = eCanvas.getContext('2d');
eCtx.clearRect(0, 0, eCanvas.width || 10000, eCanvas.height || 10000);
}
}
function resetCamera() {
if(!currentImage) return;
const vRect = viewport.getBoundingClientRect();
// Fit to screen with some padding
const scaleX = (vRect.width - 100) / canvas.width;
const scaleY = (vRect.height - 100) / canvas.height;
scale = Math.min(scaleX, scaleY, 1);
panX = (vRect.width - (canvas.width * scale)) / 2;
panY = (vRect.height - (canvas.height * scale)) / 2;
updateCamera();
}
// Math to convert screen mouse coordinates to raw Canvas coordinates (crucial for accurate drawing when zoomed)
function getCanvasCoords(clientX, clientY) {
const vRect = viewport.getBoundingClientRect();
const mouseX = clientX - vRect.left;
const mouseY = clientY - vRect.top;
return {
x: (mouseX - panX) / scale,
y: (mouseY - panY) / scale
};
}
// Mouse Wheel Zoom
viewport.addEventListener('wheel', (e) => {
e.preventDefault();
const zoomDirection = e.deltaY < 0 ? 1.1 : 0.9;
const vRect = viewport.getBoundingClientRect();
const mouseX = e.clientX - vRect.left;
const mouseY = e.clientY - vRect.top;
const newScale = Math.max(0.1, Math.min(scale * zoomDirection, 20)); // Allow 2000% zoom
// Math to keep the pixel under the mouse in the same screen spot
panX = mouseX - (mouseX - panX) * (newScale / scale);
panY = mouseY - (mouseY - panY) * (newScale / scale);
scale = newScale;
updateCamera();
}, { passive: false });
// Pan with Right Click or Middle Click
viewport.addEventListener('mousedown', (e) => {
if (e.button === 1 || e.button === 2) {
isPanning = true; startPanX = e.clientX - panX; startPanY = e.clientY - panY;
viewport.style.cursor = 'grabbing';
}
});
window.addEventListener('mousemove', (e) => {
if (isPanning) { panX = e.clientX - startPanX; panY = e.clientY - startPanY; updateCamera(); }
});
window.addEventListener('mouseup', (e) => {
if (isPanning) { isPanning = false; viewport.style.cursor = currentTool === 'select' ? 'grab' : 'crosshair'; }
});
// Touch gestures for Mobile (Pinch zoom & drag pan)
let initialPinchDistance = null; let initialScale = 1;
viewport.addEventListener('touchstart', (e) => {
if(e.touches.length === 2) {
initialPinchDistance = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
initialScale = scale;
} else if (e.touches.length === 1 && currentTool === 'select') {
isPanning = true; startPanX = e.touches[0].clientX - panX; startPanY = e.touches[0].clientY - panY;
}
});
viewport.addEventListener('touchmove', (e) => {
if(e.touches.length === 2 && initialPinchDistance) {
e.preventDefault();
const dist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
const zoomDir = dist / initialPinchDistance;
// Simple zoom relative to center for mobile to save complexity
const newScale = Math.max(0.1, Math.min(initialScale * zoomDir, 20));
const vRect = viewport.getBoundingClientRect();
const centerX = vRect.width / 2; const centerY = vRect.height / 2;
panX = centerX - (centerX - panX) * (newScale / scale);
panY = centerY - (centerY - panY) * (newScale / scale);
scale = newScale; updateCamera();
} else if (e.touches.length === 1 && isPanning) {
panX = e.touches[0].clientX - startPanX; panY = e.touches[0].clientY - startPanY; updateCamera();
}
}, { passive: false });
viewport.addEventListener('touchend', () => { initialPinchDistance = null; isPanning = false; });
// --- UI & Tools ---
let brushSize = 20; let colorTolerance = 30;
function setTool(toolName) {
currentTool = toolName;
document.querySelectorAll('.tool-btn').forEach(btn => {
btn.classList.remove('text-indigo-400'); btn.classList.add('text-neutral-400');
if(btn.dataset.tool === toolName) { btn.classList.remove('text-neutral-400'); btn.classList.add('text-indigo-400'); }
});
viewport.className = toolName === 'select' ? 'flex-1 cursor-grab' : 'flex-1 drawing-mode';
propsPanel.style.display = 'flex'; propsPanel.classList.remove('hidden');
// NEW: Handle the Live Edge Overlay
if (toolName === 'canny' && currentImage) {
showLoading("MAPPING EDGES...");
setTimeout(() => { generateEdgeMap(); hideLoading(); }, 50);
} else {
clearEdgeMap();
}
if(toolName === 'erase') {
dynProps.innerHTML = `
<label class="text-sm text-neutral-400">Brush Size</label>
<input type="range" min="1" max="200" value="${brushSize}" onchange="brushSize = this.value" class="w-full accent-indigo-500">
`;
} else if (toolName === 'replaceColor' || toolName === 'removeColor' || toolName === 'canny') {
let replaceBtn = toolName === 'replaceColor' ? `
<label class="text-sm text-neutral-400 mt-4">Replace With</label>
<button id="replaceColorPreview" onclick="openColorPicker('replacement', this)" class="w-full h-10 rounded border border-neutral-700 mt-1" style="background: red;"></button>
` : '';
let liveUpdate = toolName === 'canny' ? `oninput="colorTolerance = parseInt(this.value); generateEdgeMap();"` : `onchange="colorTolerance = parseInt(this.value)"`;
// Generate the Palette HTML
let paletteHtml = '<div class="grid grid-cols-4 gap-2 mt-2">';
if (currentImage) {
const colors = getDominantColors();
colors.forEach(c => {
paletteHtml += `<button onclick="setTargetColorFromPalette(${c[0]}, ${c[1]}, ${c[2]})" class="w-full h-8 rounded border border-neutral-700 hover:scale-110 transition-transform" style="background: rgb(${c[0]},${c[1]},${c[2]})"></button>`;
});
}
paletteHtml += '</div>';
dynProps.innerHTML = `
<label class="text-sm text-neutral-400">Dominant Colors</label>
${paletteHtml}
<p class="text-[10px] text-neutral-500 mt-1 mb-4">Click a swatch to target it, or click the image manually.</p>
<label class="text-sm text-neutral-400">Tolerance</label>
<input type="range" min="0" max="255" value="${colorTolerance}" ${liveUpdate} class="w-full accent-indigo-500">
${replaceBtn}
`;
} else { propsPanel.style.display = 'none'; }
}
// --- Drawing & Image Processing ---
function loadImage(e) {
const file = e.target.files[0]; if (!file) return;
showLoading("LOADING IMAGE...");
const reader = new FileReader();
reader.onload = function(event) {
const img = new Image();
img.onload = function() {
canvas.width = img.width; canvas.height = img.height;
ctx.drawImage(img, 0, 0);
currentImage = img;
camera.style.width = img.width + 'px'; camera.style.height = img.height + 'px';
history = []; historyStep = -1; saveState();
resetCamera(); setTool('select'); hideLoading();
}
img.src = event.target.result;
}
reader.readAsDataURL(file);
}
let isDrawing = false;
viewport.addEventListener('mousedown', (e) => {
if(!currentImage || e.button !== 0 || currentTool === 'select' || e.target.closest('.draggable-obj')) return;
const coords = getCanvasCoords(e.clientX, e.clientY);
if (currentTool === 'erase') {
isDrawing = true; ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath(); ctx.arc(coords.x, coords.y, brushSize / 2, 0, Math.PI * 2); ctx.fill();
} else if (currentTool === 'removeColor' || currentTool === 'replaceColor') {
showLoading("PROCESSING PIXELS...");
// Use setTimeout to allow UI to render loading screen before heavy CPU task
setTimeout(() => {
processGlobalColor(coords.x, coords.y, currentTool === 'removeColor' ? 'remove' : 'replace');
hideLoading();
}, 50);
} else if (currentTool === 'canny') {
showLoading("CALCULATING AREA...");
setTimeout(() => { floodFill(Math.floor(coords.x), Math.floor(coords.y)); hideLoading(); }, 50);
}
});
viewport.addEventListener('mousemove', (e) => {
if(!isDrawing || currentTool !== 'erase') return;
const coords = getCanvasCoords(e.clientX, e.clientY);
ctx.lineTo(coords.x, coords.y);
ctx.lineWidth = brushSize; ctx.lineCap = 'round'; ctx.stroke();
ctx.beginPath(); ctx.moveTo(coords.x, coords.y);
});
window.addEventListener('mouseup', () => { if(isDrawing) { isDrawing = false; ctx.globalCompositeOperation = 'source-over'; saveState(); } });
// --- Color Palette Extractor ---
function getDominantColors() {
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const colorCounts = {};
// Scan every 4th pixel to save CPU time (fast processing)
for (let i = 0; i < imgData.length; i += 16) {
if (imgData[i+3] === 0) continue; // Skip transparent
// Round to nearest 15 to group very similar shades together
const r = Math.round(imgData[i] / 15) * 15;
const g = Math.round(imgData[i+1] / 15) * 15;
const b = Math.round(imgData[i+2] / 15) * 15;
const rgb = `${r},${g},${b}`;
colorCounts[rgb] = (colorCounts[rgb] || 0) + 1;
}
// Sort by most used and grab the top 12 colors
return Object.entries(colorCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 12)
.map(e => e[0].split(',').map(Number));
}
// Global function for the UI buttons to set the target color
window.setTargetColorFromPalette = function(r, g, b) {
// Simulate a click on the canvas to set the target for the Replace/Remove tools
targetColor = {r: r, g: g, b: b, a: 255};
alert(`Target color set to RGB(${r}, ${g}, ${b}). Now click the canvas to apply!`);
};
// --- Pixel Algorithms ---
function getPixelColor(x, y, imgData) {
const idx = (Math.floor(y) * canvas.width + Math.floor(x)) * 4;
return { r: imgData.data[idx], g: imgData.data[idx+1], b: imgData.data[idx+2], a: imgData.data[idx+3] };
}
function colorMatch(c1, c2, tol) { return Math.abs(c1.r - c2.r) <= tol && Math.abs(c1.g - c2.g) <= tol && Math.abs(c1.b - c2.b) <= tol && c1.a > 0; }
function processGlobalColor(x, y, mode) {
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const target = getPixelColor(x, y, imgData);
for (let i = 0; i < imgData.data.length; i += 4) {
let current = {r: imgData.data[i], g: imgData.data[i+1], b: imgData.data[i+2], a: imgData.data[i+3]};
if (colorMatch(current, target, colorTolerance)) {
if (mode === 'remove') imgData.data[i+3] = 0;
else { imgData.data[i] = customReplacementColor.r; imgData.data[i+1] = customReplacementColor.g; imgData.data[i+2] = customReplacementColor.b; imgData.data[i+3] = customReplacementColor.a; }
}
}
ctx.putImageData(imgData, 0, 0); saveState();
}
function floodFill(startX, startY) {
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const w = canvas.width; const h = canvas.height;
const target = getPixelColor(startX, startY, imgData);
if(target.a === 0) return; // Don't fill empty space
const stack = [[startX, startY]];
const getIdx = (x, y) => (y * w + x) * 4;
while(stack.length) {
let [x, y] = stack.pop(); let pixelPos = getIdx(x, y);
while(y-- >= 0 && colorMatch(getPixelColor(x, y, imgData), target, colorTolerance)) pixelPos -= w * 4;
pixelPos += w * 4; ++y;
let reachLeft = false; let reachRight = false;
while(y++ < h - 1 && colorMatch(getPixelColor(x, y, imgData), target, colorTolerance)) {
imgData.data[pixelPos + 3] = 0; // Erase to transparent
if(x > 0) {
if(colorMatch(getPixelColor(x - 1, y, imgData), target, colorTolerance)) {
if(!reachLeft) { stack.push([x - 1, y]); reachLeft = true; }
} else reachLeft = false;
}
if(x < w - 1) {
if(colorMatch(getPixelColor(x + 1, y, imgData), target, colorTolerance)) {
if(!reachRight) { stack.push([x + 1, y]); reachRight = true; }
} else reachRight = false;
}
pixelPos += w * 4;
}
}
ctx.putImageData(imgData, 0, 0);
saveState();
// Re-run the edge map so the preview updates immediately after the cut!
generateEdgeMap();
}
// --- DOM Objects (Text & Images) with Rotate & Resize ---
let activeDOMObject = null;
let objCounter = 0;
function showTextProperties(el) {
propsPanel.style.display = 'flex';
dynProps.innerHTML = `
<label class="text-sm text-neutral-400">Color Style</label>
<select id="colorStyleSel" class="w-full bg-neutral-800 border border-neutral-700 rounded p-2 text-white outline-none mb-2" onchange="toggleGradientUI(this.value)">
<option value="solid">Solid Color</option>
<option value="gradient">Linear Gradient</option>
</select>
<div id="solidUI">
<button onclick="openColorPicker('text-solid', this)" class="w-full h-8 rounded border border-neutral-700" style="background: white;"></button>
</div>
<div id="gradientUI" class="hidden">
<div class="flex gap-2">
<button onclick="openColorPicker('text-grad-1', this)" class="w-full h-8 rounded border border-neutral-700" style="background: #ff0000;"></button>
<button onclick="openColorPicker('text-grad-2', this)" class="w-full h-8 rounded border border-neutral-700" style="background: #0000ff;"></button>
</div>
<label class="text-xs text-neutral-400 mt-2">Direction</label>
<select id="gradDirSel" onchange="updateTextGradientActive()" class="w-full bg-neutral-800 border border-neutral-700 rounded p-1 text-white text-xs">
<option value="to right">Horizontal</option>
<option value="to bottom">Vertical</option>
<option value="to bottom right">Diagonal</option>
</select>
</div>
<label class="text-sm text-neutral-400 mt-4">Font Size</label>
<input type="range" min="10" max="500" value="60" oninput="activeDOMObject.querySelector('.editable-text').style.fontSize = this.value + 'px'" class="w-full accent-indigo-500">
<button onclick="activeDOMObject.remove(); propsPanel.style.display='none'" class="mt-4 w-full py-2 bg-red-900/50 hover:bg-red-800 text-red-400 rounded transition-colors text-sm"><i class="ph ph-trash"></i> Delete Object</button>
`;
}
// Expose function for the select menu
window.toggleGradientUI = function(val) {
document.getElementById('solidUI').style.display = val === 'solid' ? 'block' : 'none';
document.getElementById('gradientUI').style.display = val === 'gradient' ? 'block' : 'none';
if(val === 'solid') {
activeDOMObject.querySelector('.editable-text').classList.remove('gradient-text');
activeDOMObject.querySelector('.editable-text').style.background = 'none';
} else {
activeDOMObject.querySelector('.editable-text').classList.add('gradient-text');
updateTextGradientActive();
}
};
window.updateTextGradientActive = function() {
if(!activeDOMObject) return;
const dir = document.getElementById('gradDirSel').value;
// Get colors from buttons (simplified for this demo)
const c1 = document.querySelectorAll('#gradientUI button')[0].style.backgroundColor;
const c2 = document.querySelectorAll('#gradientUI button')[1].style.backgroundColor;
updateTextGradient(activeDOMObject, null, null, `${dir}, ${c1}, ${c2}`);
}
function updateTextGradient(obj, num, hex, fullCssString) {
const txt = obj.querySelector('.editable-text');
if(fullCssString) {
txt.style.backgroundImage = `linear-gradient(${fullCssString})`;
} else {
// Hacking it slightly to avoid complex state management for this demo
txt.style.backgroundImage = `linear-gradient(to right, ${num===1?hex:'red'}, ${num===2?hex:'blue'})`;
}
}
function makeTransformable(el, type) {
let isDragging = false, isResizing = false, isRotating = false;
let startX, startY, initX, initY, initW, initH;
let centerX, centerY, currentRotation = 0;
const handleResize = el.querySelector('.resize-handle');
const handleRotate = el.querySelector('.rotate-handle');
el.addEventListener('mousedown', (e) => {
if(currentTool !== 'select') return;
e.stopPropagation(); // Stop pan
document.querySelectorAll('.draggable-obj').forEach(obj => obj.classList.remove('active'));
el.classList.add('active'); activeDOMObject = el;
closeColorPicker();
if (type === 'text') showTextProperties(el);
if(e.target === handleResize) { isResizing = true; startX = e.clientX; startY = e.clientY; initW = el.offsetWidth; initH = el.offsetHeight; return; }
if(e.target === handleRotate) {
isRotating = true;
const rect = el.getBoundingClientRect();
centerX = rect.left + rect.width / 2; centerY = rect.top + rect.height / 2;
return;
}
isDragging = true; startX = e.clientX; startY = e.clientY;
// Parse translate if it exists
const transform = el.style.transform;
const matchX = transform.match(/translateX\(([-\d.]+)px\)/);
const matchY = transform.match(/translateY\(([-\d.]+)px\)/);
initX = matchX ? parseFloat(matchX[1]) : 0;
initY = matchY ? parseFloat(matchY[1]) : 0;
});
window.addEventListener('mousemove', (e) => {
if(isDragging) {
const dx = (e.clientX - startX) / scale; // Account for camera scale
const dy = (e.clientY - startY) / scale;
el.style.transform = `translateX(${initX + dx}px) translateY(${initY + dy}px) rotate(${currentRotation}deg)`;
} else if (isResizing && type === 'image') {
// Only image resizes physically, text resizes via font-size
const dx = (e.clientX - startX) / scale;
el.querySelector('img').style.width = Math.max(50, initW + dx) + 'px';
} else if (isRotating) {
const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX);
currentRotation = angle * (180 / Math.PI) + 90; // Offset by 90deg because handle is at top
// Extract current translate
const transform = el.style.transform;
const matchX = transform.match(/translateX\(([-\d.]+)px\)/) || [0,0];
const matchY = transform.match(/translateY\(([-\d.]+)px\)/) || [0,0];
el.style.transform = `translateX(${matchX[1]}px) translateY(${matchY[1]}px) rotate(${currentRotation}deg)`;
}
});
window.addEventListener('mouseup', () => { isDragging = false; isResizing = false; isRotating = false; });
}
function addTextObject() {
setTool('select');
const div = document.createElement('div');
div.className = 'draggable-obj active';
// Start centered in current view
div.style.transform = `translateX(${-panX/scale + 100}px) translateY(${-panY/scale + 100}px) rotate(0deg)`;
const txt = document.createElement('div');
txt.className = 'editable-text';
txt.contentEditable = true;
txt.innerText = "Double Click to Edit";
txt.style.fontSize = '60px';
txt.style.color = '#ffffff';
div.innerHTML = `<div class="handle rotate-handle"></div>`;
div.appendChild(txt);
overlayLayer.appendChild(div);
activeDOMObject = div;
makeTransformable(div, 'text');
showTextProperties(div);
}
function addOverlayImage(e) {
setTool('select');
const file = e.target.files[0]; if (!file) return;
const reader = new FileReader();
reader.onload = function(event) {
const div = document.createElement('div');
div.className = 'draggable-obj active';
div.style.transform = `translateX(${-panX/scale + 100}px) translateY(${-panY/scale + 100}px) rotate(0deg)`;
div.innerHTML = `
<div class="handle rotate-handle"></div>
<img src="${event.target.result}" style="width: 300px; display: block;" draggable="false">
<div class="handle resize-handle"></div>
`;
overlayLayer.appendChild(div);
activeDOMObject = div;
makeTransformable(div, 'image');
// Show simple delete props
propsPanel.style.display = 'flex';
dynProps.innerHTML = `<button onclick="activeDOMObject.remove(); propsPanel.style.display='none'" class="mt-4 w-full py-2 bg-red-900/50 hover:bg-red-800 text-red-400 rounded text-sm"><i class="ph ph-trash"></i> Delete Image</button>`;
}
reader.readAsDataURL(file);
}
async function executeVectorize() {
closeModal('vectorModal');
const quality = document.getElementById('vectorQuality').value;
// 1. Get the flattened image
const blob = await getFlattenedImageBlob();
// 2. Prepare the payload
const formData = new FormData();
formData.append('file', blob, 'logicspine_workspace.png');
formData.append('quality', quality);
showLoading(`VECTORIZING (${quality.toUpperCase()})...`);
try {
// 3. Send to FastAPI
const response = await fetch('https://5m4ck3r-polypath2-0.hf.space/vectorize/', {
method: 'POST',
headers: {
'X-LogicSpine-Key': 'LogicSpine_PolyPath_Secure_2026!'
},
body: formData
});
if (!response.ok) throw new Error("Backend failed");
// 4. Download the massive SVG directly to the browser
const blobRes = await response.blob();
const url = window.URL.createObjectURL(blobRes);
const a = document.createElement('a');
a.href = url;
a.download = `LogicSpine_${quality}_vector.svg`;
a.click();
} catch (e) {
alert("Error connecting to Python backend. Is uvicorn running?");
console.error(e);
}
hideLoading();
}
async function executeExport() {
closeModal('exportModal');
let preset = document.getElementById('exportPreset').value;
let width = preset === 'custom' ? document.getElementById('customWidth').value : preset;
if (width === 'original') width = canvas.width;
// 1. Get the flattened image
const blob = await getFlattenedImageBlob();
// 2. Prepare the payload
const formData = new FormData();
formData.append('file', blob, 'logicspine_workspace.png');
formData.append('width', width);
showLoading(`UPSCALING TO ${width}px...`);
try {
// 3. Send to FastAPI
const response = await fetch('https://5m4ck3r-polypath2-0.hf.space/export-uhd/', {
method: 'POST',
headers: {
'X-LogicSpine-Key': 'LogicSpine_PolyPath_Secure_2026!'
},
body: formData
});
if (!response.ok) throw new Error("Backend failed");
// 4. Download the ultra-high-res PNG
const blobRes = await response.blob();
const url = window.URL.createObjectURL(blobRes);
const a = document.createElement('a');
a.href = url;
a.download = `LogicSpine_UHD_${width}px.png`;
a.click();
} catch (e) {
alert("Error connecting to Python backend. Is uvicorn running?");
console.error(e);
}
hideLoading();
}
// --- API Integration & Layer Flattening ---
async function getFlattenedImageBlob() {
showLoading("FLATTENING LAYERS...");
// 1. Hide UI elements
document.querySelectorAll('.active').forEach(el => el.classList.remove('active'));
clearEdgeMap();
// 2. Temporarily reset camera zoom
const currentTransform = camera.style.transform;
camera.style.transform = `translate(0px, 0px) scale(1)`;
const cameraDiv = document.getElementById('camera');
// THE FIX: Remove the glowing drop shadow temporarily so it doesn't bleed into the transparency!
const originalShadow = cameraDiv.style.boxShadow;
cameraDiv.style.boxShadow = 'none';
// 3. Take the snapshot
const renderedCanvas = await html2canvas(cameraDiv, {
backgroundColor: null, // Keeps the background completely transparent
scale: 1,
useCORS: true,
logging: false
});
// 4. Put the camera and shadow back where the user had it
cameraDiv.style.boxShadow = originalShadow;
camera.style.transform = currentTransform;
hideLoading();
// Convert to a file we can send to Python
return new Promise(resolve => renderedCanvas.toBlob(resolve, 'image/png'));
}
async function executePDFExport() {
closeModal('pdfModal');
const quality = document.getElementById('pdfQuality').value;
const width = document.getElementById('pdfWidth').value;
// 1. Get the flattened image of the workspace
const blob = await getFlattenedImageBlob();
// 2. Prepare the payload matching the Python backend requirements
const formData = new FormData();
formData.append('file', blob, 'logicspine_workspace.png');
formData.append('quality', quality);
formData.append('width', width);
showLoading(`GENERATING 3-PAGE PDF...`);
try {
// 3. Send to FastAPI
const response = await fetch('https://5m4ck3r-polypath2-0.hf.space/export-pdf/', {
method: 'POST',
headers: {
'X-LogicSpine-Key': 'LogicSpine_PolyPath_Secure_2026!'
},
body: formData
});
if (!response.ok) throw new Error("Backend failed");
// 4. Download the final PDF file
const blobRes = await response.blob();
const url = window.URL.createObjectURL(blobRes);
const a = document.createElement('a');
a.href = url;
a.download = `LogicSpine_Project_Report.pdf`;
a.click();
} catch (e) {
alert("Error connecting to Python backend. Check terminal for errors.");
console.error(e);
}
hideLoading();
}
</script>
</body>
</html>