Skip to content

Instantly share code, notes, and snippets.

@akhenakh
Last active January 23, 2026 02:19
Show Gist options
  • Select an option

  • Save akhenakh/53e896fb3e325fa2049b218b9f0f5c7d to your computer and use it in GitHub Desktop.

Select an option

Save akhenakh/53e896fb3e325fa2049b218b9f0f5c7d to your computer and use it in GitHub Desktop.
<!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