Last active
January 23, 2026 02:19
-
-
Save akhenakh/53e896fb3e325fa2049b218b9f0f5c7d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Comic Strip Builder - Fixed Resize</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> | |
| <style> | |
| :root { | |
| --primary: #4a90e2; | |
| --text: #333; | |
| --gutter-hover: #4a90e2; | |
| } | |
| body { | |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
| background-color: #f0f2f5; | |
| color: var(--text); | |
| margin: 0; | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| user-select: none; | |
| } | |
| .controls { | |
| background: #ffffff; | |
| border: 1px solid #ddd; | |
| padding: 20px; | |
| border-radius: 8px; | |
| margin-bottom: 20px; | |
| display: flex; | |
| gap: 15px; | |
| flex-wrap: wrap; | |
| align-items: center; | |
| justify-content: center; | |
| max-width: 800px; | |
| box-shadow: 0 2px 5px rgba(0,0,0,0.05); | |
| } | |
| .input-group { | |
| display: flex; | |
| flex-direction: column; | |
| font-size: 0.9rem; | |
| } | |
| input { | |
| padding: 8px; | |
| border: 1px solid #ccc; | |
| border-radius: 4px; | |
| margin-top: 5px; | |
| width: 80px; | |
| } | |
| button { | |
| background-color: var(--primary); | |
| color: white; | |
| border: none; | |
| padding: 10px 20px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 1rem; | |
| margin-top: auto; | |
| } | |
| button:hover { background-color: #357abd; } | |
| button.secondary { background-color: #666; } | |
| /* Layout Preview Buttons */ | |
| #layout-options { display: flex; gap: 15px; flex-wrap: wrap; justify-content: center; margin: 10px 0 20px 0; } | |
| .layout-btn { | |
| border: 2px solid #ddd; background: white; cursor: pointer; | |
| padding: 4px; width: 80px; height: 80px; | |
| display: grid; gap: 4px; | |
| transition: all 0.2s; | |
| } | |
| .layout-btn:hover { border-color: var(--primary); transform: translateY(-2px); } | |
| .mini-panel { background: #cbd5e0; border: 1px solid #a0aec0; } | |
| /* Comic Canvas */ | |
| #comic-wrapper { | |
| margin-bottom: 20px; | |
| display: none; | |
| border: 1px solid #ccc; | |
| box-shadow: 0 10px 25px rgba(0,0,0,0.1); | |
| position: relative; | |
| } | |
| #comic-page { | |
| background-color: white; | |
| display: grid; | |
| gap: 10px; /* The Gutter Size */ | |
| padding: 15px; | |
| box-sizing: border-box; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .comic-panel { | |
| background-color: #ffffff; | |
| border: 3px solid #000; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; | |
| position: relative; | |
| cursor: pointer; | |
| z-index: 1; | |
| } | |
| .comic-panel span.placeholder { | |
| pointer-events: none; | |
| color: #999; | |
| text-align: center; | |
| padding: 10px; | |
| font-weight: bold; | |
| text-transform: uppercase; | |
| z-index: 2; | |
| } | |
| .comic-panel img { | |
| position: absolute; | |
| cursor: grab; | |
| display: none; | |
| /* Fix: ensure image doesn't force grid expansion */ | |
| max-width: none; | |
| max-height: none; | |
| } | |
| .comic-panel img:active { cursor: grabbing; } | |
| /* Resizers */ | |
| #resizer-layer { | |
| position: absolute; | |
| top: 0; left: 0; | |
| width: 100%; height: 100%; | |
| pointer-events: none; /* Let clicks pass through to panels... */ | |
| z-index: 10; | |
| } | |
| .resizer { | |
| position: absolute; | |
| pointer-events: auto; /* ...except for the resizers themselves */ | |
| background-color: transparent; | |
| /* Debug: Uncomment below to see the hitboxes */ | |
| /* background-color: rgba(255, 0, 0, 0.2); */ | |
| } | |
| .resizer:hover, .resizer.dragging { | |
| background-color: var(--gutter-hover); | |
| transition: background-color 0.2s; | |
| } | |
| .resizer-h { cursor: row-resize; height: 10px; transform: translateY(-50%); } | |
| .resizer-v { cursor: col-resize; width: 10px; transform: translateX(-50%); } | |
| #loading { | |
| position: fixed; top:0; left:0; width:100%; height:100%; | |
| background: rgba(255,255,255,0.9); display: none; | |
| justify-content: center; align-items: center; | |
| font-size: 1.5rem; font-weight: bold; z-index: 999; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Comic Strip Builder</h1> | |
| <div class="controls" id="step-1"> | |
| <div class="input-group"> | |
| <label>Width</label> | |
| <input type="number" id="page-width" value="896" min="200" max="2000"> | |
| </div> | |
| <div class="input-group"> | |
| <label>Height</label> | |
| <input type="number" id="page-height" value="1152" min="200" max="3000"> | |
| </div> | |
| <div class="input-group"> | |
| <label>Panels</label> | |
| <input type="number" id="panel-count" value="3" min="1" max="6"> | |
| </div> | |
| <button onclick="showLayoutOptions()">Choose Layout</button> | |
| </div> | |
| <div class="controls" id="step-2" style="display:none;"> | |
| <p style="width: 100%; text-align: center; margin: 0;">Select Layout:</p> | |
| <div id="layout-options"></div> | |
| <button class="secondary" onclick="reset()">Back</button> | |
| </div> | |
| <div class="controls" id="step-3" style="display:none;"> | |
| <div style="flex-grow: 1; text-align: left; font-size: 0.9rem;"> | |
| <strong>How to edit:</strong><br> | |
| • <strong>Click</strong> panel to upload.<br> | |
| • <strong>Drag Image</strong> to move it.<br> | |
| • <strong>Mouse Wheel</strong> to zoom image.<br> | |
| • <strong>Drag Blue Lines</strong> between panels to resize strips. | |
| </div> | |
| <div> | |
| <button class="secondary" onclick="showLayoutOptions()">Reset / Change</button> | |
| <button onclick="downloadComic()">Download</button> | |
| </div> | |
| </div> | |
| <div id="comic-wrapper"> | |
| <div id="comic-page"></div> | |
| <div id="resizer-layer"></div> | |
| </div> | |
| <div id="loading">Generating Image...</div> | |
| <script> | |
| // --- CONFIG & STATE --- | |
| let config = { width: 800, height: 1000, panels: 3 }; | |
| // We use "fr" units (fractions) instead of % to prevent gaps from breaking math | |
| let gridState = { | |
| rows: [], // array of numbers representing 'fr' (e.g. [1, 1, 1]) | |
| cols: [], | |
| gap: 10 | |
| }; | |
| const comicPage = document.getElementById('comic-page'); | |
| const resizerLayer = document.getElementById('resizer-layer'); | |
| const layoutOptionsContainer = document.getElementById('layout-options'); | |
| const layoutTemplates = { | |
| 1: [ { css: `'a'`, name: "Full Page" } ], | |
| 2: [ { css: `'a' 'b'`, name: "Vertical Split" }, { css: `'a b'`, name: "Horizontal Split" } ], | |
| 3: [ | |
| { css: `'a' 'b' 'c'`, name: "Three Rows" }, | |
| { css: `'a b c'`, name: "Three Columns" }, | |
| { css: `'a a' 'b c'`, name: "Top Large" }, | |
| { css: `'a b' 'c c'`, name: "Bottom Large" } | |
| ], | |
| 4: [ | |
| { css: `'a b' 'c d'`, name: "2x2 Grid" }, | |
| { css: `'a' 'b' 'c' 'd'`, name: "Four Rows" }, | |
| { css: `'a a a' 'b c d'`, name: "Top Large" } | |
| ], | |
| 5: [ { css: `'a a' 'b c' 'd e'`, name: "1-2-2" } ], | |
| 6: [ { css: `'a b' 'c d' 'e f'`, name: "2x3 Grid" } ] | |
| }; | |
| // --- NAVIGATION --- | |
| function reset() { | |
| document.getElementById('step-1').style.display = 'flex'; | |
| document.getElementById('step-2').style.display = 'none'; | |
| document.getElementById('step-3').style.display = 'none'; | |
| document.getElementById('comic-wrapper').style.display = 'none'; | |
| } | |
| function showLayoutOptions() { | |
| config.width = parseInt(document.getElementById('page-width').value); | |
| config.height = parseInt(document.getElementById('page-height').value); | |
| config.panels = parseInt(document.getElementById('panel-count').value); | |
| layoutOptionsContainer.innerHTML = ''; | |
| let templates = layoutTemplates[config.panels] || [{ css: '', name: 'Stacked' }]; | |
| templates.forEach((tpl) => { | |
| const btn = document.createElement('div'); | |
| btn.className = 'layout-btn'; | |
| btn.title = tpl.name; | |
| btn.style.gridTemplateAreas = tpl.css || generateFallbackGrid(config.panels); | |
| // Preview Grid | |
| btn.style.gridAutoRows = "1fr"; | |
| btn.style.gridAutoColumns = "1fr"; | |
| for(let i=0; i<config.panels; i++) { | |
| let d = document.createElement('div'); | |
| d.className = 'mini-panel'; | |
| if(tpl.css) d.style.gridArea = String.fromCharCode(97 + i); | |
| btn.appendChild(d); | |
| } | |
| btn.onclick = () => buildComic(tpl.css); | |
| layoutOptionsContainer.appendChild(btn); | |
| }); | |
| document.getElementById('step-1').style.display = 'none'; | |
| document.getElementById('step-2').style.display = 'flex'; | |
| document.getElementById('step-3').style.display = 'none'; | |
| document.getElementById('comic-wrapper').style.display = 'none'; | |
| } | |
| function generateFallbackGrid(count) { | |
| let str = ""; | |
| for(let i=0; i<count; i++) str += `'${String.fromCharCode(97+i)}' `; | |
| return str.trim(); | |
| } | |
| // --- BUILDER --- | |
| function buildComic(gridAreas) { | |
| document.getElementById('step-2').style.display = 'none'; | |
| document.getElementById('step-3').style.display = 'flex'; | |
| document.getElementById('comic-wrapper').style.display = 'block'; | |
| comicPage.style.width = config.width + 'px'; | |
| comicPage.style.height = config.height + 'px'; | |
| comicPage.innerHTML = ''; | |
| resizerLayer.innerHTML = ''; | |
| // 1. Initialize Grid Logic | |
| if(gridAreas) { | |
| comicPage.style.gridTemplateAreas = gridAreas; | |
| // Count rows/cols from the string structure | |
| const rowsCount = gridAreas.split("' '").length; | |
| const colsCount = gridAreas.split("' '")[0].replace(/'/g, '').trim().split(' ').length; | |
| // Reset state to equal fractions (1fr each) | |
| gridState.rows = Array(rowsCount).fill(1); | |
| gridState.cols = Array(colsCount).fill(1); | |
| } else { | |
| // Fallback (stack) | |
| gridState.rows = Array(config.panels).fill(1); | |
| gridState.cols = [1]; | |
| comicPage.style.gridTemplateAreas = "none"; | |
| } | |
| applyGridState(); | |
| // 2. Create Panels | |
| for(let i=0; i<config.panels; i++) { | |
| const panel = document.createElement('div'); | |
| panel.className = 'comic-panel'; | |
| if(gridAreas) panel.style.gridArea = String.fromCharCode(97 + i); | |
| const placeholder = document.createElement('span'); | |
| placeholder.className = 'placeholder'; | |
| placeholder.innerHTML = "Click to Upload"; | |
| const img = document.createElement('img'); | |
| img.dataset.x = 0; img.dataset.y = 0; img.dataset.scale = 1; | |
| const fileInput = document.createElement('input'); | |
| fileInput.type = 'file'; | |
| fileInput.accept = 'image/*'; | |
| fileInput.style.display = 'none'; | |
| panel.appendChild(placeholder); | |
| panel.appendChild(img); | |
| panel.appendChild(fileInput); | |
| setupPanelEvents(panel, fileInput, img, placeholder); | |
| comicPage.appendChild(panel); | |
| } | |
| // 3. Create Resizers | |
| setTimeout(createResizers, 50); | |
| } | |
| function applyGridState() { | |
| // Important: Use minmax(0, Xfr) to prevent content blowouts | |
| comicPage.style.gridTemplateRows = gridState.rows.map(r => `minmax(0, ${r}fr)`).join(" "); | |
| comicPage.style.gridTemplateColumns = gridState.cols.map(c => `minmax(0, ${c}fr)`).join(" "); | |
| } | |
| // --- IMAGE MANIPULATION --- | |
| function setupPanelEvents(panel, input, img, text) { | |
| panel.onmousedown = (e) => { | |
| // Only trigger file upload if clicking empty space or placeholder | |
| if(e.target === panel || e.target === text) input.click(); | |
| }; | |
| input.onchange = (e) => { | |
| if (e.target.files && e.target.files[0]) loadImage(e.target.files[0], img, text, panel); | |
| }; | |
| // Drag/Drop files | |
| panel.ondragover = (e) => { e.preventDefault(); panel.style.boxShadow = "inset 0 0 0 4px #4a90e2"; }; | |
| panel.ondragleave = (e) => { e.preventDefault(); panel.style.boxShadow = ""; }; | |
| panel.ondrop = (e) => { | |
| e.preventDefault(); panel.style.boxShadow = ""; | |
| if (e.dataTransfer.files && e.dataTransfer.files[0]) loadImage(e.dataTransfer.files[0], img, text, panel); | |
| }; | |
| // Image Pan Logic | |
| let isDragging = false, startX, startY; | |
| img.onmousedown = (e) => { | |
| e.preventDefault(); e.stopPropagation(); // Stop bubbling to panel | |
| isDragging = true; | |
| startX = e.clientX; startY = e.clientY; | |
| img.style.cursor = 'grabbing'; | |
| }; | |
| window.addEventListener('mousemove', (e) => { | |
| if (!isDragging) return; | |
| const dx = e.clientX - startX; | |
| const dy = e.clientY - startY; | |
| let curX = parseFloat(img.dataset.x) + dx; | |
| let curY = parseFloat(img.dataset.y) + dy; | |
| img.style.transform = `translate(${curX}px, ${curY}px) scale(${img.dataset.scale})`; | |
| startX = e.clientX; startY = e.clientY; | |
| img.dataset.x = curX; img.dataset.y = curY; | |
| }); | |
| window.addEventListener('mouseup', () => { | |
| if(isDragging) { isDragging = false; img.style.cursor = 'grab'; } | |
| }); | |
| // Image Zoom Logic | |
| panel.addEventListener('wheel', (e) => { | |
| if(img.style.display === 'none') return; | |
| e.preventDefault(); | |
| let scale = parseFloat(img.dataset.scale); | |
| scale += (e.deltaY < 0 ? 0.1 : -0.1); | |
| if(scale < 0.1) scale = 0.1; | |
| if(scale > 5) scale = 5; | |
| img.dataset.scale = scale; | |
| img.style.transform = `translate(${img.dataset.x}px, ${img.dataset.y}px) scale(${scale})`; | |
| }, { passive: false }); | |
| } | |
| function loadImage(file, img, text, panel) { | |
| if(!file.type.match('image.*')) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| img.src = e.target.result; | |
| img.onload = () => { | |
| img.style.display = "block"; | |
| text.style.display = "none"; | |
| img.dataset.x = 0; img.dataset.y = 0; img.dataset.scale = 1; | |
| // Fit logic | |
| const panelRatio = panel.clientWidth / panel.clientHeight; | |
| const imgRatio = img.naturalWidth / img.naturalHeight; | |
| if (imgRatio > panelRatio) { | |
| img.style.height = "100%"; img.style.width = "auto"; | |
| } else { | |
| img.style.width = "100%"; img.style.height = "auto"; | |
| } | |
| img.style.transform = `translate(0,0) scale(1)`; | |
| }; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| // --- RESIZING LOGIC (THE FIX) --- | |
| function createResizers() { | |
| resizerLayer.innerHTML = ''; | |
| const rect = comicPage.getBoundingClientRect(); | |
| const styles = window.getComputedStyle(comicPage); | |
| // Get actual pixel positions of tracks from the grid | |
| // This is more accurate than calculating from state manually | |
| const rowTracks = styles.gridTemplateRows.split(' ').map(parseFloat); | |
| const colTracks = styles.gridTemplateColumns.split(' ').map(parseFloat); | |
| // Create Horizontal Resizers | |
| let currentTop = 0; | |
| // Iterate -1 because we only want lines BETWEEN tracks | |
| for(let i=0; i < gridState.rows.length - 1; i++) { | |
| currentTop += rowTracks[i]; | |
| const div = document.createElement('div'); | |
| div.className = 'resizer resizer-h'; | |
| // Center the handle on the gap | |
| div.style.top = (currentTop + (gridState.gap/2)) + 'px'; | |
| div.style.width = '100%'; | |
| setupResizerDrag(div, 'row', i); | |
| resizerLayer.appendChild(div); | |
| currentTop += gridState.gap; | |
| } | |
| // Create Vertical Resizers | |
| let currentLeft = 0; | |
| for(let i=0; i < gridState.cols.length - 1; i++) { | |
| currentLeft += colTracks[i]; | |
| const div = document.createElement('div'); | |
| div.className = 'resizer resizer-v'; | |
| div.style.left = (currentLeft + (gridState.gap/2)) + 'px'; | |
| div.style.height = '100%'; | |
| setupResizerDrag(div, 'col', i); | |
| resizerLayer.appendChild(div); | |
| currentLeft += gridState.gap; | |
| } | |
| } | |
| function setupResizerDrag(resizer, type, index) { | |
| resizer.onmousedown = (e) => { | |
| e.preventDefault(); | |
| resizer.classList.add('dragging'); | |
| // 1. Snapshot Start Values | |
| const startX = e.clientX; | |
| const startY = e.clientY; | |
| // Deep copy current Grid Fractions | |
| const initialFr = type === 'row' ? [...gridState.rows] : [...gridState.cols]; | |
| // Calculate size of 1 'fr' in pixels based on current container size | |
| // Formula: (ContainerSize - TotalGaps) / TotalFr | |
| const rect = comicPage.getBoundingClientRect(); | |
| const containerSize = type === 'row' ? rect.height : rect.width; | |
| const trackCount = initialFr.length; | |
| const totalGapSize = (trackCount - 1) * gridState.gap; | |
| const availableSpace = containerSize - totalGapSize; | |
| const totalFr = initialFr.reduce((a,b) => a+b, 0); | |
| const pixelsPerFr = availableSpace / totalFr; | |
| const onMove = (moveEvent) => { | |
| // 2. Calculate Delta in Pixels | |
| const deltaPixels = type === 'row' | |
| ? moveEvent.clientY - startY | |
| : moveEvent.clientX - startX; | |
| // 3. Convert Pixel Delta to Fr Delta | |
| const deltaFr = deltaPixels / pixelsPerFr; | |
| // 4. Apply to previous/next tracks | |
| let newPrev = initialFr[index] + deltaFr; | |
| let newNext = initialFr[index+1] - deltaFr; | |
| // Limit min size to 0.1fr to prevent collapse | |
| if (newPrev < 0.1 || newNext < 0.1) return; | |
| // 5. Update State | |
| if(type === 'row') { | |
| gridState.rows[index] = newPrev; | |
| gridState.rows[index+1] = newNext; | |
| } else { | |
| gridState.cols[index] = newPrev; | |
| gridState.cols[index+1] = newNext; | |
| } | |
| applyGridState(); | |
| // 6. Update Handle Positions Visually | |
| updateResizersDOM(); | |
| }; | |
| const onUp = () => { | |
| resizer.classList.remove('dragging'); | |
| window.removeEventListener('mousemove', onMove); | |
| window.removeEventListener('mouseup', onUp); | |
| // Force a clean redraw just in case | |
| createResizers(); | |
| }; | |
| window.addEventListener('mousemove', onMove); | |
| window.addEventListener('mouseup', onUp); | |
| }; | |
| } | |
| // Lightweight updater for handles during drag | |
| // This is faster than createResizers() because it doesn't destroy DOM | |
| function updateResizersDOM() { | |
| const styles = window.getComputedStyle(comicPage); | |
| const rowTracks = styles.gridTemplateRows.split(' ').map(parseFloat); | |
| const colTracks = styles.gridTemplateColumns.split(' ').map(parseFloat); | |
| // Get all handles | |
| const hResizers = document.querySelectorAll('.resizer-h'); | |
| const vResizers = document.querySelectorAll('.resizer-v'); | |
| let currentTop = 0; | |
| hResizers.forEach((div, i) => { | |
| currentTop += rowTracks[i]; | |
| div.style.top = (currentTop + (gridState.gap/2)) + 'px'; | |
| currentTop += gridState.gap; | |
| }); | |
| let currentLeft = 0; | |
| vResizers.forEach((div, i) => { | |
| currentLeft += colTracks[i]; | |
| div.style.left = (currentLeft + (gridState.gap/2)) + 'px'; | |
| currentLeft += gridState.gap; | |
| }); | |
| } | |
| // --- DOWNLOAD --- | |
| function downloadComic() { | |
| const loading = document.getElementById('loading'); | |
| loading.style.display = 'flex'; | |
| resizerLayer.style.display = 'none'; // Hide UI elements | |
| window.scrollTo(0,0); | |
| html2canvas(document.getElementById('comic-page'), { | |
| scale: 2, | |
| backgroundColor: "#ffffff" | |
| }).then(canvas => { | |
| const link = document.createElement('a'); | |
| link.download = 'comic.png'; | |
| link.href = canvas.toDataURL("image/png"); | |
| link.click(); | |
| loading.style.display = 'none'; | |
| resizerLayer.style.display = 'block'; | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment