restructure

Hunter
Tuesday, July 28th, 2026 at 7:56 PM

index.html+12-3
pdf.jsdeleted
readme.md+1-15
readme_images/cut_and_paste.jpgdeleted
readme_images/premium_plan.jpgdeleted
resources/app.jsnew
resources/display.jsnew
resources/grid.jsnew
resources/images.jsnew
resources/input.jsnew
resources/pdf.jsnew
resources/pointer.jsnew
resources/popup.jsnew
resources/print.jsnew
resources/style.cssnew
resources/theme.jsnew
script.jsdeleted
style.cssdeleted

index.html
 6  	<meta name="theme-color" content="#a3d5ff">
 7  	<title>memori</title>
 8  	<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>✂️</text></svg>">
   -	<link rel="stylesheet" href="style.css">
 9 +	<link rel="stylesheet" href="resources/style.css">
10  </head>
11  <body>
12  	<div class="page">

16  	<input type="file" id="fileInput" accept="image/*" multiple style="display: none;">
17  	<button id="addImagesBtn" class="add-images-btn">Add images</button>
18  
   -	<script src="script.js"></script>
   -	<script src="pdf.js"></script>
19 +	<!-- Loaded in dependency order: each file runs setup at load and reads the ones above it -->
20 +	<script src="resources/popup.js"></script>
21 +	<script src="resources/grid.js"></script>
22 +	<script src="resources/display.js"></script>
23 +	<script src="resources/images.js"></script>
24 +	<script src="resources/pointer.js"></script>
25 +	<script src="resources/input.js"></script>
26 +	<script src="resources/print.js"></script>
27 +	<script src="resources/theme.js"></script>
28 +	<script src="resources/app.js"></script>
29 +	<script src="resources/pdf.js"></script>
30  </body>
31  </html>
pdf.js
  -(function () {
  -	var MM = 72 / 25.4;         // mm -> PDF points
  -	var DPI = 600;              // photo raster resolution
  -	var JPEG_QUALITY = 0.95;	// near lossless
  -	var PX = DPI / 25.4;        // mm -> canvas px
  -	var GRID_GRAY = 0xd0 / 0xff;// print grid line color (style.css --default-grid #d0d0d0)
  -	var LINE_PT = 0.75;         // grid line width in points (1 CSS px)
  -	var DASH_MM = 0.75;         // dash length
  -	var GAP_MM = 0.4;           // nominal gap; sets dashes-per-cell, then absorbs rounding slack
  -
  -	function num(n) {
  -		var s = n.toFixed(6).replace(/\.?0+$/, "");
  -		return s === "-0" ? "0" : s;
  -	}
  -
  -	// Uint8Array -> binary string (one char per byte) so string length == byte length
  -	function bytesToBinary(bytes) {
  -		var CHUNK = 0x8000;
  -		var parts = [];
  -		for (var i = 0; i < bytes.length; i += CHUNK) {
  -			parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)));
  -		}
  -		return parts.join("");
  -	}
  -
  -	function createPdf(widthPt, heightPt) {
  -		var ops = [];
  -		var xobjects = []; // embedded JPEG images, in draw order
  -		var lastGray = null;
  -
  -		// filled rect anchored at its bottom-left; gray 0 = black, 1 = white
  -		function rect(x, y, w, h, gray) {
  -			var g = gray || 0;
  -			if (g !== lastGray) {
  -				ops.push(num(g) + " g");
  -				lastGray = g;
  -			}
  -			ops.push(num(x) + " " + num(y) + " " + num(w) + " " + num(h) + " re f");
  -		}
  -
  -		// opts.phase shifts the dash pattern along the path, to center a dash on a given point
  -		function dashedLine(x1, y1, x2, y2, opts) {
  -			var g = opts.gray == null ? 0 : opts.gray;
  -			var phase = opts.phase || 0;
  -			ops.push("q [" + num(opts.dash[0]) + " " + num(opts.dash[1]) + "] " + num(phase) + " d " +
  -				num(opts.width) + " w " + num(g) + " G " +
  -				num(x1) + " " + num(y1) + " m " + num(x2) + " " + num(y2) + " l S Q");
  -		}
  -
  -		// place a JPEG with its bottom-left at (x, y), drawn w x h points
  -		function image(jpegBytes, imgW, imgH, x, y, w, h) {
  -			var idx = xobjects.length;
  -			xobjects.push({ w: imgW, h: imgH, bin: bytesToBinary(jpegBytes) });
  -			ops.push("q " + num(w) + " 0 0 " + num(h) + " " + num(x) + " " + num(y) +
  -				" cm /Im" + idx + " Do Q");
  -		}
  -
  -		// assemble as a binary string (char codes <= 0xFF) so string offsets are byte offsets
  -		function end() {
  -			var stream = ops.join("\n");
  -			// image XObjects are numbered after the four fixed objects
  -			var xobjEntries = xobjects.map(function (_, i) {
  -				return "/Im" + i + " " + (5 + i) + " 0 R";
  -			}).join(" ");
  -			var resources = xobjects.length ? "/XObject << " + xobjEntries + " >>" : "";
  -			var objects = [
  -				"<< /Type /Catalog /Pages 2 0 R >>",
  -				"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
  -				"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + num(widthPt) + " " + num(heightPt) + "]" +
  -					" /Resources << " + resources + " >> /Contents 4 0 R >>",
  -				"<< /Length " + stream.length + " >>\nstream\n" + stream + "\nendstream"
  -			];
  -			xobjects.forEach(function (im) {
  -				objects.push("<< /Type /XObject /Subtype /Image /Width " + im.w + " /Height " + im.h +
  -					" /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " +
  -					im.bin.length + " >>\nstream\n" + im.bin + "\nendstream");
  -			});
  -			var out = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
  -			var offsets = [];
  -			objects.forEach(function (body, i) {
  -				offsets.push(out.length);
  -				out += (i + 1) + " 0 obj\n" + body + "\nendobj\n";
  -			});
  -			var xref = out.length;
  -			out += "xref\n0 " + (objects.length + 1) + "\n0000000000 65535 f \n";
  -			offsets.forEach(function (off) {
  -				out += ("000000000" + off).slice(-10) + " 00000 n \n";
  -			});
  -			out += "trailer\n<< /Size " + (objects.length + 1) + " /Root 1 0 R >>\n" +
  -				"startxref\n" + xref + "\n%%EOF\n";
  -			var bytes = new Uint8Array(out.length);
  -			for (var i = 0; i < out.length; i++) { bytes[i] = out.charCodeAt(i); }
  -			return bytes;
  -		}
  -
  -		return { rect: rect, dashedLine: dashedLine, image: image, end: end };
  -	}
  -
  -	// "memori · YY·MM·DD·HH·MM·SS.pdf"
  -	function pdfFilename() {
  -		var d = new Date();
  -		var p = function (n) { return String(n).padStart(2, "0"); };
  -		var ts = [d.getFullYear() % 100, d.getMonth() + 1, d.getDate(),
  -			d.getHours(), d.getMinutes(), d.getSeconds()].map(p).join("·");
  -		return "memori · " + ts + ".pdf";
  -	}
  -
  -	// back-to-front by stacking order, so overlaps paint like on screen
  -	function imagesInZOrder() {
  -		return images.slice().sort(function (a, b) {
  -			return (parseInt(a.container.style.zIndex, 10) || 0) -
  -				(parseInt(b.container.style.zIndex, 10) || 0);
  -		});
  -	}
  -
  -	// Rasterize one image at the size of the area it covers on the grid, reproducing
  -	// updateImagePosition's transform.
  -	function rasterizeImage(img) {
  -		var el = img.container.querySelector("img");
  -		if (!el || !img.naturalWidth || !img.naturalHeight) { return null; }
  -
  -		var cellPx = CELL_SIZE_MM * PX;
  -		var cw = Math.round(img.widthCells * cellPx), ch = Math.round(img.heightCells * cellPx);
  -		var canvas = document.createElement("canvas");
  -		canvas.width = cw;
  -		canvas.height = ch;
  -		var ctx = canvas.getContext("2d");
  -
  -		// cover-fit scale, recomputed here as the print CSS path does
  -		var rotated = img.rotation % 180 !== 0;
  -		var effW = rotated ? img.naturalHeight : img.naturalWidth;
  -		var effH = rotated ? img.naturalWidth : img.naturalHeight;
  -		var scale = Math.max(cw / effW, ch / effH) * img.userScale;
  -
  -		ctx.translate(cw / 2, ch / 2);
  -		ctx.scale(scale, scale);
  -		ctx.rotate(img.rotation * Math.PI / 180);
  -		ctx.translate(img.panX, img.panY);
  -		ctx.drawImage(el, -img.naturalWidth / 2, -img.naturalHeight / 2, img.naturalWidth, img.naturalHeight);
  -		return canvas;
  -	}
  -
  -	function drawGrid(doc, cellPt, mediaW, mediaH) {
  -		var perCell = Math.max(1, Math.round(CELL_SIZE_MM / (DASH_MM + GAP_MM)));
  -		var period = cellPt / perCell;
  -		var dash = Math.min(DASH_MM * MM, period);
  -		var half = LINE_PT / 2;
  -		var opts = { dash: [dash, period - dash], width: LINE_PT, gray: GRID_GRAY, phase: dash / 2 - half };
  -
  -		for (var c = 0; c <= GRID_COLS; c++) {
  -			var x = c * cellPt + half;
  -			doc.dashedLine(x, 0, x, mediaH, opts);
  -		}
  -		for (var r = 0; r <= GRID_ROWS; r++) {
  -			var y = mediaH - (r * cellPt + half); // PDF origin is bottom-left
  -			doc.dashedLine(0, y, mediaW, y, opts);
  -		}
  -	}
  -
  -	function jpegBytes(canvas) {
  -		var dataUrl = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
  -		var bin = atob(dataUrl.slice(dataUrl.indexOf(",") + 1));
  -		var bytes = new Uint8Array(bin.length);
  -		for (var i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); }
  -		return bytes;
  -	}
  -
  -	function buildPdf() {
  -		var cellPt = CELL_SIZE_MM * MM;
  -		var mediaW = GRID_COLS * cellPt + LINE_PT;
  -		var mediaH = GRID_ROWS * cellPt + LINE_PT;
  -		var doc = createPdf(mediaW, mediaH);
  -
  -		doc.rect(0, 0, mediaW, mediaH, 1); // white sheet
  -		drawGrid(doc, cellPt, mediaW, mediaH);
  -
  -		// photos on top; PDF origin is bottom-left, so flip the cell's y
  -		imagesInZOrder().forEach(function (img) {
  -			var canvas = rasterizeImage(img);
  -			if (!canvas) { return; }
  -			var x = img.xCell * cellPt;
  -			var y = mediaH - (img.yCell + img.heightCells) * cellPt;
  -			doc.image(jpegBytes(canvas), canvas.width, canvas.height,
  -				x, y, img.widthCells * cellPt, img.heightCells * cellPt);
  -		});
  -
  -		return doc.end();
  -	}
  -
  -	function exportPdf() {
  -		var url = URL.createObjectURL(new Blob([buildPdf()], { type: "application/pdf" }));
  -		var a = document.createElement("a");
  -		a.href = url;
  -		a.download = pdfFilename();
  -		document.body.appendChild(a);
  -		a.click();
  -		a.remove();
  -		setTimeout(function () { URL.revokeObjectURL(url); }, 0);
  -	}
  -
  -	var printUrl = null;
  -	function openPdfForPrinting() {
  -		if (printUrl) { URL.revokeObjectURL(printUrl); }
  -		printUrl = URL.createObjectURL(new Blob([buildPdf()], { type: "application/pdf" }));
  -		window.open(printUrl, "_blank");
  -	}
  -
  -	window.addEventListener("keydown", function (e) {
  -		if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) { return; }
  -		var key = e.key.toLowerCase();
  -		if (key === "e") {
  -			e.preventDefault();
  -			exportPdf();
  -		} else if (key === "p" && isUsingSafari) {
  -			// keydown preempts Safari's print dialog (beforeprint can't cancel it)
  -			e.preventDefault();
  -			openPdfForPrinting();
  -		}
  -	});
  -
  -	window.addEventListener("pagehide", function () {
  -		if (printUrl) { URL.revokeObjectURL(printUrl); printUrl = null; }
  -	});
  -})();
readme.md
1  # memori ✂️
  -The Hobonichi Techo is a <a href="https://www.1101.com/store/techo/en/about/">Life Book</a> with one page for each day of the year. I use mine as a daily planner, journal, and sketchbook.
2  
  -This year, Hobonichi announced an official <a href="https://techoapp.1101.com/en/">Hobonichi Techo App</a>, which, among other features, lets users print photos that are perfectly sized to fit the Techo's grid paper.
  -
  -<img src="readme_images/cut_and_paste.jpg" width=400px><br>
  -
  -One tiny problem with this:
  -<ul>
  -	<li>
  -		The Memory Print feature requires a <a href="https://techoapp.1101.com/en/premium/">paid subscription</a>.
  -	</li>
  -</ul>
  -
  -<a href="https://techoapp.1101.com/en/premium/"><img src="readme_images/premium_plan.jpg" width=500px></a>
  -
  -So here's a web app that does the same thing (and then some) for free.
3 +A free photo-printing tool that perfectly sizes images to fit the <a href="https://www.1101.com/store/techo/en/about/">Hobonichi Techo</a>'s grid paper.
4  
5  ## usage
6  Different Techo types use differently-sized grid paper. Use the links below to select the appropriate grid size for your Techo:
readme_images/cut_and_paste.jpg
Binary files differ.
readme_images/premium_plan.jpg
Binary files differ.
resources/app.js
 1 +// Wiring that spans the other files
 2 +
 3 +// Update all image positions when window resizes (for responsive scaling)
 4 +window.addEventListener('resize', () => {
 5 +	applyPageWidth(); // display or zoom may have changed
 6 +	images.forEach(img => {
 7 +		updateImagePosition(img);
 8 +	});
 9 +});
10 +
11 +// Warn before leaving page if images are present
12 +window.addEventListener('beforeunload', (e) => {
13 +	if (images.length > 0) {
14 +		e.preventDefault();
15 +		e.returnValue = '';
16 +		return '';
17 +	}
18 +});
19 +
20 +// Mobile file input handling
21 +const fileInput = document.getElementById('fileInput');
22 +const addImagesBtn = document.getElementById('addImagesBtn');
23 +
24 +addImagesBtn.addEventListener('click', () => {
25 +	fileInput.click();
26 +});
27 +
28 +fileInput.addEventListener('change', async (e) => {
29 +	await processAndAddImages(e.target.files, 0, 0);
30 +
31 +	// Clear the input so the same files can be selected again
32 +	fileInput.value = '';
33 +});
resources/display.js
 1 +// Estimate physical display density so we can attempt to render the page at print size
 2 +
 3 +// Detect Safari browser
 4 +function isSafari() {
 5 +	const ua = navigator.userAgent;
 6 +	return ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium');
 7 +}
 8 +
 9 +const isUsingSafari = isSafari();
10 +
11 +const isUsingWindows = navigator.userAgent.includes('Windows');
12 +
13 +const PANEL_PPI = {
14 +	1920: 141,   // 15.6" 1080p laptop
15 +	2560: 109,   // 27" 1440p
16 +	2736: 267,   // 12.3" 3:2 tablet
17 +	2880: 267,   // 13" 3:2 tablet
18 +	3840: 163    // 27" 4K
19 +};
20 +
21 +function estimateWindowsPPI() {
22 +	const pixelRatio = window.devicePixelRatio;
23 +	if (pixelRatio === 1) return null;
24 +
25 +	const nativeWidth = screen.width * pixelRatio;
26 +	if (nativeWidth >= 3800 && pixelRatio >= 2) return null;
27 +
28 +	const panel = Object.keys(PANEL_PPI).find(width => Math.abs(width - nativeWidth) <= width * 0.02);
29 +
30 +	return panel ? PANEL_PPI[panel] / pixelRatio : null;
31 +}
32 +
33 +function estimateScreenPPI() {
34 +	const logicalWidth = screen.width;
35 +	const pixelRatio = window.devicePixelRatio;
36 +
37 +	if (isUsingWindows) {
38 +		const windowsPPI = estimateWindowsPPI();
39 +		if (windowsPPI) return windowsPPI;
40 +	}
41 +
42 +	if (logicalWidth >= 3840) return 163;    // 27" 4K, OS scaling off
43 +	if (logicalWidth >= 2048) return 109;    // 27" 5K retina or 1440p
44 +	if (logicalWidth >= 1800) return 92.6;   // 23.8" 1080p and friends
45 +
46 +	return pixelRatio > 1 ? 127.7 : 92.6;    // retina laptop (2560px / 227ppi), else standard density
47 +}
48 +
49 +// Life-size page width; CSS clamps this to the viewport so it never overflows horizontally
50 +function applyPageWidth() {
51 +	const pageWidth = (LETTER_WIDTH_MM / 25.4) * estimateScreenPPI();
52 +	document.documentElement.style.setProperty('--page-width', `${pageWidth}px`);
53 +	updateGridLineWidth();
54 +}
55 +
56 +const baselinePixelRatio = window.devicePixelRatio;
57 +
58 +function updateGridLineWidth() {
59 +	const zoom = window.devicePixelRatio / baselinePixelRatio;
60 +	document.documentElement.style.setProperty('--line-width', `${Math.min(1, 1 / zoom)}px`);
61 +}
62 +
63 +applyPageWidth();
resources/grid.js
  1 +// Paper dimensions in mm
  2 +const LETTER_WIDTH_MM = 215.9;  // 8.5 inches
  3 +const LETTER_HEIGHT_MM = 279.4; // 11 inches
  4 +const A4_WIDTH_MM = 210;
  5 +const A4_HEIGHT_MM = 297;
  6 +const MARGIN_MM = 6.35; // 0.25 inches
  7 +
  8 +// Calculate maximum grid dimensions that fit both Letter and A4 with margins
  9 +function calculateGridDimensions(cellSize) {
 10 +	// Available printable area (limiting factor is the smaller of Letter/A4 for each dimension)
 11 +	const availableWidth = Math.min(LETTER_WIDTH_MM, A4_WIDTH_MM) - (2 * MARGIN_MM);
 12 +	const availableHeight = Math.min(LETTER_HEIGHT_MM, A4_HEIGHT_MM) - (2 * MARGIN_MM);
 13 +
 14 +	// Calculate how many cells fit
 15 +	const cols = Math.floor(availableWidth / cellSize);
 16 +	const rows = Math.floor(availableHeight / cellSize);
 17 +
 18 +	return { cols, rows };
 19 +}
 20 +
 21 +// Calculate grid dimensions as percentage of Letter paper (used for screen layout)
 22 +function calculateGridPercentages(cellSize, cols, rows) {
 23 +	const gridWidthMM = cols * cellSize;
 24 +	const gridHeightMM = rows * cellSize;
 25 +	return {
 26 +		widthPercent: (gridWidthMM / LETTER_WIDTH_MM) * 100,
 27 +		heightPercent: (gridHeightMM / LETTER_HEIGHT_MM) * 100
 28 +	};
 29 +}
 30 +
 31 +// Cell size bounds (in mm)
 32 +const MIN_CELL_SIZE_MM = 2;
 33 +const MAX_CELL_SIZE_MM = 10;
 34 +
 35 +let GRID_COLS = 49;
 36 +let GRID_ROWS = 66;
 37 +let CELL_SIZE_MM = 4; // Physical size of each cell when printed (can be overridden by URL parameter)
 38 +
 39 +const grid = document.getElementById('grid');
 40 +
 41 +// Parse URL parameters for custom cell size
 42 +function parseCellSizeFromURL() {
 43 +	const urlParams = new URLSearchParams(window.location.search);
 44 +	const gridSizeParam = urlParams.get('grid-size');
 45 +
 46 +	if (gridSizeParam) {
 47 +		// Remove 'mm' suffix if present
 48 +		const sizeStr = gridSizeParam.toLowerCase().replace('mm', '').trim();
 49 +		const size = parseFloat(sizeStr);
 50 +
 51 +		// Check if size is valid
 52 +		if (isNaN(size)) {
 53 +			showPopup('Invalid grid size. Using default 4mm.', 3000);
 54 +			return 4;
 55 +		}
 56 +
 57 +		// Check if size is within reasonable bounds
 58 +		if (size < MIN_CELL_SIZE_MM || size > MAX_CELL_SIZE_MM) {
 59 +			showPopup(`Grid size must be between ${MIN_CELL_SIZE_MM}mm and ${MAX_CELL_SIZE_MM}mm. Using default 4mm.`, 3500);
 60 +			return 4;
 61 +		}
 62 +
 63 +		showPopup(`Grid set to ${size}mm`);
 64 +		return size;
 65 +	}
 66 +
 67 +	return 4; // Default
 68 +}
 69 +
 70 +// Initialize cell size from URL
 71 +CELL_SIZE_MM = parseCellSizeFromURL();
 72 +
 73 +// Calculate grid dimensions based on cell size
 74 +const gridDimensions = calculateGridDimensions(CELL_SIZE_MM);
 75 +GRID_COLS = gridDimensions.cols;
 76 +GRID_ROWS = gridDimensions.rows;
 77 +
 78 +// Calculate grid percentages for screen layout
 79 +const gridPercentages = calculateGridPercentages(CELL_SIZE_MM, GRID_COLS, GRID_ROWS);
 80 +
 81 +// Update CSS variables for both screen and print
 82 +document.documentElement.style.setProperty('--cell-size-mm', `${CELL_SIZE_MM}mm`);
 83 +document.documentElement.style.setProperty('--grid-cols', GRID_COLS);
 84 +document.documentElement.style.setProperty('--grid-rows', GRID_ROWS);
 85 +document.documentElement.style.setProperty('--grid-width-percent', `${gridPercentages.widthPercent}%`);
 86 +document.documentElement.style.setProperty('--grid-height-percent', `${gridPercentages.heightPercent}%`);
 87 +
 88 +// Calculate cell size dynamically based on actual grid dimensions
 89 +function getCellSize() {
 90 +	const gridRect = grid.getBoundingClientRect();
 91 +	return {
 92 +		width: gridRect.width / GRID_COLS,
 93 +		height: gridRect.height / GRID_ROWS,
 94 +		totalWidth: gridRect.width,
 95 +		totalHeight: gridRect.height
 96 +	};
 97 +}
 98 +
 99 +// Calculate pixel-perfect position for a cell range
100 +function getPixelPerfectBounds(cellX, cellY, cellWidth, cellHeight) {
101 +	// Get all grid cells and measure their actual positions
102 +	const gridCells = grid.querySelectorAll('.grid-cell');
103 +
104 +	// Calculate the index of the top-left cell
105 +	const startCellIndex = cellY * GRID_COLS + cellX;
106 +	const startCell = gridCells[startCellIndex];
107 +
108 +	if (!startCell) {
109 +		// Fallback if cell doesn't exist
110 +		const cellSize = getCellSize();
111 +		return {
112 +			left: cellX * cellSize.width,
113 +			top: cellY * cellSize.height,
114 +			width: cellWidth * cellSize.width,
115 +			height: cellHeight * cellSize.height
116 +		};
117 +	}
118 +
119 +	// Get the actual position of the start cell relative to the grid
120 +	const gridRect = grid.getBoundingClientRect();
121 +	const startCellRect = startCell.getBoundingClientRect();
122 +
123 +	const left = startCellRect.left - gridRect.left;
124 +	const top = startCellRect.top - gridRect.top;
125 +
126 +	// Calculate end position by finding the bottom-right cell
127 +	const endCellIndex = (cellY + cellHeight - 1) * GRID_COLS + (cellX + cellWidth - 1);
128 +	const endCell = gridCells[endCellIndex];
129 +
130 +	if (!endCell) {
131 +		// Fallback if end cell doesn't exist
132 +		const cellSize = getCellSize();
133 +		return {
134 +			left: left,
135 +			top: top,
136 +			width: cellWidth * cellSize.width,
137 +			height: cellHeight * cellSize.height
138 +		};
139 +	}
140 +
141 +	const endCellRect = endCell.getBoundingClientRect();
142 +	const right = endCellRect.right - gridRect.left;
143 +	const bottom = endCellRect.bottom - gridRect.top;
144 +
145 +	return {
146 +		left: left,
147 +		top: top,
148 +		width: right - left,
149 +		height: bottom - top
150 +	};
151 +}
152 +
153 +// Create grid cells
154 +for (let i = 0; i < GRID_COLS * GRID_ROWS; i++) {
155 +	const cell = document.createElement('div');
156 +	cell.className = 'grid-cell';
157 +
158 +	// Add right border to rightmost column
159 +	const col = i % GRID_COLS;
160 +	if (col === GRID_COLS - 1) {
161 +		cell.classList.add('right-edge');
162 +	}
163 +
164 +	// Add bottom border to bottom row
165 +	const row = Math.floor(i / GRID_COLS);
166 +	if (row === GRID_ROWS - 1) {
167 +		cell.classList.add('bottom-edge');
168 +	}
169 +
170 +	grid.appendChild(cell);
171 +}
172 +
173 +// Apply solid border style for grids 3.56mm or smaller
174 +if (CELL_SIZE_MM <= 3.56) {
175 +	document.documentElement.classList.add('solid-grid');
176 +}
resources/images.js
  1 +// Image state, the add pipeline, and the transforms that place images on the grid
  2 +
  3 +let images = [];
  4 +let dragState = null;
  5 +let resizeState = null;
  6 +let highestZIndex = 0;
  7 +let touchState = null; // For tracking multi-touch gestures
  8 +let longPressTimer = null; // For detecting long press to enable pan mode
  9 +
 10 +// Helper function to calculate image dimensions from aspect ratio
 11 +function calculateImageDimensions(aspectRatio) {
 12 +	let widthCells, heightCells;
 13 +	if (aspectRatio >= 1) {
 14 +		heightCells = 5;
 15 +		widthCells = Math.round(heightCells * aspectRatio);
 16 +	} else {
 17 +		widthCells = 5;
 18 +		heightCells = Math.round(widthCells / aspectRatio);
 19 +	}
 20 +	return {
 21 +		widthCells: Math.min(widthCells, GRID_COLS),
 22 +		heightCells: Math.min(heightCells, GRID_ROWS)
 23 +	};
 24 +}
 25 +
 26 +// Helper function to load an image and get its dimensions
 27 +async function loadImageDimensions(file) {
 28 +	const reader = new FileReader();
 29 +	const dataUrl = await new Promise(resolve => {
 30 +		reader.onload = (e) => resolve(e.target.result);
 31 +		reader.readAsDataURL(file);
 32 +	});
 33 +
 34 +	const img = new Image();
 35 +	const dimensions = await new Promise(resolve => {
 36 +		img.onload = () => {
 37 +			const aspectRatio = img.width / img.height;
 38 +			resolve(calculateImageDimensions(aspectRatio));
 39 +		};
 40 +		img.src = dataUrl;
 41 +	});
 42 +
 43 +	return { dataUrl, ...dimensions };
 44 +}
 45 +
 46 +// Shared function to process and add images to the grid
 47 +async function processAndAddImages(files, dropX = 0, dropY = 0) {
 48 +	const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
 49 +	if (imageFiles.length === 0) return;
 50 +
 51 +	const cellSize = getCellSize();
 52 +
 53 +	// Load first image to get base dimensions for positioning
 54 +	const firstImageData = await loadImageDimensions(imageFiles[0]);
 55 +
 56 +	// Calculate base drop position
 57 +	// For single images, center on cursor; for multiple images, place top-left at cursor
 58 +	let baseXCell, baseYCell;
 59 +	if (imageFiles.length === 1) {
 60 +		baseXCell = Math.round(dropX / cellSize.width - firstImageData.widthCells / 2);
 61 +		baseYCell = Math.round(dropY / cellSize.height - firstImageData.heightCells / 2);
 62 +	} else {
 63 +		baseXCell = Math.round(dropX / cellSize.width);
 64 +		baseYCell = Math.round(dropY / cellSize.height);
 65 +	}
 66 +
 67 +	// Pre-allocate z-indexes to maintain drop order
 68 +	const baseZIndex = highestZIndex + 1;
 69 +	highestZIndex += imageFiles.length;
 70 +
 71 +	// Load all images
 72 +	const imageDataArray = [];
 73 +	for (let idx = 0; idx < imageFiles.length; idx++) {
 74 +		const data = idx === 0 ? firstImageData : await loadImageDimensions(imageFiles[idx]);
 75 +		imageDataArray.push({ idx, ...data });
 76 +	}
 77 +
 78 +	let wrappedOffset = 0;
 79 +
 80 +	for (const { idx, dataUrl, widthCells, heightCells } of imageDataArray) {
 81 +		// Calculate position with diagonal offset
 82 +		let xCell = baseXCell + idx;
 83 +		let yCell = baseYCell + idx;
 84 +
 85 +		// If out of bounds or would overlap with wrapped images, wrap to next diagonal position
 86 +		const outOfBounds = xCell < 0 || yCell < 0 ||
 87 +		                    xCell + widthCells > GRID_COLS ||
 88 +		                    yCell + heightCells > GRID_ROWS;
 89 +		const overlapsWrapped = xCell < wrappedOffset || yCell < wrappedOffset;
 90 +
 91 +		if (outOfBounds || overlapsWrapped) {
 92 +			xCell = wrappedOffset;
 93 +			yCell = wrappedOffset;
 94 +			wrappedOffset++;
 95 +		}
 96 +
 97 +		const imageData = addImage(dataUrl, xCell, yCell, widthCells, heightCells);
 98 +		imageData.container.style.zIndex = baseZIndex + idx;
 99 +	}
100 +}
101 +
102 +function addImage(src, xCell, yCell, widthCells, heightCells) {
103 +	const container = document.createElement('div');
104 +	container.className = 'image-container';
105 +	highestZIndex++;
106 +	container.style.zIndex = highestZIndex;
107 +
108 +	const wrapper = document.createElement('div');
109 +	wrapper.className = 'image-wrapper';
110 +	const img = document.createElement('img');
111 +	img.src = src;
112 +	wrapper.appendChild(img);
113 +	container.appendChild(wrapper);
114 +
115 +	// Add resize handles
116 +	const handles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
117 +	handles.forEach(dir => {
118 +		const handle = document.createElement('div');
119 +		handle.className = `resize-handle ${dir.length === 1 ? 'edge' : 'corner'} ${dir}`;
120 +		handle.dataset.direction = dir;
121 +		container.appendChild(handle);
122 +	});
123 +
124 +	// Add dimension labels
125 +	const widthLabel = document.createElement('div');
126 +	widthLabel.className = 'dimension-label width';
127 +	widthLabel.textContent = widthCells;
128 +	container.appendChild(widthLabel);
129 +
130 +	const heightLabel = document.createElement('div');
131 +	heightLabel.className = 'dimension-label height';
132 +	heightLabel.textContent = heightCells;
133 +	container.appendChild(heightLabel);
134 +
135 +	const imageData = {
136 +		container,
137 +		xCell,
138 +		yCell,
139 +		widthCells,
140 +		heightCells,
141 +		// Image positioning within container (in pixels, relative to center)
142 +		panX: 0,
143 +		panY: 0,
144 +		userScale: 1,  // user zoom level (1-5)
145 +		rotation: 0,  // rotation in degrees (0, 90, 180, 270)
146 +		// Store natural image dimensions for calculations
147 +		naturalWidth: 0,
148 +		naturalHeight: 0,
149 +		baseScale: 1  // scale needed to cover container
150 +	};
151 +
152 +	// Calculate dimensions and scale once image loads
153 +	img.onload = () => {
154 +		imageData.naturalWidth = img.naturalWidth;
155 +		imageData.naturalHeight = img.naturalHeight;
156 +
157 +		// Calculate base scale to cover container (mimics object-fit: cover)
158 +		const bounds = getPixelPerfectBounds(imageData.xCell, imageData.yCell, imageData.widthCells, imageData.heightCells);
159 +		const scaleX = bounds.width / img.naturalWidth;
160 +		const scaleY = bounds.height / img.naturalHeight;
161 +		imageData.baseScale = Math.max(scaleX, scaleY);
162 +
163 +		updateImagePosition(imageData);
164 +	};
165 +	images.push(imageData);
166 +
167 +	updateImagePosition(imageData);
168 +	grid.appendChild(container);
169 +
170 +	setupImageHandlers(imageData);
171 +
172 +	return imageData;
173 +}
174 +
175 +function calculatePanBounds(imageData) {
176 +	// Container dimensions in the current (possibly swapped) grid orientation
177 +	const bounds = getPixelPerfectBounds(imageData.xCell, imageData.yCell, imageData.widthCells, imageData.heightCells);
178 +	const containerWidth = bounds.width;
179 +	const containerHeight = bounds.height;
180 +
181 +	if (imageData.naturalWidth === 0 || imageData.naturalHeight === 0) {
182 +		return { maxPanX: 0, maxPanY: 0 };
183 +	}
184 +
185 +	// Pan coordinates are in the image's original coordinate system (before scale and rotation)
186 +	// So we need to calculate bounds based on the original image dimensions
187 +	const isRotated90or270 = imageData.rotation % 180 !== 0;
188 +
189 +	// For pan bounds, we need to match image dimensions to container dimensions
190 +	// in the image's coordinate space (not the screen's coordinate space)
191 +	// When rotated 90/270, panX constrains vertical screen movement (maps to container height)
192 +	// and panY constrains horizontal screen movement (maps to container width)
193 +	const effectiveContainerWidth = isRotated90or270 ? containerHeight : containerWidth;
194 +	const effectiveContainerHeight = isRotated90or270 ? containerWidth : containerHeight;
195 +
196 +	// Pan values are in pre-scale image space, but the CSS transform scales them
197 +	// So we need to calculate bounds in pre-scale space
198 +	// The image's natural size minus the container size (in pre-scale space) gives us the overhang
199 +	const totalScale = imageData.baseScale * imageData.userScale;
200 +	const containerWidthInImageSpace = effectiveContainerWidth / totalScale;
201 +	const containerHeightInImageSpace = effectiveContainerHeight / totalScale;
202 +
203 +	// Calculate maximum pan in each direction (in pre-scale image space)
204 +	const maxPanX = Math.max(0, (imageData.naturalWidth - containerWidthInImageSpace) / 2);
205 +	const maxPanY = Math.max(0, (imageData.naturalHeight - containerHeightInImageSpace) / 2);
206 +
207 +	return { maxPanX, maxPanY };
208 +}
209 +
210 +function clampPan(imageData) {
211 +	const { maxPanX, maxPanY } = calculatePanBounds(imageData);
212 +	imageData.panX = Math.max(-maxPanX, Math.min(maxPanX, imageData.panX));
213 +	imageData.panY = Math.max(-maxPanY, Math.min(maxPanY, imageData.panY));
214 +}
215 +
216 +// Helper to transform screen-space coordinates to image coordinate space (accounting for rotation)
217 +function rotatePoint(x, y, angleDegrees) {
218 +	const angle = -angleDegrees * Math.PI / 180;
219 +	const cos = Math.cos(angle);
220 +	const sin = Math.sin(angle);
221 +	return {
222 +		x: x * cos - y * sin,
223 +		y: x * sin + y * cos
224 +	};
225 +}
226 +
227 +// Helper to apply pan adjustment based on screen delta
228 +function applyPanDelta(imageData, screenDeltaX, screenDeltaY) {
229 +	const rotated = rotatePoint(screenDeltaX, screenDeltaY, imageData.rotation);
230 +	const totalScale = imageData.baseScale * imageData.userScale;
231 +	imageData.panX += rotated.x / totalScale;
232 +	imageData.panY += rotated.y / totalScale;
233 +}
234 +
235 +// Helper to calculate zoom-centered pan adjustment
236 +function adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale) {
237 +	const rotated = rotatePoint(cursorX, cursorY, imageData.rotation);
238 +	const scaleDiff = 1/newTotalScale - 1/oldTotalScale;
239 +	imageData.panX += rotated.x * scaleDiff;
240 +	imageData.panY += rotated.y * scaleDiff;
241 +}
242 +
243 +function updateImagePosition(img) {
244 +	// Use pixel-perfect bounds to prevent subpixel accumulation
245 +	const bounds = getPixelPerfectBounds(img.xCell, img.yCell, img.widthCells, img.heightCells);
246 +	img.container.style.left = bounds.left + 'px';
247 +	img.container.style.top = bounds.top + 'px';
248 +	img.container.style.width = bounds.width + 'px';
249 +	img.container.style.height = bounds.height + 'px';
250 +
251 +	// Store cell positions as CSS variables for print styles
252 +	img.container.style.setProperty('--x-cell', img.xCell);
253 +	img.container.style.setProperty('--y-cell', img.yCell);
254 +	img.container.style.setProperty('--width-cells', img.widthCells);
255 +	img.container.style.setProperty('--height-cells', img.heightCells);
256 +
257 +	// Update dimension labels
258 +	const widthLabel = img.container.querySelector('.dimension-label.width');
259 +	const heightLabel = img.container.querySelector('.dimension-label.height');
260 +	if (widthLabel) {
261 +		// Only update text if dimension is >= 5
262 +		if (img.widthCells >= 5) {
263 +			widthLabel.textContent = img.widthCells;
264 +		}
265 +		widthLabel.dataset.hidden = img.widthCells < 5 ? 'true' : 'false';
266 +	}
267 +	if (heightLabel) {
268 +		// Only update text if dimension is >= 5
269 +		if (img.heightCells >= 5) {
270 +			heightLabel.textContent = img.heightCells;
271 +		}
272 +		heightLabel.dataset.hidden = img.heightCells < 5 ? 'true' : 'false';
273 +	}
274 +
275 +	// Recalculate baseScale if container size changed
276 +	if (img.naturalWidth > 0 && img.naturalHeight > 0) {
277 +		// Use pixel-perfect bounds for container dimensions
278 +		const containerWidth = bounds.width;
279 +		const containerHeight = bounds.height;
280 +
281 +		// When rotated 90° or 270°, the image dimensions are effectively swapped
282 +		const isRotated90or270 = img.rotation % 180 !== 0;
283 +		const effectiveWidth = isRotated90or270 ? img.naturalHeight : img.naturalWidth;
284 +		const effectiveHeight = isRotated90or270 ? img.naturalWidth : img.naturalHeight;
285 +
286 +		const scaleX = containerWidth / effectiveWidth;
287 +		const scaleY = containerHeight / effectiveHeight;
288 +		img.baseScale = Math.max(scaleX, scaleY);
289 +
290 +		// Reclamp pan after recalculating base scale
291 +		clampPan(img);
292 +	}
293 +
294 +	// Apply image positioning and scale using transform
295 +	const imgElement = img.container.querySelector('img');
296 +	if (imgElement) {
297 +		const totalScale = img.baseScale * img.userScale;
298 +		// Transform: translate from center (-50%, -50%), scale, rotate, then pan
299 +		// Pan is applied after rotation so it stays relative to the image's rotated state
300 +		imgElement.style.transform = `translate(-50%, -50%) scale(${totalScale}) rotate(${img.rotation}deg) translate(${img.panX}px, ${img.panY}px)`;
301 +	}
302 +}
303 +
304 +function bringToFront(container) {
305 +	highestZIndex++;
306 +	container.style.zIndex = highestZIndex;
307 +}
resources/input.js
 1 +// Getting images into the app: cursor tracking, clipboard paste, drag and drop
 2 +
 3 +// Track mouse position for paste placement
 4 +let lastMouseX = -1;
 5 +let lastMouseY = -1;
 6 +document.addEventListener('mousemove', (e) => {
 7 +	lastMouseX = e.clientX;
 8 +	lastMouseY = e.clientY;
 9 +});
10 +
11 +// Clipboard paste support
12 +document.addEventListener('paste', async (e) => {
13 +	const items = e.clipboardData?.items;
14 +	if (!items) return;
15 +
16 +	const imageFiles = [];
17 +	for (const item of items) {
18 +		if (item.type.startsWith('image/')) {
19 +			const file = item.getAsFile();
20 +			if (file) imageFiles.push(file);
21 +		}
22 +	}
23 +
24 +	if (imageFiles.length === 0) {
25 +		showPopup('No image found in clipboard');
26 +		return;
27 +	}
28 +
29 +	// Use last known mouse position relative to grid, fall back to (0, 0)
30 +	const gridRect = grid.getBoundingClientRect();
31 +	const mouseXInGrid = lastMouseX - gridRect.left;
32 +	const mouseYInGrid = lastMouseY - gridRect.top;
33 +	const isOnGrid = mouseXInGrid >= 0 && mouseYInGrid >= 0 &&
34 +		mouseXInGrid <= gridRect.width && mouseYInGrid <= gridRect.height;
35 +
36 +	const dropX = isOnGrid ? mouseXInGrid : 0;
37 +	const dropY = isOnGrid ? mouseYInGrid : 0;
38 +
39 +	await processAndAddImages(imageFiles, dropX, dropY);
40 +});
41 +
42 +// Prevent default drag behavior on entire document
43 +document.addEventListener('dragover', (e) => {
44 +	e.preventDefault();
45 +	e.stopPropagation();
46 +});
47 +
48 +document.addEventListener('drop', async (e) => {
49 +	e.preventDefault();
50 +	e.stopPropagation();
51 +
52 +	// Get drop position relative to the grid
53 +	const gridRect = grid.getBoundingClientRect();
54 +	const dropX = e.clientX - gridRect.left;
55 +	const dropY = e.clientY - gridRect.top;
56 +
57 +	await processAndAddImages(e.dataTransfer.files, dropX, dropY);
58 +});
resources/pdf.js
  1 +// Self-contained PDF writer: vector grid plus embedded photos, for export and Safari printing
  2 +
  3 +(function () {
  4 +	var MM = 72 / 25.4;         // mm -> PDF points
  5 +	var DPI = 600;              // photo raster resolution
  6 +	var JPEG_QUALITY = 0.95;	// near lossless
  7 +	var PX = DPI / 25.4;        // mm -> canvas px
  8 +	var GRID_GRAY = 0xd0 / 0xff;// print grid line color (style.css --default-grid #d0d0d0)
  9 +	var LINE_PT = 0.75;         // grid line width in points (1 CSS px)
 10 +	var DASH_MM = 0.75;         // dash length
 11 +	var GAP_MM = 0.4;           // nominal gap; sets dashes-per-cell, then absorbs rounding slack
 12 +
 13 +	function num(n) {
 14 +		var s = n.toFixed(6).replace(/\.?0+$/, "");
 15 +		return s === "-0" ? "0" : s;
 16 +	}
 17 +
 18 +	// Uint8Array -> binary string (one char per byte) so string length == byte length
 19 +	function bytesToBinary(bytes) {
 20 +		var CHUNK = 0x8000;
 21 +		var parts = [];
 22 +		for (var i = 0; i < bytes.length; i += CHUNK) {
 23 +			parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)));
 24 +		}
 25 +		return parts.join("");
 26 +	}
 27 +
 28 +	function createPdf(widthPt, heightPt) {
 29 +		var ops = [];
 30 +		var xobjects = []; // embedded JPEG images, in draw order
 31 +		var lastGray = null;
 32 +
 33 +		// filled rect anchored at its bottom-left; gray 0 = black, 1 = white
 34 +		function rect(x, y, w, h, gray) {
 35 +			var g = gray || 0;
 36 +			if (g !== lastGray) {
 37 +				ops.push(num(g) + " g");
 38 +				lastGray = g;
 39 +			}
 40 +			ops.push(num(x) + " " + num(y) + " " + num(w) + " " + num(h) + " re f");
 41 +		}
 42 +
 43 +		// opts.phase shifts the dash pattern along the path, to center a dash on a given point
 44 +		function dashedLine(x1, y1, x2, y2, opts) {
 45 +			var g = opts.gray == null ? 0 : opts.gray;
 46 +			var phase = opts.phase || 0;
 47 +			ops.push("q [" + num(opts.dash[0]) + " " + num(opts.dash[1]) + "] " + num(phase) + " d " +
 48 +				num(opts.width) + " w " + num(g) + " G " +
 49 +				num(x1) + " " + num(y1) + " m " + num(x2) + " " + num(y2) + " l S Q");
 50 +		}
 51 +
 52 +		// place a JPEG with its bottom-left at (x, y), drawn w x h points
 53 +		function image(jpegBytes, imgW, imgH, x, y, w, h) {
 54 +			var idx = xobjects.length;
 55 +			xobjects.push({ w: imgW, h: imgH, bin: bytesToBinary(jpegBytes) });
 56 +			ops.push("q " + num(w) + " 0 0 " + num(h) + " " + num(x) + " " + num(y) +
 57 +				" cm /Im" + idx + " Do Q");
 58 +		}
 59 +
 60 +		// assemble as a binary string (char codes <= 0xFF) so string offsets are byte offsets
 61 +		function end() {
 62 +			var stream = ops.join("\n");
 63 +			// image XObjects are numbered after the four fixed objects
 64 +			var xobjEntries = xobjects.map(function (_, i) {
 65 +				return "/Im" + i + " " + (5 + i) + " 0 R";
 66 +			}).join(" ");
 67 +			var resources = xobjects.length ? "/XObject << " + xobjEntries + " >>" : "";
 68 +			var objects = [
 69 +				"<< /Type /Catalog /Pages 2 0 R >>",
 70 +				"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
 71 +				"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + num(widthPt) + " " + num(heightPt) + "]" +
 72 +					" /Resources << " + resources + " >> /Contents 4 0 R >>",
 73 +				"<< /Length " + stream.length + " >>\nstream\n" + stream + "\nendstream"
 74 +			];
 75 +			xobjects.forEach(function (im) {
 76 +				objects.push("<< /Type /XObject /Subtype /Image /Width " + im.w + " /Height " + im.h +
 77 +					" /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " +
 78 +					im.bin.length + " >>\nstream\n" + im.bin + "\nendstream");
 79 +			});
 80 +			var out = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n";
 81 +			var offsets = [];
 82 +			objects.forEach(function (body, i) {
 83 +				offsets.push(out.length);
 84 +				out += (i + 1) + " 0 obj\n" + body + "\nendobj\n";
 85 +			});
 86 +			var xref = out.length;
 87 +			out += "xref\n0 " + (objects.length + 1) + "\n0000000000 65535 f \n";
 88 +			offsets.forEach(function (off) {
 89 +				out += ("000000000" + off).slice(-10) + " 00000 n \n";
 90 +			});
 91 +			out += "trailer\n<< /Size " + (objects.length + 1) + " /Root 1 0 R >>\n" +
 92 +				"startxref\n" + xref + "\n%%EOF\n";
 93 +			var bytes = new Uint8Array(out.length);
 94 +			for (var i = 0; i < out.length; i++) { bytes[i] = out.charCodeAt(i); }
 95 +			return bytes;
 96 +		}
 97 +
 98 +		return { rect: rect, dashedLine: dashedLine, image: image, end: end };
 99 +	}
100 +
101 +	// "memori · YY·MM·DD·HH·MM·SS.pdf"
102 +	function pdfFilename() {
103 +		var d = new Date();
104 +		var p = function (n) { return String(n).padStart(2, "0"); };
105 +		var ts = [d.getFullYear() % 100, d.getMonth() + 1, d.getDate(),
106 +			d.getHours(), d.getMinutes(), d.getSeconds()].map(p).join("·");
107 +		return "memori · " + ts + ".pdf";
108 +	}
109 +
110 +	// back-to-front by stacking order, so overlaps paint like on screen
111 +	function imagesInZOrder() {
112 +		return images.slice().sort(function (a, b) {
113 +			return (parseInt(a.container.style.zIndex, 10) || 0) -
114 +				(parseInt(b.container.style.zIndex, 10) || 0);
115 +		});
116 +	}
117 +
118 +	// Rasterize one image at the size of the area it covers on the grid, reproducing
119 +	// updateImagePosition's transform.
120 +	function rasterizeImage(img) {
121 +		var el = img.container.querySelector("img");
122 +		if (!el || !img.naturalWidth || !img.naturalHeight) { return null; }
123 +
124 +		var cellPx = CELL_SIZE_MM * PX;
125 +		var cw = Math.round(img.widthCells * cellPx), ch = Math.round(img.heightCells * cellPx);
126 +		var canvas = document.createElement("canvas");
127 +		canvas.width = cw;
128 +		canvas.height = ch;
129 +		var ctx = canvas.getContext("2d");
130 +
131 +		// cover-fit scale, recomputed here as the print CSS path does
132 +		var rotated = img.rotation % 180 !== 0;
133 +		var effW = rotated ? img.naturalHeight : img.naturalWidth;
134 +		var effH = rotated ? img.naturalWidth : img.naturalHeight;
135 +		var scale = Math.max(cw / effW, ch / effH) * img.userScale;
136 +
137 +		ctx.translate(cw / 2, ch / 2);
138 +		ctx.scale(scale, scale);
139 +		ctx.rotate(img.rotation * Math.PI / 180);
140 +		ctx.translate(img.panX, img.panY);
141 +		ctx.drawImage(el, -img.naturalWidth / 2, -img.naturalHeight / 2, img.naturalWidth, img.naturalHeight);
142 +		return canvas;
143 +	}
144 +
145 +	function drawGrid(doc, cellPt, mediaW, mediaH) {
146 +		var perCell = Math.max(1, Math.round(CELL_SIZE_MM / (DASH_MM + GAP_MM)));
147 +		var period = cellPt / perCell;
148 +		var dash = Math.min(DASH_MM * MM, period);
149 +		var half = LINE_PT / 2;
150 +		var opts = { dash: [dash, period - dash], width: LINE_PT, gray: GRID_GRAY, phase: dash / 2 - half };
151 +
152 +		for (var c = 0; c <= GRID_COLS; c++) {
153 +			var x = c * cellPt + half;
154 +			doc.dashedLine(x, 0, x, mediaH, opts);
155 +		}
156 +		for (var r = 0; r <= GRID_ROWS; r++) {
157 +			var y = mediaH - (r * cellPt + half); // PDF origin is bottom-left
158 +			doc.dashedLine(0, y, mediaW, y, opts);
159 +		}
160 +	}
161 +
162 +	function jpegBytes(canvas) {
163 +		var dataUrl = canvas.toDataURL("image/jpeg", JPEG_QUALITY);
164 +		var bin = atob(dataUrl.slice(dataUrl.indexOf(",") + 1));
165 +		var bytes = new Uint8Array(bin.length);
166 +		for (var i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); }
167 +		return bytes;
168 +	}
169 +
170 +	function buildPdf() {
171 +		var cellPt = CELL_SIZE_MM * MM;
172 +		var mediaW = GRID_COLS * cellPt + LINE_PT;
173 +		var mediaH = GRID_ROWS * cellPt + LINE_PT;
174 +		var doc = createPdf(mediaW, mediaH);
175 +
176 +		doc.rect(0, 0, mediaW, mediaH, 1); // white sheet
177 +		drawGrid(doc, cellPt, mediaW, mediaH);
178 +
179 +		// photos on top; PDF origin is bottom-left, so flip the cell's y
180 +		imagesInZOrder().forEach(function (img) {
181 +			var canvas = rasterizeImage(img);
182 +			if (!canvas) { return; }
183 +			var x = img.xCell * cellPt;
184 +			var y = mediaH - (img.yCell + img.heightCells) * cellPt;
185 +			doc.image(jpegBytes(canvas), canvas.width, canvas.height,
186 +				x, y, img.widthCells * cellPt, img.heightCells * cellPt);
187 +		});
188 +
189 +		return doc.end();
190 +	}
191 +
192 +	function exportPdf() {
193 +		var url = URL.createObjectURL(new Blob([buildPdf()], { type: "application/pdf" }));
194 +		var a = document.createElement("a");
195 +		a.href = url;
196 +		a.download = pdfFilename();
197 +		document.body.appendChild(a);
198 +		a.click();
199 +		a.remove();
200 +		setTimeout(function () { URL.revokeObjectURL(url); }, 0);
201 +	}
202 +
203 +	var printUrl = null;
204 +	function openPdfForPrinting() {
205 +		if (printUrl) { URL.revokeObjectURL(printUrl); }
206 +		printUrl = URL.createObjectURL(new Blob([buildPdf()], { type: "application/pdf" }));
207 +		window.open(printUrl, "_blank");
208 +	}
209 +
210 +	window.addEventListener("keydown", function (e) {
211 +		if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) { return; }
212 +		var key = e.key.toLowerCase();
213 +		if (key === "e") {
214 +			e.preventDefault();
215 +			exportPdf();
216 +		} else if (key === "p" && isUsingSafari) {
217 +			// keydown preempts Safari's print dialog (beforeprint can't cancel it)
218 +			e.preventDefault();
219 +			openPdfForPrinting();
220 +		}
221 +	});
222 +
223 +	window.addEventListener("pagehide", function () {
224 +		if (printUrl) { URL.revokeObjectURL(printUrl); printUrl = null; }
225 +	});
226 +})();
resources/pointer.js
  1 +// Per-image mouse, touch, and wheel handling: move, resize, rotate, pan, zoom
  2 +
  3 +function setupImageHandlers(imageData) {
  4 +	const container = imageData.container;
  5 +
  6 +	// Bring to front on hover
  7 +	container.addEventListener('mouseenter', () => {
  8 +		bringToFront(container);
  9 +	});
 10 +
 11 +	// Unified pointer start handler
 12 +	function handlePointerStart(clientX, clientY, isTouch = false, touchIdentifier = null) {
 13 +		// Clear any existing timers from other images
 14 +		clearDragState();
 15 +
 16 +		document.body.style.cursor = 'grabbing';
 17 +		document.body.classList.add('dragging');
 18 +
 19 +		dragState = {
 20 +			image: imageData,
 21 +			startX: clientX,
 22 +			startY: clientY,
 23 +			startXCell: imageData.xCell,
 24 +			startYCell: imageData.yCell,
 25 +			isPanMode: false, // Will be set to true after long press
 26 +			isTouch: isTouch,
 27 +			timerId: null, // Store timer ID to ensure we only activate the correct timer
 28 +			touchIdentifier: touchIdentifier, // Track which touch this drag belongs to
 29 +			hasMoved: false // Track if any movement has occurred
 30 +		};
 31 +
 32 +		container.classList.add('dragging');
 33 +
 34 +		// For touch, set up long press timer to enable pan mode
 35 +		if (isTouch) {
 36 +			const timerId = setTimeout(() => {
 37 +				// Only activate pan mode if:
 38 +				// 1. This drag state is still active
 39 +				// 2. This drag state is for this specific image
 40 +				// 3. The image hasn't moved to a new cell
 41 +				// 4. This is the timer that was created for this drag state
 42 +				if (dragState &&
 43 +				    dragState.image === imageData &&
 44 +				    dragState.startXCell === imageData.xCell &&
 45 +				    dragState.startYCell === imageData.yCell &&
 46 +				    dragState.timerId === timerId) {
 47 +					// User held for 0.5 seconds without moving to a new cell - enable pan mode
 48 +					dragState.isPanMode = true;
 49 +					dragState.initialPanX = imageData.panX;
 50 +					dragState.initialPanY = imageData.panY;
 51 +					// Add visual feedback class
 52 +					container.classList.add('pan-mode');
 53 +				}
 54 +			}, 500);
 55 +			dragState.timerId = timerId;
 56 +			longPressTimer = timerId;
 57 +		}
 58 +	}
 59 +
 60 +	// Touch event handlers for mobile
 61 +	container.addEventListener('touchstart', (e) => {
 62 +		if (e.target.classList.contains('resize-handle')) return;
 63 +
 64 +		// Bring to front on touch
 65 +		bringToFront(container);
 66 +
 67 +		if (e.touches.length === 1) {
 68 +			// Single touch - start drag (or long press for pan)
 69 +			e.preventDefault();
 70 +			const touch = e.touches[0];
 71 +			handlePointerStart(touch.clientX, touch.clientY, true, touch.identifier);
 72 +		} else if (e.touches.length === 2) {
 73 +			// Two fingers - prepare for pinch/pan
 74 +			e.preventDefault();
 75 +
 76 +			// Cancel any ongoing drag
 77 +			clearDragState();
 78 +
 79 +			const touch1 = e.touches[0];
 80 +			const touch2 = e.touches[1];
 81 +
 82 +			// Calculate initial distance for pinch detection
 83 +			const dx = touch2.clientX - touch1.clientX;
 84 +			const dy = touch2.clientY - touch1.clientY;
 85 +			const distance = Math.sqrt(dx * dx + dy * dy);
 86 +
 87 +			// Calculate center point
 88 +			const centerX = (touch1.clientX + touch2.clientX) / 2;
 89 +			const centerY = (touch1.clientY + touch2.clientY) / 2;
 90 +
 91 +			touchState = {
 92 +				image: imageData,
 93 +				initialDistance: distance,
 94 +				lastDistance: distance,
 95 +				initialScale: imageData.userScale,
 96 +				lastCenterX: centerX,
 97 +				lastCenterY: centerY,
 98 +				lastPanX: imageData.panX,
 99 +				lastPanY: imageData.panY
100 +			};
101 +		}
102 +	}, { passive: false });
103 +
104 +	container.addEventListener('touchmove', (e) => {
105 +		if (e.touches.length === 2 && touchState && touchState.image === imageData) {
106 +			// Two finger pinch/pan
107 +			e.preventDefault();
108 +
109 +			const touch1 = e.touches[0];
110 +			const touch2 = e.touches[1];
111 +
112 +			// Calculate current distance
113 +			const dx = touch2.clientX - touch1.clientX;
114 +			const dy = touch2.clientY - touch1.clientY;
115 +			const distance = Math.sqrt(dx * dx + dy * dy);
116 +
117 +			// Calculate center point
118 +			const centerX = (touch1.clientX + touch2.clientX) / 2;
119 +			const centerY = (touch1.clientY + touch2.clientY) / 2;
120 +
121 +			// Detect if this is primarily a pinch or a pan
122 +			const distanceChange = Math.abs(distance - touchState.lastDistance);
123 +			const centerMoveX = centerX - touchState.lastCenterX;
124 +			const centerMoveY = centerY - touchState.lastCenterY;
125 +			const centerMovement = Math.sqrt(centerMoveX * centerMoveX + centerMoveY * centerMoveY);
126 +
127 +			// If distance changed significantly more than center moved, treat as pinch
128 +			if (distanceChange > centerMovement * 0.5) {
129 +				// Pinch zoom
130 +				const rect = container.getBoundingClientRect();
131 +				const cursorX = centerX - rect.left - rect.width / 2;
132 +				const cursorY = centerY - rect.top - rect.height / 2;
133 +
134 +				const oldUserScale = imageData.userScale;
135 +				const oldTotalScale = imageData.baseScale * oldUserScale;
136 +				const scaleFactor = distance / touchState.initialDistance;
137 +				const newUserScale = Math.max(1, Math.min(5, touchState.initialScale * scaleFactor));
138 +				const newTotalScale = imageData.baseScale * newUserScale;
139 +
140 +				// Restore previous pan state and apply zoom adjustment
141 +				imageData.panX = touchState.lastPanX;
142 +				imageData.panY = touchState.lastPanY;
143 +				adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale);
144 +				imageData.userScale = newUserScale;
145 +
146 +				clampPan(imageData);
147 +				touchState.lastPanX = imageData.panX;
148 +				touchState.lastPanY = imageData.panY;
149 +			} else {
150 +				// Two-finger pan
151 +				imageData.panX = touchState.lastPanX;
152 +				imageData.panY = touchState.lastPanY;
153 +				applyPanDelta(imageData, centerMoveX, centerMoveY);
154 +
155 +				clampPan(imageData);
156 +				touchState.lastPanX = imageData.panX;
157 +				touchState.lastPanY = imageData.panY;
158 +			}
159 +
160 +			touchState.lastDistance = distance;
161 +			touchState.lastCenterX = centerX;
162 +			touchState.lastCenterY = centerY;
163 +
164 +			updateImagePosition(imageData);
165 +		}
166 +	}, { passive: false });
167 +
168 +	container.addEventListener('touchend', () => {
169 +		if (touchState && touchState.image === imageData) {
170 +			touchState = null;
171 +		}
172 +	}, { passive: false });
173 +
174 +	container.addEventListener('touchcancel', () => {
175 +		if (touchState && touchState.image === imageData) {
176 +			touchState = null;
177 +		}
178 +	}, { passive: false });
179 +
180 +	// Moving / Deleting / Duplicating
181 +	container.addEventListener('mousedown', (e) => {
182 +		if (e.target.classList.contains('resize-handle')) return;
183 +
184 +		e.preventDefault();
185 +
186 +		// Shift-click to delete
187 +		if (e.shiftKey) {
188 +			const index = images.indexOf(imageData);
189 +			if (index > -1) {
190 +				images.splice(index, 1);
191 +			}
192 +			container.remove();
193 +			return;
194 +		}
195 +
196 +		// Option-click (or Alt-click on Windows/Linux) to rotate
197 +		if (e.altKey) {
198 +			// Check if rotation would cause dimension swap and if it would fit on grid
199 +			const oldRotation = imageData.rotation;
200 +			const newRotation = (imageData.rotation + 90) % 360;
201 +
202 +			// When rotating between portrait and landscape (90° or 270°), dimensions swap
203 +			const willSwapDimensions = (oldRotation % 180 === 0 && newRotation % 180 !== 0) ||
204 +			                           (oldRotation % 180 !== 0 && newRotation % 180 === 0);
205 +
206 +			if (willSwapDimensions) {
207 +				// Check if swapped dimensions would fit on grid at current position
208 +				const newWidthCells = imageData.heightCells;
209 +				const newHeightCells = imageData.widthCells;
210 +
211 +				// Don't allow rotation if it would exceed grid bounds
212 +				if (imageData.xCell + newWidthCells > GRID_COLS ||
213 +				    imageData.yCell + newHeightCells > GRID_ROWS) {
214 +					return; // Silently ignore the rotation
215 +				}
216 +
217 +				// Swap width and height
218 +				imageData.widthCells = newWidthCells;
219 +				imageData.heightCells = newHeightCells;
220 +			}
221 +
222 +			// Rotate 90 degrees clockwise
223 +			imageData.rotation = newRotation;
224 +
225 +			// Don't rotate pan coordinates - they stay in the image's original coordinate system
226 +			// The CSS transform applies rotation before pan, so pan is relative to the rotated image
227 +
228 +			updateImagePosition(imageData);
229 +			return;
230 +		}
231 +
232 +		// Cmd-click (or Ctrl-click on Windows/Linux) to duplicate
233 +		if (e.metaKey || e.ctrlKey) {
234 +			// Calculate target position (1 cell right and 1 cell down)
235 +			let newXCell = imageData.xCell + 1;
236 +			let newYCell = imageData.yCell + 1;
237 +
238 +			// If there's not enough room, fall back to top-left
239 +			if (newXCell + imageData.widthCells > GRID_COLS || newYCell + imageData.heightCells > GRID_ROWS) {
240 +				newXCell = 0;
241 +				newYCell = 0;
242 +			}
243 +
244 +			// Create duplicate with the same image source and dimensions
245 +			const imgElement = container.querySelector('img');
246 +			const newImageData = addImage(
247 +				imgElement.src,
248 +				newXCell,
249 +				newYCell,
250 +				imageData.widthCells,
251 +				imageData.heightCells
252 +			);
253 +
254 +			// Copy pan, zoom, and rotation settings from original
255 +			// Store the original settings to apply after image loads
256 +			const originalPanX = imageData.panX;
257 +			const originalPanY = imageData.panY;
258 +			const originalUserScale = imageData.userScale;
259 +			const originalRotation = imageData.rotation;
260 +
261 +			// Override the onload to copy settings
262 +			const newImg = newImageData.container.querySelector('img');
263 +			const originalOnload = newImg.onload;
264 +			newImg.onload = () => {
265 +				// Run the original onload first
266 +				if (originalOnload) originalOnload.call(newImg);
267 +
268 +				// Then apply the copied settings
269 +				newImageData.panX = originalPanX;
270 +				newImageData.panY = originalPanY;
271 +				newImageData.userScale = originalUserScale;
272 +				newImageData.rotation = originalRotation;
273 +				updateImagePosition(newImageData);
274 +			};
275 +
276 +			// If image is already loaded (cached), trigger the settings copy
277 +			if (newImg.complete && newImageData.naturalWidth > 0) {
278 +				newImageData.panX = originalPanX;
279 +				newImageData.panY = originalPanY;
280 +				newImageData.userScale = originalUserScale;
281 +				newImageData.rotation = originalRotation;
282 +				updateImagePosition(newImageData);
283 +			}
284 +
285 +			return;
286 +		}
287 +
288 +		handlePointerStart(e.clientX, e.clientY);
289 +	});
290 +
291 +	// Resizing - unified handler for mouse and touch
292 +	function startResize(clientX, clientY, direction, cursorStyle = null) {
293 +		if (cursorStyle) {
294 +			document.body.style.cursor = cursorStyle;
295 +		}
296 +		document.body.classList.add('resizing');
297 +
298 +		resizeState = {
299 +			image: imageData,
300 +			direction: direction,
301 +			startX: clientX,
302 +			startY: clientY,
303 +			startXCell: imageData.xCell,
304 +			startYCell: imageData.yCell,
305 +			startWidthCells: imageData.widthCells,
306 +			startHeightCells: imageData.heightCells
307 +		};
308 +		container.classList.add('resizing');
309 +	}
310 +
311 +	container.querySelectorAll('.resize-handle').forEach(handle => {
312 +		handle.addEventListener('mousedown', (e) => {
313 +			e.preventDefault();
314 +			e.stopPropagation();
315 +			const cursorStyle = window.getComputedStyle(handle).cursor;
316 +			startResize(e.clientX, e.clientY, handle.dataset.direction, cursorStyle);
317 +		});
318 +
319 +		handle.addEventListener('touchstart', (e) => {
320 +			e.preventDefault();
321 +			e.stopPropagation();
322 +			const touch = e.touches[0];
323 +			startResize(touch.clientX, touch.clientY, handle.dataset.direction);
324 +		}, { passive: false });
325 +	});
326 +
327 +	// Pan and Zoom with wheel events (macOS trackpad gestures)
328 +	container.addEventListener('wheel', (e) => {
329 +		e.preventDefault();
330 +		e.stopPropagation();
331 +
332 +		// Don't interfere with dragging or resizing
333 +		if (dragState || resizeState) return;
334 +
335 +		// Detect pinch zoom (ctrlKey is set for pinch gestures on macOS trackpad)
336 +		if (e.ctrlKey) {
337 +			// Zoom at cursor position
338 +			const rect = container.getBoundingClientRect();
339 +			const cursorX = e.clientX - rect.left - rect.width / 2;
340 +			const cursorY = e.clientY - rect.top - rect.height / 2;
341 +
342 +			const oldUserScale = imageData.userScale;
343 +			const oldTotalScale = imageData.baseScale * oldUserScale;
344 +			const zoomDelta = -e.deltaY * 0.01;
345 +			const newUserScale = Math.max(1, Math.min(5, oldUserScale * (1 + zoomDelta)));
346 +			const newTotalScale = imageData.baseScale * newUserScale;
347 +
348 +			adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale);
349 +			imageData.userScale = newUserScale;
350 +
351 +			clampPan(imageData);
352 +		} else {
353 +			// Pan (two-finger scroll on macOS trackpad)
354 +			applyPanDelta(imageData, -e.deltaX, -e.deltaY);
355 +			clampPan(imageData);
356 +		}
357 +
358 +		updateImagePosition(imageData);
359 +	}, { passive: false });
360 +}
361 +
362 +// Helper to clear drag state and timers
363 +function clearDragState() {
364 +	if (longPressTimer) {
365 +		clearTimeout(longPressTimer);
366 +		longPressTimer = null;
367 +	}
368 +	if (dragState) {
369 +		dragState.image.container.classList.remove('dragging');
370 +		dragState.image.container.classList.remove('pan-mode');
371 +		dragState = null;
372 +		document.body.style.cursor = '';
373 +		document.body.classList.remove('dragging');
374 +	}
375 +}
376 +
377 +function handleMove(clientX, clientY) {
378 +	if (dragState) {
379 +		const dx = clientX - dragState.startX;
380 +		const dy = clientY - dragState.startY;
381 +
382 +		// Safari on iOS can send the first touchmove with stale coordinates when zoomed
383 +		// Validate that the first move is reasonable by checking if it would move more than 1 cell
384 +		if (dragState.isTouch && !dragState.hasMoved) {
385 +			const cellSize = getCellSize();
386 +			const dxCells = Math.abs(Math.round(dx / cellSize.width));
387 +			const dyCells = Math.abs(Math.round(dy / cellSize.height));
388 +
389 +			// If the first move would jump more than 1 cell in either direction,
390 +			// it's likely stale coordinates from a previous tap - reset start position
391 +			if (dxCells > 1 || dyCells > 1) {
392 +				dragState.startX = clientX;
393 +				dragState.startY = clientY;
394 +				dragState.hasMoved = true;
395 +				return; // Don't process this move event
396 +			}
397 +			dragState.hasMoved = true;
398 +		}
399 +
400 +		if (dragState.isPanMode) {
401 +			// Pan mode - move the image within its container
402 +			const imageData = dragState.image;
403 +			imageData.panX = dragState.initialPanX;
404 +			imageData.panY = dragState.initialPanY;
405 +			applyPanDelta(imageData, dx, dy);
406 +			clampPan(imageData);
407 +			updateImagePosition(imageData);
408 +		} else {
409 +			// Normal drag mode - move the image container on the grid
410 +			const cellSize = getCellSize();
411 +			const dxCells = Math.round(dx / cellSize.width);
412 +			const dyCells = Math.round(dy / cellSize.height);
413 +
414 +			const newXCell = Math.max(0, Math.min(GRID_COLS - dragState.image.widthCells, dragState.startXCell + dxCells));
415 +			const newYCell = Math.max(0, Math.min(GRID_ROWS - dragState.image.heightCells, dragState.startYCell + dyCells));
416 +
417 +			// If the image moved to a new cell, cancel the long press timer
418 +			if (dragState.isTouch && longPressTimer &&
419 +			    (newXCell !== dragState.startXCell || newYCell !== dragState.startYCell)) {
420 +				clearTimeout(longPressTimer);
421 +				longPressTimer = null;
422 +			}
423 +
424 +			dragState.image.xCell = newXCell;
425 +			dragState.image.yCell = newYCell;
426 +			updateImagePosition(dragState.image);
427 +		}
428 +	}
429 +
430 +	if (resizeState) {
431 +		const dx = clientX - resizeState.startX;
432 +		const dy = clientY - resizeState.startY;
433 +
434 +		const cellSize = getCellSize();
435 +		const dxCells = Math.round(dx / cellSize.width);
436 +		const dyCells = Math.round(dy / cellSize.height);
437 +
438 +		const dir = resizeState.direction;
439 +		const img = resizeState.image;
440 +
441 +		let newX = img.xCell;
442 +		let newY = img.yCell;
443 +		let newW = img.widthCells;
444 +		let newH = img.heightCells;
445 +
446 +		if (dir.includes('e')) {
447 +			const proposedW = Math.max(1, resizeState.startWidthCells + dxCells);
448 +			// Clamp to grid boundary
449 +			newW = Math.min(proposedW, GRID_COLS - resizeState.startXCell);
450 +		}
451 +		if (dir.includes('w')) {
452 +			const delta = Math.min(dxCells, resizeState.startWidthCells - 1);
453 +			const proposedX = resizeState.startXCell + delta;
454 +			// Clamp to grid boundary
455 +			const clampedX = Math.max(0, proposedX);
456 +			newX = clampedX;
457 +			newW = resizeState.startWidthCells - (clampedX - resizeState.startXCell);
458 +		}
459 +		if (dir.includes('s')) {
460 +			const proposedH = Math.max(1, resizeState.startHeightCells + dyCells);
461 +			// Clamp to grid boundary
462 +			newH = Math.min(proposedH, GRID_ROWS - resizeState.startYCell);
463 +		}
464 +		if (dir.includes('n')) {
465 +			const delta = Math.min(dyCells, resizeState.startHeightCells - 1);
466 +			const proposedY = resizeState.startYCell + delta;
467 +			// Clamp to grid boundary
468 +			const clampedY = Math.max(0, proposedY);
469 +			newY = clampedY;
470 +			newH = resizeState.startHeightCells - (clampedY - resizeState.startYCell);
471 +		}
472 +
473 +		img.xCell = newX;
474 +		img.yCell = newY;
475 +		img.widthCells = newW;
476 +		img.heightCells = newH;
477 +		updateImagePosition(img);
478 +	}
479 +}
480 +
481 +function handleEnd() {
482 +	clearDragState();
483 +	if (resizeState) {
484 +		resizeState.image.container.classList.remove('resizing');
485 +		resizeState = null;
486 +		document.body.style.cursor = '';
487 +		document.body.classList.remove('resizing');
488 +	}
489 +}
490 +
491 +document.addEventListener('mousemove', (e) => {
492 +	handleMove(e.clientX, e.clientY);
493 +});
494 +
495 +document.addEventListener('mouseup', () => {
496 +	handleEnd();
497 +});
498 +
499 +document.addEventListener('touchmove', (e) => {
500 +	// Only handle global drag/resize, not image-specific multi-touch
501 +	if ((dragState || resizeState) && e.touches.length === 1) {
502 +		const touch = e.touches[0];
503 +
504 +		// For drag operations, verify this touch matches the one that started the drag
505 +		if (dragState && dragState.touchIdentifier !== null &&
506 +		    touch.identifier !== dragState.touchIdentifier) {
507 +			// This is a different touch - ignore it
508 +			return;
509 +		}
510 +
511 +		e.preventDefault();
512 +		handleMove(touch.clientX, touch.clientY);
513 +	}
514 +}, { passive: false });
515 +
516 +document.addEventListener('touchend', (e) => {
517 +	// If we have an active drag state, only end it if the touch that's ending
518 +	// matches the touch that started the drag
519 +	if (dragState && dragState.touchIdentifier !== null && e.changedTouches.length > 0) {
520 +		let matchingTouchEnded = false;
521 +		for (let i = 0; i < e.changedTouches.length; i++) {
522 +			if (e.changedTouches[i].identifier === dragState.touchIdentifier) {
523 +				matchingTouchEnded = true;
524 +				break;
525 +			}
526 +		}
527 +		// Only end the drag if the matching touch ended
528 +		if (!matchingTouchEnded) {
529 +			return;
530 +		}
531 +	}
532 +
533 +	handleEnd();
534 +
535 +	// Clear any lingering timers even if there's no active drag state
536 +	if (longPressTimer) {
537 +		clearTimeout(longPressTimer);
538 +		longPressTimer = null;
539 +	}
540 +});
541 +
542 +document.addEventListener('touchcancel', () => {
543 +	handleEnd();
544 +
545 +	if (longPressTimer) {
546 +		clearTimeout(longPressTimer);
547 +		longPressTimer = null;
548 +	}
549 +});
550 +
551 +// Intercept wheel events at the document level to prevent page scroll/zoom when the cursor is over an image container.
552 +document.addEventListener('wheel', (e) => {
553 +	const hoveredImage = images.find(img => img.container.contains(e.target) || img.container === e.target);
554 +	if (hoveredImage) {
555 +		e.preventDefault();
556 +	}
557 +}, { passive: false });
resources/popup.js
 1 +// Transient notification displayed for grid size changes, paste failures, etc.
 2 +
 3 +let popupElement = null;
 4 +let popupTimeout = null;
 5 +
 6 +function showPopup(message, displayDuration = 1250) {
 7 +	if (!popupElement) {
 8 +		popupElement = document.createElement('div');
 9 +		popupElement.className = 'popup-notification';
10 +		document.body.appendChild(popupElement);
11 +	}
12 +
13 +	if (popupTimeout) clearTimeout(popupTimeout);
14 +
15 +	popupElement.textContent = message;
16 +	popupElement.classList.remove('fade-out');
17 +	popupElement.classList.add('show');
18 +
19 +	popupTimeout = setTimeout(() => {
20 +		popupElement.classList.remove('show');
21 +		popupElement.classList.add('fade-out');
22 +	}, displayDuration);
23 +}
resources/print.js
 1 +// Rescale images against the mm-sized print cell on beforeprint, restore screen scale on afterprint
 2 +
 3 +const PRINT_CELL_SIZE_PX = CELL_SIZE_MM * 96 / 25.4; // Cell size in pixels at 96 DPI
 4 +
 5 +window.addEventListener('beforeprint', () => {
 6 +	images.forEach(img => {
 7 +		const imgElement = img.container.querySelector('img');
 8 +		if (!imgElement || !img.naturalWidth || !img.naturalHeight) return;
 9 +
10 +		// Calculate print container size
11 +		const printWidth = img.widthCells * PRINT_CELL_SIZE_PX;
12 +		const printHeight = img.heightCells * PRINT_CELL_SIZE_PX;
13 +
14 +		// Recalculate base scale for print
15 +		const isRotated = img.rotation % 180 !== 0;
16 +		const effectiveW = isRotated ? img.naturalHeight : img.naturalWidth;
17 +		const effectiveH = isRotated ? img.naturalWidth : img.naturalHeight;
18 +		const printBaseScale = Math.max(printWidth / effectiveW, printHeight / effectiveH);
19 +
20 +		// Apply print transform
21 +		const printScale = printBaseScale * img.userScale;
22 +		imgElement.style.transform = `translate(-50%, -50%) scale(${printScale}) rotate(${img.rotation}deg) translate(${img.panX}px, ${img.panY}px)`;
23 +	});
24 +});
25 +
26 +window.addEventListener('afterprint', () => {
27 +	// Wait for layout to settle after exiting print mode
28 +	requestAnimationFrame(() => {
29 +		requestAnimationFrame(() => {
30 +			images.forEach(img => updateImagePosition(img));
31 +		});
32 +	});
33 +});
resources/style.css
  1 +:root {
  2 +	/* Default colors */
  3 +	--white: white;
  4 +	--black: black;
  5 +	--default-grid: #d0d0d0;
  6 +	--shadow: #00000044;
  7 +
  8 +	/* guac theme colors */
  9 +	--onion: #a81d8f;
 10 +	--garlic: #fefef4;
 11 +	--lime: #a9cf51;
 12 +	--avocado: #7a8520;
 13 +
 14 +	/* banana theme colors */
 15 +	--peel: #ffcc12;
 16 +	--flesh: #f8f5e3;
 17 +	--unripe: #a5d269;
 18 +	--bruise: #4d4235;
 19 +
 20 +	/* mojito theme colors */
 21 +	--mint: #9febaa;
 22 +	--soda: #f8fffe;
 23 +	--zest: #9ee3ad;
 24 +	--watermelon: #f8599b;
 25 +
 26 +	/* grape-soda theme colors */
 27 +	--natural-flavors: #d4c5f9;
 28 +	--effervescence: #f9f7ff;
 29 +	--concentrate: #b8a9dd;
 30 +	--syrup: #7c5cbe;
 31 +
 32 +	/* grapefruit theme colors */
 33 +	--rind: #ff9b87;
 34 +	--pith: #fff8f6;
 35 +	--pulp: #f0bcb3;
 36 +	--ruby: #ff6b51;
 37 +
 38 +	/* sea-breeze theme colors */
 39 +	--cerulean: #a3d5ff;
 40 +	--mist: #f7fcff;
 41 +	--horizon: #85c0f5;
 42 +	--lagoon: #2196f3;
 43 +
 44 +	--grid-cols: 49;
 45 +	--grid-rows: 66;
 46 +	--cell-size-mm: 4mm;
 47 +
 48 +	/* Screen view: Grid as percentage of page (centered on US Letter simulation) */
 49 +	/* Grid width as percent of Letter: 196/215.9 = 90.78% */
 50 +	/* Grid height as percent of Letter: 264/279.4 = 94.48% */
 51 +	--grid-width-percent: 90.78%;
 52 +	--grid-height-percent: 94.48%;
 53 +
 54 +	/* Default theme (sea-breeze) */
 55 +	--desk: var(--cerulean);
 56 +	--page: var(--mist);
 57 +	--grid-line: var(--horizon);
 58 +	--grid-line-light: color-mix(in srgb, var(--grid-line) 65%, transparent);
 59 +	--accent: var(--lagoon);
 60 +
 61 +	/* Set text color for all themes */
 62 +	color: var(--white);
 63 +
 64 +	/* Set font */
 65 +	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
 66 +}
 67 +
 68 +/* Theme definitions */
 69 +:root[data-theme="guac"] {
 70 +	--desk: var(--avocado);
 71 +	--page: var(--garlic);
 72 +	--grid-line: var(--lime);
 73 +	--accent: var(--onion);
 74 +}
 75 +
 76 +:root[data-theme="banana"] {
 77 +	--desk: var(--peel);
 78 +	--page: var(--flesh);
 79 +	--grid-line: var(--unripe);
 80 +	--accent: var(--bruise);
 81 +}
 82 +
 83 +:root[data-theme="mojito"] {
 84 +	--desk: var(--mint);
 85 +	--page: var(--soda);
 86 +	--grid-line: var(--zest);
 87 +	--accent: var(--watermelon);
 88 +}
 89 +
 90 +:root[data-theme="grape-soda"] {
 91 +	--desk: var(--natural-flavors);
 92 +	--page: var(--effervescence);
 93 +	--grid-line: var(--concentrate);
 94 +	--accent: var(--syrup);
 95 +}
 96 +
 97 +:root[data-theme="grapefruit"] {
 98 +	--desk: var(--rind);
 99 +	--page: var(--pith);
100 +	--grid-line: var(--pulp);
101 +	--accent: var(--ruby);
102 +}
103 +
104 +:root[data-theme="sea-breeze"] {
105 +	--desk: var(--cerulean);
106 +	--page: var(--mist);
107 +	--grid-line: var(--horizon);
108 +	--accent: var(--lagoon);
109 +}
110 +
111 +* {
112 +	margin: 0;
113 +	padding: 0;
114 +	box-sizing: border-box;
115 +	-webkit-user-select: none;
116 +	user-select: none;
117 +	scrollbar-color: var(--page) var(--desk);
118 +}
119 +
120 +body {
121 +	background: var(--desk);
122 +	display: flex;
123 +	justify-content: center;
124 +	align-items: flex-start;
125 +	align-items: safe center;
126 +	min-height: 100vh;
127 +	padding: 20px;
128 +}
129 +
130 +.page {
131 +	width: min(100%, var(--page-width, 8.5in));
132 +	aspect-ratio: 8.5 / 11;
133 +	background: var(--page);
134 +	position: relative;
135 +	box-shadow: 0 8px 24px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.08);
136 +	display: flex;
137 +	align-items: center;
138 +	justify-content: center;
139 +}
140 +
141 +.grid {
142 +	/* Grid dimensions as percentage of page to scale responsively */
143 +	width: var(--grid-width-percent);
144 +	height: var(--grid-height-percent);
145 +	display: grid;
146 +	grid-template-columns: repeat(var(--grid-cols), 1fr);
147 +	grid-template-rows: repeat(var(--grid-rows), 1fr);
148 +	gap: 0;
149 +	line-height: 0;
150 +	position: relative; /* Image containers are positioned relative to grid */
151 +	/* Grid is centered within .page by the parent's flexbox */
152 +	box-sizing: border-box;
153 +}
154 +
155 +.grid-cell {
156 +	width: 100%;
157 +	height: 100%;
158 +	box-sizing: border-box;
159 +	border-top: var(--line-width, 1px) dashed var(--grid-line);
160 +	border-left: var(--line-width, 1px) dashed var(--grid-line);
161 +	margin: 0;
162 +	padding: 0;
163 +	display: block;
164 +}
165 +
166 +/* Add right border to rightmost column */
167 +.grid-cell.right-edge {
168 +	border-right: var(--line-width, 1px) dashed var(--grid-line);
169 +	width: calc(100% + var(--line-width, 1px));
170 +}
171 +
172 +/* Add bottom border to bottom row */
173 +.grid-cell.bottom-edge {
174 +	border-bottom: var(--line-width, 1px) dashed var(--grid-line);
175 +	height: calc(100% + var(--line-width, 1px));
176 +}
177 +
178 +/* Solid grid lines for cell sizes <= 3.56mm (screen preview only) */
179 +.solid-grid .grid-cell {
180 +	border-top-style: solid;
181 +	border-left-style: solid;
182 +	border-top-color: var(--grid-line-light);
183 +	border-left-color: var(--grid-line-light);
184 +}
185 +
186 +.solid-grid .grid-cell.right-edge {
187 +	border-right-style: solid;
188 +	border-right-color: var(--grid-line-light);
189 +}
190 +
191 +.solid-grid .grid-cell.bottom-edge {
192 +	border-bottom-style: solid;
193 +	border-bottom-color: var(--grid-line-light);
194 +}
195 +
196 +@media (max-width: 680px) {
197 +	body {
198 +		padding: 0;
199 +	}
200 +
201 +	.page {
202 +		box-shadow: none;
203 +	}
204 +
205 +	.grid-cell {
206 +		border-top-style: solid;
207 +		border-left-style: solid;
208 +		border-top-color: var(--grid-line-light);
209 +		border-left-color: var(--grid-line-light);
210 +	}
211 +
212 +	.grid-cell.right-edge {
213 +		border-right-style: solid;
214 +		border-right-color: var(--grid-line-light);
215 +		width: calc(100% + var(--line-width, 1px));
216 +	}
217 +
218 +	.grid-cell.bottom-edge {
219 +		border-bottom-style: solid;
220 +		border-bottom-color: var(--grid-line-light);
221 +		height: calc(100% + var(--line-width, 1px));
222 +	}
223 +}
224 +
225 +.image-container {
226 +	position: absolute;
227 +	cursor: grab;
228 +	outline: 2px solid transparent;
229 +	outline-offset: -2px;
230 +	transition: outline-color 0.2s;
231 +	background: var(--page);
232 +	touch-action: none;
233 +}
234 +
235 +.image-container::after {
236 +	content: '';
237 +	position: absolute;
238 +	top: 0;
239 +	left: 0;
240 +	right: 0;
241 +	bottom: 0;
242 +	pointer-events: none;
243 +	border: 2px solid transparent;
244 +	transition: border-color 0.2s;
245 +	z-index: 1;
246 +	/* overflow: hidden; */
247 +}
248 +
249 +.image-container .image-wrapper {
250 +	position: absolute;
251 +	top: 0;
252 +	left: 0;
253 +	right: 0;
254 +	bottom: 0;
255 +	overflow: hidden;
256 +}
257 +
258 +.image-container:hover::after {
259 +	border-color: var(--accent);
260 +}
261 +
262 +.image-container.dragging {
263 +	opacity: 0.7;
264 +}
265 +
266 +.image-container.dragging::after {
267 +	border-color: var(--accent);
268 +}
269 +
270 +/* Visual feedback for pan mode on touch */
271 +.image-container.pan-mode::after {
272 +	border-color: var(--accent);
273 +	border-width: 3px;
274 +	border-style: solid;
275 +	animation: pulse-border 0.25s ease-out;
276 +}
277 +
278 +@keyframes pulse-border {
279 +	0% {
280 +		border-width: 2px;
281 +		opacity: 0.5;
282 +	}
283 +	50% {
284 +		border-width: 5px;
285 +		opacity: 1;
286 +	}
287 +	100% {
288 +		border-width: 3px;
289 +		opacity: 1;
290 +	}
291 +}
292 +
293 +/* Hide dimension labels and resize handles in pan mode */
294 +.image-container.pan-mode .dimension-label {
295 +	opacity: 0 !important;
296 +}
297 +
298 +.image-container.pan-mode .resize-handle {
299 +	opacity: 0 !important;
300 +}
301 +
302 +.image-container.resizing::after {
303 +	border-color: var(--accent);
304 +}
305 +
306 +body.resizing *,
307 +body.dragging * {
308 +	cursor: inherit !important;
309 +}
310 +
311 +.image-container img {
312 +	position: absolute;
313 +	top: 50%;
314 +	left: 50%;
315 +	display: block;
316 +	pointer-events: none;
317 +	transform-origin: center center;
318 +	transition: none;
319 +}
320 +
321 +.resize-handle {
322 +	position: absolute;
323 +	background: var(--accent);
324 +	opacity: 0;
325 +	transition: opacity 0.2s;
326 +	z-index: 2;
327 +}
328 +
329 +.image-container:hover .resize-handle,
330 +.image-container.resizing .resize-handle {
331 +	opacity: 1;
332 +}
333 +
334 +.dimension-label {
335 +	position: absolute;
336 +	background: var(--accent);
337 +	color: var(--text);
338 +	padding: 4px 10px;
339 +	border-radius: 12px;
340 +	font-size: 16px;
341 +	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
342 +	font-weight: 500;
343 +	line-height: 1;
344 +	opacity: 0;
345 +	transition: opacity 0.2s;
346 +	pointer-events: none;
347 +	z-index: 9999;
348 +	white-space: nowrap;
349 +}
350 +
351 +.dimension-label.width {
352 +	top: 1px;
353 +	left: 50%;
354 +	transform: translate(-50%, -50%);
355 +}
356 +
357 +.dimension-label.height {
358 +	right: 1px;
359 +	top: 50%;
360 +	transform: translate(50%, -50%) rotate(90deg);
361 +}
362 +
363 +.image-container:hover .dimension-label,
364 +.image-container.dragging .dimension-label,
365 +.image-container.resizing .dimension-label {
366 +	opacity: 1;
367 +}
368 +
369 +.dimension-label[data-hidden="true"] {
370 +	opacity: 0 !important;
371 +}
372 +
373 +.resize-handle.corner {
374 +	width: 10px;
375 +	height: 10px;
376 +	background: transparent;
377 +}
378 +
379 +.resize-handle.corner::before {
380 +	content: '';
381 +	position: absolute;
382 +	width: 10px;
383 +	height: 10px;
384 +	background: var(--accent);
385 +	border-radius: 50%;
386 +}
387 +
388 +.resize-handle.edge {
389 +	background: transparent;
390 +}
391 +
392 +/* Desktop: Original 10px hitboxes */
393 +.resize-handle.n { top: -5px; left: 5px; right: 5px; height: 10px; cursor: n-resize; }
394 +.resize-handle.s { bottom: -5px; left: 5px; right: 5px; height: 10px; cursor: s-resize; }
395 +.resize-handle.e { right: -5px; top: 5px; bottom: 5px; width: 10px; cursor: e-resize; }
396 +.resize-handle.w { left: -5px; top: 5px; bottom: 5px; width: 10px; cursor: w-resize; }
397 +
398 +.resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
399 +.resize-handle.ne::before { top: 0; right: 0; }
400 +
401 +.resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
402 +.resize-handle.nw::before { top: 0; left: 0; }
403 +
404 +.resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
405 +.resize-handle.se::before { bottom: 0; right: 0; }
406 +
407 +.resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
408 +.resize-handle.sw::before { bottom: 0; left: 0; }
409 +
410 +@media (pointer: coarse), (hover: none) {
411 +	html {
412 +		overscroll-behavior: none;
413 +	}
414 +
415 +	/* Only fix position in portrait mode to prevent scroll issues in landscape */
416 +	@media (orientation: portrait) {
417 +		html, body {
418 +			height: 100%;
419 +			position: fixed;
420 +			width: 100%;
421 +		}
422 +	}
423 +
424 +	.resize-handle.corner {
425 +		width: 12px;
426 +		height: 12px;
427 +	}
428 +
429 +	.resize-handle.corner::before {
430 +		/* Center the visual dot within the larger hitbox */
431 +		top: 50%;
432 +		left: 50%;
433 +		transform: translate(-50%, -50%);
434 +	}
435 +
436 +	/* Edge handles with 12px hitboxes */
437 +	.resize-handle.n { top: -6px; height: 12px; }
438 +	.resize-handle.s { bottom: -6px; height: 12px; }
439 +	.resize-handle.e { right: -6px; width: 12px; }
440 +	.resize-handle.w { left: -6px; width: 12px; }
441 +
442 +	/* Corner handles with 12px hitboxes, centered on the visual corner */
443 +	.resize-handle.ne { top: -6px; right: -6px; }
444 +	.resize-handle.nw { top: -6px; left: -6px; }
445 +	.resize-handle.se { bottom: -6px; right: -6px; }
446 +	.resize-handle.sw { bottom: -6px; left: -6px; }
447 +}
448 +
449 +@media print {
450 +	* {
451 +		-webkit-print-color-adjust: exact !important;
452 +		print-color-adjust: exact !important;
453 +	}
454 +
455 +	html, body {
456 +		margin: 0 !important;
457 +		padding: 0 !important;
458 +		width: 100% !important;
459 +		height: 100vh !important;
460 +		overflow: visible !important;
461 +	}
462 +
463 +	body {
464 +		background: var(--white);
465 +		display: flex !important;
466 +		align-items: center !important;
467 +		justify-content: center !important;
468 +	}
469 +
470 +	.page {
471 +		width: auto !important;
472 +		height: auto !important;
473 +		max-width: none !important;
474 +		box-shadow: none;
475 +		margin: 0;
476 +		padding: 0;
477 +		background: var(--white);
478 +		display: block !important;
479 +	}
480 +
481 +	.grid {
482 +		width: calc(var(--grid-cols) * var(--cell-size-mm)) !important;
483 +		height: calc(var(--grid-rows) * var(--cell-size-mm)) !important;
484 +		display: grid;
485 +		grid-template-columns: repeat(var(--grid-cols), 1fr) !important;
486 +		grid-template-rows: repeat(var(--grid-rows), 1fr) !important;
487 +		position: relative !important;
488 +		box-sizing: border-box !important;
489 +	}
490 +
491 +	.grid-cell {
492 +		border-top: 1px dashed var(--default-grid) !important;
493 +		border-left: 1px dashed var(--default-grid) !important;
494 +		box-sizing: border-box !important;
495 +		width: 100% !important;
496 +		height: 100% !important;
497 +		margin: 0 !important;
498 +		padding: 0 !important;
499 +	}
500 +
501 +	.grid-cell.right-edge {
502 +		border-right: 1px dashed var(--default-grid) !important;
503 +		width: calc(100% + 1px) !important;
504 +	}
505 +
506 +	.grid-cell.bottom-edge {
507 +		border-bottom: 1px dashed var(--default-grid) !important;
508 +		height: calc(100% + 1px) !important;
509 +	}
510 +
511 +	.image-container {
512 +		outline: none !important;
513 +		position: absolute !important;
514 +		background: var(--white) !important;
515 +		/* Recalculate positions using cell coordinates and print cell size */
516 +		left: calc(var(--x-cell) * var(--cell-size-mm)) !important;
517 +		top: calc(var(--y-cell) * var(--cell-size-mm)) !important;
518 +		width: calc(var(--width-cells) * var(--cell-size-mm)) !important;
519 +		height: calc(var(--height-cells) * var(--cell-size-mm)) !important;
520 +	}
521 +
522 +	.image-container::after {
523 +		border: none !important;
524 +	}
525 +
526 +	.image-container .image-wrapper {
527 +		position: absolute !important;
528 +		top: 0 !important;
529 +		left: 0 !important;
530 +		right: 0 !important;
531 +		bottom: 0 !important;
532 +		overflow: hidden !important;
533 +	}
534 +
535 +	.image-container img {
536 +		position: absolute !important;
537 +		top: 50% !important;
538 +		left: 50% !important;
539 +		display: block !important;
540 +		pointer-events: none !important;
541 +		/* Preserve the transform from the screen view */
542 +	}
543 +
544 +	.resize-handle {
545 +		display: none !important;
546 +	}
547 +
548 +	.dimension-label {
549 +		display: none !important;
550 +	}
551 +
552 +	.add-images-btn {
553 +		display: none !important;
554 +	}
555 +
556 +	.popup-notification {
557 +		display: none !important;
558 +	}
559 +}
560 +
561 +@page {
562 +	margin: 0;
563 +}
564 +
565 +.add-images-btn {
566 +	display: none;
567 +	position: fixed;
568 +	bottom: 24px;
569 +	left: 50%;
570 +	transform: translateX(-50%);
571 +	background: var(--accent);
572 +	color: var(--page);
573 +	border: none;
574 +	border-radius: 100px;
575 +	padding: 14px 28px;
576 +	font-size: 16px;
577 +	font-weight: 600;
578 +	cursor: pointer;
579 +	box-shadow: 0 4px 12px rgba(0,0,0,0.2);
580 +	z-index: 10000;
581 +	-webkit-tap-highlight-color: transparent;
582 +	user-select: none;
583 +	-webkit-user-select: none;
584 +	transition: transform 0.1s ease, box-shadow 0.1s ease;
585 +}
586 +
587 +.add-images-btn:active {
588 +	transform: translateX(-50%) scale(0.95);
589 +	box-shadow: 0 2px 8px rgba(0,0,0,0.2);
590 +}
591 +
592 +@media (pointer: coarse), (hover: none) {
593 +	.add-images-btn {
594 +		display: block;
595 +		bottom: 0;
596 +		margin-bottom: 24px;
597 +	}
598 +
599 +	/* On touch devices, don't change opacity when dragging */
600 +	.image-container.dragging {
601 +		opacity: 1;
602 +	}
603 +}
604 +
605 +.popup-notification {
606 +	position: fixed;
607 +	bottom: 2rem;
608 +	left: 50%;
609 +	transform: translateX(-50%);
610 +	background-color: var(--accent);
611 +	color: var(--page);
612 +	padding: 0.7rem 0.9rem;
613 +	border-radius: 50rem;
614 +	font-size: 1.2rem;
615 +	text-align: center;
616 +	white-space: nowrap;
617 +	user-select: none;
618 +	width: fit-content;
619 +	height: fit-content;
620 +	opacity: 0;
621 +	pointer-events: none;
622 +	z-index: 10000;
623 +}
624 +
625 +.popup-notification.show {
626 +	opacity: 1;
627 +	transition: none;
628 +}
629 +
630 +.popup-notification.fade-out {
631 +	opacity: 0;
632 +	filter: blur(4px);
633 +	transition: opacity 1.5s ease-out, filter 1.5s ease-out;
634 +}
resources/theme.js
 1 +// Theme system
 2 +let currentThemeIndex = 0;
 3 +let isF2Pressed = false;
 4 +const themes = ['sea-breeze', 'grape-soda', 'grapefruit', 'guac', 'mojito', 'banana'];
 5 +
 6 +function setTheme(theme) {
 7 +	document.documentElement.setAttribute('data-theme', theme);
 8 +	const backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--desk').trim();
 9 +	document.querySelector('meta[name="theme-color"]').setAttribute('content', backgroundColor);
10 +}
11 +
12 +function cycleTheme() {
13 +	currentThemeIndex = (currentThemeIndex + 1) % themes.length;
14 +	const newTheme = themes[currentThemeIndex];
15 +	setTheme(newTheme);
16 +	saveThemeToLocalStorage(newTheme);
17 +}
18 +
19 +function saveThemeToLocalStorage(theme) {
20 +	localStorage.setItem('memori-theme', theme);
21 +}
22 +
23 +function loadThemeFromLocalStorage() {
24 +	const savedTheme = localStorage.getItem('memori-theme');
25 +	if (savedTheme && themes.includes(savedTheme)) {
26 +		currentThemeIndex = themes.indexOf(savedTheme);
27 +		setTheme(savedTheme);
28 +	} else {
29 +		// Use default theme
30 +		setTheme(themes[0]);
31 +	}
32 +}
33 +
34 +// F2 key handler for theme cycling
35 +document.addEventListener('keydown', (e) => {
36 +	if (e.key === 'F2' && !isF2Pressed) {
37 +		e.preventDefault();
38 +		isF2Pressed = true;
39 +		cycleTheme();
40 +	}
41 +});
42 +
43 +document.addEventListener('keyup', (e) => {
44 +	if (e.key === 'F2') {
45 +		isF2Pressed = false;
46 +	}
47 +});
48 +
49 +// Load theme on page load
50 +loadThemeFromLocalStorage();
script.js
  -// Paper dimensions in mm
  -const LETTER_WIDTH_MM = 215.9;  // 8.5 inches
  -const LETTER_HEIGHT_MM = 279.4; // 11 inches
  -const A4_WIDTH_MM = 210;
  -const A4_HEIGHT_MM = 297;
  -const MARGIN_MM = 6.35; // 0.25 inches
  -
  -// Calculate maximum grid dimensions that fit both Letter and A4 with margins
  -function calculateGridDimensions(cellSize) {
  -	// Available printable area (limiting factor is the smaller of Letter/A4 for each dimension)
  -	const availableWidth = Math.min(LETTER_WIDTH_MM, A4_WIDTH_MM) - (2 * MARGIN_MM);
  -	const availableHeight = Math.min(LETTER_HEIGHT_MM, A4_HEIGHT_MM) - (2 * MARGIN_MM);
  -
  -	// Calculate how many cells fit
  -	const cols = Math.floor(availableWidth / cellSize);
  -	const rows = Math.floor(availableHeight / cellSize);
  -
  -	return { cols, rows };
  -}
  -
  -// Calculate grid dimensions as percentage of Letter paper (used for screen layout)
  -function calculateGridPercentages(cellSize, cols, rows) {
  -	const gridWidthMM = cols * cellSize;
  -	const gridHeightMM = rows * cellSize;
  -	return {
  -		widthPercent: (gridWidthMM / LETTER_WIDTH_MM) * 100,
  -		heightPercent: (gridHeightMM / LETTER_HEIGHT_MM) * 100
  -	};
  -}
  -
  -// Cell size bounds (in mm)
  -const MIN_CELL_SIZE_MM = 2;
  -const MAX_CELL_SIZE_MM = 10;
  -
  -let GRID_COLS = 49;
  -let GRID_ROWS = 66;
  -let CELL_SIZE_MM = 4; // Physical size of each cell when printed (can be overridden by URL parameter)
  -
  -const grid = document.getElementById('grid');
  -const page = document.querySelector('.page');
  -
  -let popupElement = null;
  -let popupTimeout = null;
  -
  -// Detect Safari browser
  -function isSafari() {
  -	const ua = navigator.userAgent;
  -	return ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium');
  -}
  -
  -const isUsingSafari = isSafari();
  -
  -const isUsingWindows = navigator.userAgent.includes('Windows');
  -
  -// Parse URL parameters for custom cell size
  -function parseCellSizeFromURL() {
  -	const urlParams = new URLSearchParams(window.location.search);
  -	const gridSizeParam = urlParams.get('grid-size');
  -
  -	if (gridSizeParam) {
  -		// Remove 'mm' suffix if present
  -		const sizeStr = gridSizeParam.toLowerCase().replace('mm', '').trim();
  -		const size = parseFloat(sizeStr);
  -
  -		// Check if size is valid
  -		if (isNaN(size)) {
  -			showPopup('Invalid grid size. Using default 4mm.', 3000);
  -			return 4;
  -		}
  -
  -		// Check if size is within reasonable bounds
  -		if (size < MIN_CELL_SIZE_MM || size > MAX_CELL_SIZE_MM) {
  -			showPopup(`Grid size must be between ${MIN_CELL_SIZE_MM}mm and ${MAX_CELL_SIZE_MM}mm. Using default 4mm.`, 3500);
  -			return 4;
  -		}
  -
  -		showPopup(`Grid set to ${size}mm`);
  -		return size;
  -	}
  -
  -	return 4; // Default
  -}
  -
  -// Initialize cell size from URL
  -CELL_SIZE_MM = parseCellSizeFromURL();
  -
  -const PANEL_PPI = {
  -	1920: 141,   // 15.6" 1080p laptop
  -	2560: 109,   // 27" 1440p
  -	2736: 267,   // 12.3" 3:2 tablet
  -	2880: 267,   // 13" 3:2 tablet
  -	3840: 163    // 27" 4K
  -};
  -
  -function estimateWindowsPPI() {
  -	const pixelRatio = window.devicePixelRatio;
  -	if (pixelRatio === 1) return null;
  -
  -	const nativeWidth = screen.width * pixelRatio;
  -	if (nativeWidth >= 3800 && pixelRatio >= 2) return null;
  -
  -	const panel = Object.keys(PANEL_PPI).find(width => Math.abs(width - nativeWidth) <= width * 0.02);
  -
  -	return panel ? PANEL_PPI[panel] / pixelRatio : null;
  -}
  -
  -function estimateScreenPPI() {
  -	const logicalWidth = screen.width;
  -	const pixelRatio = window.devicePixelRatio;
  -
  -	if (isUsingWindows) {
  -		const windowsPPI = estimateWindowsPPI();
  -		if (windowsPPI) return windowsPPI;
  -	}
  -
  -	if (logicalWidth >= 3840) return 163;    // 27" 4K, OS scaling off
  -	if (logicalWidth >= 2048) return 109;    // 27" 5K retina or 1440p
  -	if (logicalWidth >= 1800) return 92.6;   // 23.8" 1080p and friends
  -
  -	return pixelRatio > 1 ? 127.7 : 92.6;    // retina laptop (2560px / 227ppi), else standard density
  -}
  -
  -// Life-size page width; CSS clamps this to the viewport so it never overflows horizontally
  -function applyPageWidth() {
  -	const pageWidth = (LETTER_WIDTH_MM / 25.4) * estimateScreenPPI();
  -	document.documentElement.style.setProperty('--page-width', `${pageWidth}px`);
  -	updateGridLineWidth();
  -}
  -
  -const baselinePixelRatio = window.devicePixelRatio;
  -
  -function updateGridLineWidth() {
  -	const zoom = window.devicePixelRatio / baselinePixelRatio;
  -	document.documentElement.style.setProperty('--line-width', `${Math.min(1, 1 / zoom)}px`);
  -}
  -
  -applyPageWidth();
  -
  -// Calculate grid dimensions based on cell size
  -const gridDimensions = calculateGridDimensions(CELL_SIZE_MM);
  -GRID_COLS = gridDimensions.cols;
  -GRID_ROWS = gridDimensions.rows;
  -
  -// Calculate grid percentages for screen layout
  -const gridPercentages = calculateGridPercentages(CELL_SIZE_MM, GRID_COLS, GRID_ROWS);
  -
  -// Update CSS variables for both screen and print
  -document.documentElement.style.setProperty('--cell-size-mm', `${CELL_SIZE_MM}mm`);
  -document.documentElement.style.setProperty('--grid-cols', GRID_COLS);
  -document.documentElement.style.setProperty('--grid-rows', GRID_ROWS);
  -document.documentElement.style.setProperty('--grid-width-percent', `${gridPercentages.widthPercent}%`);
  -document.documentElement.style.setProperty('--grid-height-percent', `${gridPercentages.heightPercent}%`);
  -
  -// Calculate cell size dynamically based on actual grid dimensions
  -function getCellSize() {
  -	const gridRect = grid.getBoundingClientRect();
  -	return {
  -		width: gridRect.width / GRID_COLS,
  -		height: gridRect.height / GRID_ROWS,
  -		totalWidth: gridRect.width,
  -		totalHeight: gridRect.height
  -	};
  -}
  -
  -// Calculate pixel-perfect position for a cell range
  -// This accounts for grid cell borders (1px per cell) to ensure perfect alignment
  -function getPixelPerfectBounds(cellX, cellY, cellWidth, cellHeight) {
  -	// Get all grid cells and measure their actual positions
  -	const gridCells = grid.querySelectorAll('.grid-cell');
  -
  -	// Calculate the index of the top-left cell
  -	const startCellIndex = cellY * GRID_COLS + cellX;
  -	const startCell = gridCells[startCellIndex];
  -
  -	if (!startCell) {
  -		// Fallback if cell doesn't exist
  -		const cellSize = getCellSize();
  -		return {
  -			left: cellX * cellSize.width,
  -			top: cellY * cellSize.height,
  -			width: cellWidth * cellSize.width,
  -			height: cellHeight * cellSize.height
  -		};
  -	}
  -
  -	// Get the actual position of the start cell relative to the grid
  -	const gridRect = grid.getBoundingClientRect();
  -	const startCellRect = startCell.getBoundingClientRect();
  -
  -	const left = startCellRect.left - gridRect.left;
  -	const top = startCellRect.top - gridRect.top;
  -
  -	// Calculate end position by finding the bottom-right cell
  -	const endCellIndex = (cellY + cellHeight - 1) * GRID_COLS + (cellX + cellWidth - 1);
  -	const endCell = gridCells[endCellIndex];
  -
  -	if (!endCell) {
  -		// Fallback if end cell doesn't exist
  -		const cellSize = getCellSize();
  -		return {
  -			left: left,
  -			top: top,
  -			width: cellWidth * cellSize.width,
  -			height: cellHeight * cellSize.height
  -		};
  -	}
  -
  -	const endCellRect = endCell.getBoundingClientRect();
  -	const right = endCellRect.right - gridRect.left;
  -	const bottom = endCellRect.bottom - gridRect.top;
  -
  -	return {
  -		left: left,
  -		top: top,
  -		width: right - left,
  -		height: bottom - top
  -	};
  -}
  -
  -// Create grid cells
  -for (let i = 0; i < GRID_COLS * GRID_ROWS; i++) {
  -	const cell = document.createElement('div');
  -	cell.className = 'grid-cell';
  -
  -	// Add right border to rightmost column
  -	const col = i % GRID_COLS;
  -	if (col === GRID_COLS - 1) {
  -		cell.classList.add('right-edge');
  -	}
  -
  -	// Add bottom border to bottom row
  -	const row = Math.floor(i / GRID_COLS);
  -	if (row === GRID_ROWS - 1) {
  -		cell.classList.add('bottom-edge');
  -	}
  -
  -	grid.appendChild(cell);
  -}
  -
  -// Apply solid border style for grids 3.56mm or smaller
  -if (CELL_SIZE_MM <= 3.56) {
  -	document.documentElement.classList.add('solid-grid');
  -}
  -
  -let images = [];
  -let dragState = null;
  -let resizeState = null;
  -let highestZIndex = 0;
  -let touchState = null; // For tracking multi-touch gestures
  -let longPressTimer = null; // For detecting long press to enable pan mode
  -
  -// Helper function to calculate image dimensions from aspect ratio
  -function calculateImageDimensions(aspectRatio) {
  -	let widthCells, heightCells;
  -	if (aspectRatio >= 1) {
  -		heightCells = 5;
  -		widthCells = Math.round(heightCells * aspectRatio);
  -	} else {
  -		widthCells = 5;
  -		heightCells = Math.round(widthCells / aspectRatio);
  -	}
  -	return {
  -		widthCells: Math.min(widthCells, GRID_COLS),
  -		heightCells: Math.min(heightCells, GRID_ROWS)
  -	};
  -}
  -
  -// Helper function to load an image and get its dimensions
  -async function loadImageDimensions(file) {
  -	const reader = new FileReader();
  -	const dataUrl = await new Promise(resolve => {
  -		reader.onload = (e) => resolve(e.target.result);
  -		reader.readAsDataURL(file);
  -	});
  -
  -	const img = new Image();
  -	const dimensions = await new Promise(resolve => {
  -		img.onload = () => {
  -			const aspectRatio = img.width / img.height;
  -			resolve(calculateImageDimensions(aspectRatio));
  -		};
  -		img.src = dataUrl;
  -	});
  -
  -	return { dataUrl, ...dimensions };
  -}
  -
  -// Shared function to process and add images to the grid
  -async function processAndAddImages(files, dropX = 0, dropY = 0) {
  -	const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
  -	if (imageFiles.length === 0) return;
  -
  -	const cellSize = getCellSize();
  -
  -	// Load first image to get base dimensions for positioning
  -	const firstImageData = await loadImageDimensions(imageFiles[0]);
  -
  -	// Calculate base drop position
  -	// For single images, center on cursor; for multiple images, place top-left at cursor
  -	let baseXCell, baseYCell;
  -	if (imageFiles.length === 1) {
  -		baseXCell = Math.round(dropX / cellSize.width - firstImageData.widthCells / 2);
  -		baseYCell = Math.round(dropY / cellSize.height - firstImageData.heightCells / 2);
  -	} else {
  -		baseXCell = Math.round(dropX / cellSize.width);
  -		baseYCell = Math.round(dropY / cellSize.height);
  -	}
  -
  -	// Pre-allocate z-indexes to maintain drop order
  -	const baseZIndex = highestZIndex + 1;
  -	highestZIndex += imageFiles.length;
  -
  -	// Load all images
  -	const imageDataArray = [];
  -	for (let idx = 0; idx < imageFiles.length; idx++) {
  -		const data = idx === 0 ? firstImageData : await loadImageDimensions(imageFiles[idx]);
  -		imageDataArray.push({ idx, ...data });
  -	}
  -
  -	let wrappedOffset = 0;
  -
  -	for (const { idx, dataUrl, widthCells, heightCells } of imageDataArray) {
  -		// Calculate position with diagonal offset
  -		let xCell = baseXCell + idx;
  -		let yCell = baseYCell + idx;
  -
  -		// If out of bounds or would overlap with wrapped images, wrap to next diagonal position
  -		const outOfBounds = xCell < 0 || yCell < 0 ||
  -		                    xCell + widthCells > GRID_COLS ||
  -		                    yCell + heightCells > GRID_ROWS;
  -		const overlapsWrapped = xCell < wrappedOffset || yCell < wrappedOffset;
  -
  -		if (outOfBounds || overlapsWrapped) {
  -			xCell = wrappedOffset;
  -			yCell = wrappedOffset;
  -			wrappedOffset++;
  -		}
  -
  -		const imageData = addImage(dataUrl, xCell, yCell, widthCells, heightCells);
  -		imageData.container.style.zIndex = baseZIndex + idx;
  -	}
  -}
  -
  -// Track mouse position for paste placement
  -let lastMouseX = -1;
  -let lastMouseY = -1;
  -document.addEventListener('mousemove', (e) => {
  -	lastMouseX = e.clientX;
  -	lastMouseY = e.clientY;
  -});
  -
  -// Clipboard paste support
  -document.addEventListener('paste', async (e) => {
  -	const items = e.clipboardData?.items;
  -	if (!items) return;
  -
  -	const imageFiles = [];
  -	for (const item of items) {
  -		if (item.type.startsWith('image/')) {
  -			const file = item.getAsFile();
  -			if (file) imageFiles.push(file);
  -		}
  -	}
  -
  -	if (imageFiles.length === 0) {
  -		showPopup('No image found in clipboard');
  -		return;
  -	}
  -
  -	// Use last known mouse position relative to grid, fall back to (0, 0)
  -	const gridRect = grid.getBoundingClientRect();
  -	const mouseXInGrid = lastMouseX - gridRect.left;
  -	const mouseYInGrid = lastMouseY - gridRect.top;
  -	const isOnGrid = mouseXInGrid >= 0 && mouseYInGrid >= 0 &&
  -		mouseXInGrid <= gridRect.width && mouseYInGrid <= gridRect.height;
  -
  -	const dropX = isOnGrid ? mouseXInGrid : 0;
  -	const dropY = isOnGrid ? mouseYInGrid : 0;
  -
  -	await processAndAddImages(imageFiles, dropX, dropY);
  -});
  -
  -// Prevent default drag behavior on entire document
  -document.addEventListener('dragover', (e) => {
  -	e.preventDefault();
  -	e.stopPropagation();
  -});
  -
  -document.addEventListener('drop', async (e) => {
  -	e.preventDefault();
  -	e.stopPropagation();
  -
  -	// Get drop position relative to the grid
  -	const gridRect = grid.getBoundingClientRect();
  -	const dropX = e.clientX - gridRect.left;
  -	const dropY = e.clientY - gridRect.top;
  -
  -	await processAndAddImages(e.dataTransfer.files, dropX, dropY);
  -});
  -
  -function addImage(src, xCell, yCell, widthCells, heightCells) {
  -	const container = document.createElement('div');
  -	container.className = 'image-container';
  -	highestZIndex++;
  -	container.style.zIndex = highestZIndex;
  -
  -	const wrapper = document.createElement('div');
  -	wrapper.className = 'image-wrapper';
  -	const img = document.createElement('img');
  -	img.src = src;
  -	wrapper.appendChild(img);
  -	container.appendChild(wrapper);
  -
  -	// Add resize handles
  -	const handles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
  -	handles.forEach(dir => {
  -		const handle = document.createElement('div');
  -		handle.className = `resize-handle ${dir.length === 1 ? 'edge' : 'corner'} ${dir}`;
  -		handle.dataset.direction = dir;
  -		container.appendChild(handle);
  -	});
  -
  -	// Add dimension labels
  -	const widthLabel = document.createElement('div');
  -	widthLabel.className = 'dimension-label width';
  -	widthLabel.textContent = widthCells;
  -	container.appendChild(widthLabel);
  -
  -	const heightLabel = document.createElement('div');
  -	heightLabel.className = 'dimension-label height';
  -	heightLabel.textContent = heightCells;
  -	container.appendChild(heightLabel);
  -
  -	const imageData = {
  -		container,
  -		xCell,
  -		yCell,
  -		widthCells,
  -		heightCells,
  -		// Image positioning within container (in pixels, relative to center)
  -		panX: 0,
  -		panY: 0,
  -		userScale: 1,  // user zoom level (1-5)
  -		rotation: 0,  // rotation in degrees (0, 90, 180, 270)
  -		// Store natural image dimensions for calculations
  -		naturalWidth: 0,
  -		naturalHeight: 0,
  -		baseScale: 1  // scale needed to cover container
  -	};
  -
  -	// Calculate dimensions and scale once image loads
  -	img.onload = () => {
  -		imageData.naturalWidth = img.naturalWidth;
  -		imageData.naturalHeight = img.naturalHeight;
  -
  -		// Calculate base scale to cover container (mimics object-fit: cover)
  -		const bounds = getPixelPerfectBounds(imageData.xCell, imageData.yCell, imageData.widthCells, imageData.heightCells);
  -		const scaleX = bounds.width / img.naturalWidth;
  -		const scaleY = bounds.height / img.naturalHeight;
  -		imageData.baseScale = Math.max(scaleX, scaleY);
  -
  -		updateImagePosition(imageData);
  -	};
  -	images.push(imageData);
  -
  -	updateImagePosition(imageData);
  -	grid.appendChild(container);
  -
  -	setupImageHandlers(imageData);
  -
  -	return imageData;
  -}
  -
  -function calculatePanBounds(imageData) {
  -	// Container dimensions in the current (possibly swapped) grid orientation
  -	const bounds = getPixelPerfectBounds(imageData.xCell, imageData.yCell, imageData.widthCells, imageData.heightCells);
  -	const containerWidth = bounds.width;
  -	const containerHeight = bounds.height;
  -
  -	if (imageData.naturalWidth === 0 || imageData.naturalHeight === 0) {
  -		return { maxPanX: 0, maxPanY: 0 };
  -	}
  -
  -	// Pan coordinates are in the image's original coordinate system (before scale and rotation)
  -	// So we need to calculate bounds based on the original image dimensions
  -	const isRotated90or270 = imageData.rotation % 180 !== 0;
  -
  -	// For pan bounds, we need to match image dimensions to container dimensions
  -	// in the image's coordinate space (not the screen's coordinate space)
  -	// When rotated 90/270, panX constrains vertical screen movement (maps to container height)
  -	// and panY constrains horizontal screen movement (maps to container width)
  -	const effectiveContainerWidth = isRotated90or270 ? containerHeight : containerWidth;
  -	const effectiveContainerHeight = isRotated90or270 ? containerWidth : containerHeight;
  -
  -	// Pan values are in pre-scale image space, but the CSS transform scales them
  -	// So we need to calculate bounds in pre-scale space
  -	// The image's natural size minus the container size (in pre-scale space) gives us the overhang
  -	const totalScale = imageData.baseScale * imageData.userScale;
  -	const containerWidthInImageSpace = effectiveContainerWidth / totalScale;
  -	const containerHeightInImageSpace = effectiveContainerHeight / totalScale;
  -
  -	// Calculate maximum pan in each direction (in pre-scale image space)
  -	const maxPanX = Math.max(0, (imageData.naturalWidth - containerWidthInImageSpace) / 2);
  -	const maxPanY = Math.max(0, (imageData.naturalHeight - containerHeightInImageSpace) / 2);
  -
  -	return { maxPanX, maxPanY };
  -}
  -
  -function clampPan(imageData) {
  -	const { maxPanX, maxPanY } = calculatePanBounds(imageData);
  -	imageData.panX = Math.max(-maxPanX, Math.min(maxPanX, imageData.panX));
  -	imageData.panY = Math.max(-maxPanY, Math.min(maxPanY, imageData.panY));
  -}
  -
  -// Helper to transform screen-space coordinates to image coordinate space (accounting for rotation)
  -function rotatePoint(x, y, angleDegrees) {
  -	const angle = -angleDegrees * Math.PI / 180;
  -	const cos = Math.cos(angle);
  -	const sin = Math.sin(angle);
  -	return {
  -		x: x * cos - y * sin,
  -		y: x * sin + y * cos
  -	};
  -}
  -
  -// Helper to apply pan adjustment based on screen delta
  -function applyPanDelta(imageData, screenDeltaX, screenDeltaY) {
  -	const rotated = rotatePoint(screenDeltaX, screenDeltaY, imageData.rotation);
  -	const totalScale = imageData.baseScale * imageData.userScale;
  -	imageData.panX += rotated.x / totalScale;
  -	imageData.panY += rotated.y / totalScale;
  -}
  -
  -// Helper to calculate zoom-centered pan adjustment
  -function adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale) {
  -	const rotated = rotatePoint(cursorX, cursorY, imageData.rotation);
  -	const scaleDiff = 1/newTotalScale - 1/oldTotalScale;
  -	imageData.panX += rotated.x * scaleDiff;
  -	imageData.panY += rotated.y * scaleDiff;
  -}
  -
  -function updateImagePosition(img) {
  -	// Use pixel-perfect bounds to prevent subpixel accumulation
  -	const bounds = getPixelPerfectBounds(img.xCell, img.yCell, img.widthCells, img.heightCells);
  -	img.container.style.left = bounds.left + 'px';
  -	img.container.style.top = bounds.top + 'px';
  -	img.container.style.width = bounds.width + 'px';
  -	img.container.style.height = bounds.height + 'px';
  -
  -	// Store cell positions as CSS variables for print styles
  -	img.container.style.setProperty('--x-cell', img.xCell);
  -	img.container.style.setProperty('--y-cell', img.yCell);
  -	img.container.style.setProperty('--width-cells', img.widthCells);
  -	img.container.style.setProperty('--height-cells', img.heightCells);
  -
  -	// Update dimension labels
  -	const widthLabel = img.container.querySelector('.dimension-label.width');
  -	const heightLabel = img.container.querySelector('.dimension-label.height');
  -	if (widthLabel) {
  -		// Only update text if dimension is >= 5
  -		if (img.widthCells >= 5) {
  -			widthLabel.textContent = img.widthCells;
  -		}
  -		widthLabel.dataset.hidden = img.widthCells < 5 ? 'true' : 'false';
  -	}
  -	if (heightLabel) {
  -		// Only update text if dimension is >= 5
  -		if (img.heightCells >= 5) {
  -			heightLabel.textContent = img.heightCells;
  -		}
  -		heightLabel.dataset.hidden = img.heightCells < 5 ? 'true' : 'false';
  -	}
  -
  -	// Recalculate baseScale if container size changed
  -	if (img.naturalWidth > 0 && img.naturalHeight > 0) {
  -		// Use pixel-perfect bounds for container dimensions
  -		const containerWidth = bounds.width;
  -		const containerHeight = bounds.height;
  -
  -		// When rotated 90° or 270°, the image dimensions are effectively swapped
  -		const isRotated90or270 = img.rotation % 180 !== 0;
  -		const effectiveWidth = isRotated90or270 ? img.naturalHeight : img.naturalWidth;
  -		const effectiveHeight = isRotated90or270 ? img.naturalWidth : img.naturalHeight;
  -
  -		const scaleX = containerWidth / effectiveWidth;
  -		const scaleY = containerHeight / effectiveHeight;
  -		img.baseScale = Math.max(scaleX, scaleY);
  -
  -		// Reclamp pan after recalculating base scale
  -		clampPan(img);
  -	}
  -
  -	// Apply image positioning and scale using transform
  -	const imgElement = img.container.querySelector('img');
  -	if (imgElement) {
  -		const totalScale = img.baseScale * img.userScale;
  -		// Transform: translate from center (-50%, -50%), scale, rotate, then pan
  -		// Pan is applied after rotation so it stays relative to the image's rotated state
  -		imgElement.style.transform = `translate(-50%, -50%) scale(${totalScale}) rotate(${img.rotation}deg) translate(${img.panX}px, ${img.panY}px)`;
  -	}
  -}
  -
  -function bringToFront(container) {
  -	highestZIndex++;
  -	container.style.zIndex = highestZIndex;
  -}
  -
  -function setupImageHandlers(imageData) {
  -	const container = imageData.container;
  -
  -	// Bring to front on hover
  -	container.addEventListener('mouseenter', () => {
  -		bringToFront(container);
  -	});
  -
  -	// Unified pointer start handler
  -	function handlePointerStart(clientX, clientY, isTouch = false, touchIdentifier = null) {
  -		// Clear any existing timers from other images
  -		clearDragState();
  -
  -		document.body.style.cursor = 'grabbing';
  -		document.body.classList.add('dragging');
  -
  -		dragState = {
  -			image: imageData,
  -			startX: clientX,
  -			startY: clientY,
  -			startXCell: imageData.xCell,
  -			startYCell: imageData.yCell,
  -			isPanMode: false, // Will be set to true after long press
  -			isTouch: isTouch,
  -			timerId: null, // Store timer ID to ensure we only activate the correct timer
  -			touchIdentifier: touchIdentifier, // Track which touch this drag belongs to
  -			hasMoved: false // Track if any movement has occurred
  -		};
  -
  -		container.classList.add('dragging');
  -
  -		// For touch, set up long press timer to enable pan mode
  -		if (isTouch) {
  -			const timerId = setTimeout(() => {
  -				// Only activate pan mode if:
  -				// 1. This drag state is still active
  -				// 2. This drag state is for this specific image
  -				// 3. The image hasn't moved to a new cell
  -				// 4. This is the timer that was created for this drag state
  -				if (dragState &&
  -				    dragState.image === imageData &&
  -				    dragState.startXCell === imageData.xCell &&
  -				    dragState.startYCell === imageData.yCell &&
  -				    dragState.timerId === timerId) {
  -					// User held for 0.5 seconds without moving to a new cell - enable pan mode
  -					dragState.isPanMode = true;
  -					dragState.initialPanX = imageData.panX;
  -					dragState.initialPanY = imageData.panY;
  -					// Add visual feedback class
  -					container.classList.add('pan-mode');
  -				}
  -			}, 500);
  -			dragState.timerId = timerId;
  -			longPressTimer = timerId;
  -		}
  -	}
  -
  -	// Touch event handlers for mobile
  -	container.addEventListener('touchstart', (e) => {
  -		if (e.target.classList.contains('resize-handle')) return;
  -
  -		// Bring to front on touch
  -		bringToFront(container);
  -
  -		if (e.touches.length === 1) {
  -			// Single touch - start drag (or long press for pan)
  -			e.preventDefault();
  -			const touch = e.touches[0];
  -			handlePointerStart(touch.clientX, touch.clientY, true, touch.identifier);
  -		} else if (e.touches.length === 2) {
  -			// Two fingers - prepare for pinch/pan
  -			e.preventDefault();
  -
  -			// Cancel any ongoing drag
  -			clearDragState();
  -
  -			const touch1 = e.touches[0];
  -			const touch2 = e.touches[1];
  -
  -			// Calculate initial distance for pinch detection
  -			const dx = touch2.clientX - touch1.clientX;
  -			const dy = touch2.clientY - touch1.clientY;
  -			const distance = Math.sqrt(dx * dx + dy * dy);
  -
  -			// Calculate center point
  -			const centerX = (touch1.clientX + touch2.clientX) / 2;
  -			const centerY = (touch1.clientY + touch2.clientY) / 2;
  -
  -			touchState = {
  -				image: imageData,
  -				initialDistance: distance,
  -				lastDistance: distance,
  -				initialScale: imageData.userScale,
  -				lastCenterX: centerX,
  -				lastCenterY: centerY,
  -				lastPanX: imageData.panX,
  -				lastPanY: imageData.panY
  -			};
  -		}
  -	}, { passive: false });
  -
  -	container.addEventListener('touchmove', (e) => {
  -		if (e.touches.length === 2 && touchState && touchState.image === imageData) {
  -			// Two finger pinch/pan
  -			e.preventDefault();
  -
  -			const touch1 = e.touches[0];
  -			const touch2 = e.touches[1];
  -
  -			// Calculate current distance
  -			const dx = touch2.clientX - touch1.clientX;
  -			const dy = touch2.clientY - touch1.clientY;
  -			const distance = Math.sqrt(dx * dx + dy * dy);
  -
  -			// Calculate center point
  -			const centerX = (touch1.clientX + touch2.clientX) / 2;
  -			const centerY = (touch1.clientY + touch2.clientY) / 2;
  -
  -			// Detect if this is primarily a pinch or a pan
  -			const distanceChange = Math.abs(distance - touchState.lastDistance);
  -			const centerMoveX = centerX - touchState.lastCenterX;
  -			const centerMoveY = centerY - touchState.lastCenterY;
  -			const centerMovement = Math.sqrt(centerMoveX * centerMoveX + centerMoveY * centerMoveY);
  -
  -			// If distance changed significantly more than center moved, treat as pinch
  -			if (distanceChange > centerMovement * 0.5) {
  -				// Pinch zoom
  -				const rect = container.getBoundingClientRect();
  -				const cursorX = centerX - rect.left - rect.width / 2;
  -				const cursorY = centerY - rect.top - rect.height / 2;
  -
  -				const oldUserScale = imageData.userScale;
  -				const oldTotalScale = imageData.baseScale * oldUserScale;
  -				const scaleFactor = distance / touchState.initialDistance;
  -				const newUserScale = Math.max(1, Math.min(5, touchState.initialScale * scaleFactor));
  -				const newTotalScale = imageData.baseScale * newUserScale;
  -
  -				// Restore previous pan state and apply zoom adjustment
  -				imageData.panX = touchState.lastPanX;
  -				imageData.panY = touchState.lastPanY;
  -				adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale);
  -				imageData.userScale = newUserScale;
  -
  -				clampPan(imageData);
  -				touchState.lastPanX = imageData.panX;
  -				touchState.lastPanY = imageData.panY;
  -			} else {
  -				// Two-finger pan
  -				imageData.panX = touchState.lastPanX;
  -				imageData.panY = touchState.lastPanY;
  -				applyPanDelta(imageData, centerMoveX, centerMoveY);
  -
  -				clampPan(imageData);
  -				touchState.lastPanX = imageData.panX;
  -				touchState.lastPanY = imageData.panY;
  -			}
  -
  -			touchState.lastDistance = distance;
  -			touchState.lastCenterX = centerX;
  -			touchState.lastCenterY = centerY;
  -
  -			updateImagePosition(imageData);
  -		}
  -	}, { passive: false });
  -
  -	container.addEventListener('touchend', () => {
  -		if (touchState && touchState.image === imageData) {
  -			touchState = null;
  -		}
  -	}, { passive: false });
  -
  -	container.addEventListener('touchcancel', () => {
  -		if (touchState && touchState.image === imageData) {
  -			touchState = null;
  -		}
  -	}, { passive: false });
  -
  -	// Moving / Deleting / Duplicating
  -	container.addEventListener('mousedown', (e) => {
  -		if (e.target.classList.contains('resize-handle')) return;
  -
  -		e.preventDefault();
  -
  -		// Shift-click to delete
  -		if (e.shiftKey) {
  -			const index = images.indexOf(imageData);
  -			if (index > -1) {
  -				images.splice(index, 1);
  -			}
  -			container.remove();
  -			return;
  -		}
  -
  -		// Option-click (or Alt-click on Windows/Linux) to rotate
  -		if (e.altKey) {
  -			// Check if rotation would cause dimension swap and if it would fit on grid
  -			const oldRotation = imageData.rotation;
  -			const newRotation = (imageData.rotation + 90) % 360;
  -
  -			// When rotating between portrait and landscape (90° or 270°), dimensions swap
  -			const willSwapDimensions = (oldRotation % 180 === 0 && newRotation % 180 !== 0) ||
  -			                           (oldRotation % 180 !== 0 && newRotation % 180 === 0);
  -
  -			if (willSwapDimensions) {
  -				// Check if swapped dimensions would fit on grid at current position
  -				const newWidthCells = imageData.heightCells;
  -				const newHeightCells = imageData.widthCells;
  -
  -				// Don't allow rotation if it would exceed grid bounds
  -				if (imageData.xCell + newWidthCells > GRID_COLS ||
  -				    imageData.yCell + newHeightCells > GRID_ROWS) {
  -					return; // Silently ignore the rotation
  -				}
  -
  -				// Swap width and height
  -				imageData.widthCells = newWidthCells;
  -				imageData.heightCells = newHeightCells;
  -			}
  -
  -			// Rotate 90 degrees clockwise
  -			imageData.rotation = newRotation;
  -
  -			// Don't rotate pan coordinates - they stay in the image's original coordinate system
  -			// The CSS transform applies rotation before pan, so pan is relative to the rotated image
  -
  -			updateImagePosition(imageData);
  -			return;
  -		}
  -
  -		// Cmd-click (or Ctrl-click on Windows/Linux) to duplicate
  -		if (e.metaKey || e.ctrlKey) {
  -			// Calculate target position (1 cell right and 1 cell down)
  -			let newXCell = imageData.xCell + 1;
  -			let newYCell = imageData.yCell + 1;
  -
  -			// If there's not enough room, fall back to top-left
  -			if (newXCell + imageData.widthCells > GRID_COLS || newYCell + imageData.heightCells > GRID_ROWS) {
  -				newXCell = 0;
  -				newYCell = 0;
  -			}
  -
  -			// Create duplicate with the same image source and dimensions
  -			const imgElement = container.querySelector('img');
  -			const newImageData = addImage(
  -				imgElement.src,
  -				newXCell,
  -				newYCell,
  -				imageData.widthCells,
  -				imageData.heightCells
  -			);
  -
  -			// Copy pan, zoom, and rotation settings from original
  -			// Store the original settings to apply after image loads
  -			const originalPanX = imageData.panX;
  -			const originalPanY = imageData.panY;
  -			const originalUserScale = imageData.userScale;
  -			const originalRotation = imageData.rotation;
  -
  -			// Override the onload to copy settings
  -			const newImg = newImageData.container.querySelector('img');
  -			const originalOnload = newImg.onload;
  -			newImg.onload = () => {
  -				// Run the original onload first
  -				if (originalOnload) originalOnload.call(newImg);
  -
  -				// Then apply the copied settings
  -				newImageData.panX = originalPanX;
  -				newImageData.panY = originalPanY;
  -				newImageData.userScale = originalUserScale;
  -				newImageData.rotation = originalRotation;
  -				updateImagePosition(newImageData);
  -			};
  -
  -			// If image is already loaded (cached), trigger the settings copy
  -			if (newImg.complete && newImageData.naturalWidth > 0) {
  -				newImageData.panX = originalPanX;
  -				newImageData.panY = originalPanY;
  -				newImageData.userScale = originalUserScale;
  -				newImageData.rotation = originalRotation;
  -				updateImagePosition(newImageData);
  -			}
  -
  -			return;
  -		}
  -
  -		handlePointerStart(e.clientX, e.clientY);
  -	});
  -
  -	// Resizing - unified handler for mouse and touch
  -	function startResize(clientX, clientY, direction, cursorStyle = null) {
  -		if (cursorStyle) {
  -			document.body.style.cursor = cursorStyle;
  -		}
  -		document.body.classList.add('resizing');
  -
  -		resizeState = {
  -			image: imageData,
  -			direction: direction,
  -			startX: clientX,
  -			startY: clientY,
  -			startXCell: imageData.xCell,
  -			startYCell: imageData.yCell,
  -			startWidthCells: imageData.widthCells,
  -			startHeightCells: imageData.heightCells
  -		};
  -		container.classList.add('resizing');
  -	}
  -
  -	container.querySelectorAll('.resize-handle').forEach(handle => {
  -		handle.addEventListener('mousedown', (e) => {
  -			e.preventDefault();
  -			e.stopPropagation();
  -			const cursorStyle = window.getComputedStyle(handle).cursor;
  -			startResize(e.clientX, e.clientY, handle.dataset.direction, cursorStyle);
  -		});
  -
  -		handle.addEventListener('touchstart', (e) => {
  -			e.preventDefault();
  -			e.stopPropagation();
  -			const touch = e.touches[0];
  -			startResize(touch.clientX, touch.clientY, handle.dataset.direction);
  -		}, { passive: false });
  -	});
  -
  -	// Pan and Zoom with wheel events (macOS trackpad gestures)
  -	container.addEventListener('wheel', (e) => {
  -		e.preventDefault();
  -		e.stopPropagation();
  -
  -		// Don't interfere with dragging or resizing
  -		if (dragState || resizeState) return;
  -
  -		// Detect pinch zoom (ctrlKey is set for pinch gestures on macOS trackpad)
  -		if (e.ctrlKey) {
  -			// Zoom at cursor position
  -			const rect = container.getBoundingClientRect();
  -			const cursorX = e.clientX - rect.left - rect.width / 2;
  -			const cursorY = e.clientY - rect.top - rect.height / 2;
  -
  -			const oldUserScale = imageData.userScale;
  -			const oldTotalScale = imageData.baseScale * oldUserScale;
  -			const zoomDelta = -e.deltaY * 0.01;
  -			const newUserScale = Math.max(1, Math.min(5, oldUserScale * (1 + zoomDelta)));
  -			const newTotalScale = imageData.baseScale * newUserScale;
  -
  -			adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale);
  -			imageData.userScale = newUserScale;
  -
  -			clampPan(imageData);
  -		} else {
  -			// Pan (two-finger scroll on macOS trackpad)
  -			applyPanDelta(imageData, -e.deltaX, -e.deltaY);
  -			clampPan(imageData);
  -		}
  -
  -		updateImagePosition(imageData);
  -	}, { passive: false });
  -}
  -
  -// Helper to clear drag state and timers
  -function clearDragState() {
  -	if (longPressTimer) {
  -		clearTimeout(longPressTimer);
  -		longPressTimer = null;
  -	}
  -	if (dragState) {
  -		dragState.image.container.classList.remove('dragging');
  -		dragState.image.container.classList.remove('pan-mode');
  -		dragState = null;
  -		document.body.style.cursor = '';
  -		document.body.classList.remove('dragging');
  -	}
  -}
  -
  -function handleMove(clientX, clientY) {
  -	if (dragState) {
  -		const dx = clientX - dragState.startX;
  -		const dy = clientY - dragState.startY;
  -
  -		// Safari on iOS can send the first touchmove with stale coordinates when zoomed
  -		// Validate that the first move is reasonable by checking if it would move more than 1 cell
  -		if (dragState.isTouch && !dragState.hasMoved) {
  -			const cellSize = getCellSize();
  -			const dxCells = Math.abs(Math.round(dx / cellSize.width));
  -			const dyCells = Math.abs(Math.round(dy / cellSize.height));
  -
  -			// If the first move would jump more than 1 cell in either direction,
  -			// it's likely stale coordinates from a previous tap - reset start position
  -			if (dxCells > 1 || dyCells > 1) {
  -				dragState.startX = clientX;
  -				dragState.startY = clientY;
  -				dragState.hasMoved = true;
  -				return; // Don't process this move event
  -			}
  -			dragState.hasMoved = true;
  -		}
  -
  -		if (dragState.isPanMode) {
  -			// Pan mode - move the image within its container
  -			const imageData = dragState.image;
  -			imageData.panX = dragState.initialPanX;
  -			imageData.panY = dragState.initialPanY;
  -			applyPanDelta(imageData, dx, dy);
  -			clampPan(imageData);
  -			updateImagePosition(imageData);
  -		} else {
  -			// Normal drag mode - move the image container on the grid
  -			const cellSize = getCellSize();
  -			const dxCells = Math.round(dx / cellSize.width);
  -			const dyCells = Math.round(dy / cellSize.height);
  -
  -			const newXCell = Math.max(0, Math.min(GRID_COLS - dragState.image.widthCells, dragState.startXCell + dxCells));
  -			const newYCell = Math.max(0, Math.min(GRID_ROWS - dragState.image.heightCells, dragState.startYCell + dyCells));
  -
  -			// If the image moved to a new cell, cancel the long press timer
  -			if (dragState.isTouch && longPressTimer &&
  -			    (newXCell !== dragState.startXCell || newYCell !== dragState.startYCell)) {
  -				clearTimeout(longPressTimer);
  -				longPressTimer = null;
  -			}
  -
  -			dragState.image.xCell = newXCell;
  -			dragState.image.yCell = newYCell;
  -			updateImagePosition(dragState.image);
  -		}
  -	}
  -
  -	if (resizeState) {
  -		const dx = clientX - resizeState.startX;
  -		const dy = clientY - resizeState.startY;
  -
  -		const cellSize = getCellSize();
  -		const dxCells = Math.round(dx / cellSize.width);
  -		const dyCells = Math.round(dy / cellSize.height);
  -
  -		const dir = resizeState.direction;
  -		const img = resizeState.image;
  -
  -		let newX = img.xCell;
  -		let newY = img.yCell;
  -		let newW = img.widthCells;
  -		let newH = img.heightCells;
  -
  -		if (dir.includes('e')) {
  -			const proposedW = Math.max(1, resizeState.startWidthCells + dxCells);
  -			// Clamp to grid boundary
  -			newW = Math.min(proposedW, GRID_COLS - resizeState.startXCell);
  -		}
  -		if (dir.includes('w')) {
  -			const delta = Math.min(dxCells, resizeState.startWidthCells - 1);
  -			const proposedX = resizeState.startXCell + delta;
  -			// Clamp to grid boundary
  -			const clampedX = Math.max(0, proposedX);
  -			newX = clampedX;
  -			newW = resizeState.startWidthCells - (clampedX - resizeState.startXCell);
  -		}
  -		if (dir.includes('s')) {
  -			const proposedH = Math.max(1, resizeState.startHeightCells + dyCells);
  -			// Clamp to grid boundary
  -			newH = Math.min(proposedH, GRID_ROWS - resizeState.startYCell);
  -		}
  -		if (dir.includes('n')) {
  -			const delta = Math.min(dyCells, resizeState.startHeightCells - 1);
  -			const proposedY = resizeState.startYCell + delta;
  -			// Clamp to grid boundary
  -			const clampedY = Math.max(0, proposedY);
  -			newY = clampedY;
  -			newH = resizeState.startHeightCells - (clampedY - resizeState.startYCell);
  -		}
  -
  -		img.xCell = newX;
  -		img.yCell = newY;
  -		img.widthCells = newW;
  -		img.heightCells = newH;
  -		updateImagePosition(img);
  -	}
  -}
  -
  -function handleEnd() {
  -	clearDragState();
  -	if (resizeState) {
  -		resizeState.image.container.classList.remove('resizing');
  -		resizeState = null;
  -		document.body.style.cursor = '';
  -		document.body.classList.remove('resizing');
  -	}
  -}
  -
  -document.addEventListener('mousemove', (e) => {
  -	handleMove(e.clientX, e.clientY);
  -});
  -
  -document.addEventListener('mouseup', () => {
  -	handleEnd();
  -});
  -
  -document.addEventListener('touchmove', (e) => {
  -	// Only handle global drag/resize, not image-specific multi-touch
  -	if ((dragState || resizeState) && e.touches.length === 1) {
  -		const touch = e.touches[0];
  -
  -		// For drag operations, verify this touch matches the one that started the drag
  -		if (dragState && dragState.touchIdentifier !== null &&
  -		    touch.identifier !== dragState.touchIdentifier) {
  -			// This is a different touch - ignore it
  -			return;
  -		}
  -
  -		e.preventDefault();
  -		handleMove(touch.clientX, touch.clientY);
  -	}
  -}, { passive: false });
  -
  -document.addEventListener('touchend', (e) => {
  -	// If we have an active drag state, only end it if the touch that's ending
  -	// matches the touch that started the drag
  -	if (dragState && dragState.touchIdentifier !== null && e.changedTouches.length > 0) {
  -		let matchingTouchEnded = false;
  -		for (let i = 0; i < e.changedTouches.length; i++) {
  -			if (e.changedTouches[i].identifier === dragState.touchIdentifier) {
  -				matchingTouchEnded = true;
  -				break;
  -			}
  -		}
  -		// Only end the drag if the matching touch ended
  -		if (!matchingTouchEnded) {
  -			return;
  -		}
  -	}
  -
  -	handleEnd();
  -
  -	// Clear any lingering timers even if there's no active drag state
  -	if (longPressTimer) {
  -		clearTimeout(longPressTimer);
  -		longPressTimer = null;
  -	}
  -});
  -
  -document.addEventListener('touchcancel', () => {
  -	handleEnd();
  -
  -	if (longPressTimer) {
  -		clearTimeout(longPressTimer);
  -		longPressTimer = null;
  -	}
  -});
  -
  -// Intercept wheel events at the document level to prevent page scroll/zoom when the cursor is over an image container.
  -document.addEventListener('wheel', (e) => {
  -	const hoveredImage = images.find(img => img.container.contains(e.target) || img.container === e.target);
  -	if (hoveredImage) {
  -		e.preventDefault();
  -	}
  -}, { passive: false });
  -
  -// Update all image positions when window resizes (for responsive scaling)
  -window.addEventListener('resize', () => {
  -	applyPageWidth(); // display or zoom may have changed
  -	images.forEach(img => {
  -		updateImagePosition(img);
  -	});
  -});
  -
  -// Adjust image scales for print and restore after
  -const PRINT_CELL_SIZE_PX = CELL_SIZE_MM * 96 / 25.4; // Cell size in pixels at 96 DPI
  -
  -window.addEventListener('beforeprint', () => {
  -	images.forEach(img => {
  -		const imgElement = img.container.querySelector('img');
  -		if (!imgElement || !img.naturalWidth || !img.naturalHeight) return;
  -
  -		// Calculate print container size
  -		const printWidth = img.widthCells * PRINT_CELL_SIZE_PX;
  -		const printHeight = img.heightCells * PRINT_CELL_SIZE_PX;
  -
  -		// Recalculate base scale for print
  -		const isRotated = img.rotation % 180 !== 0;
  -		const effectiveW = isRotated ? img.naturalHeight : img.naturalWidth;
  -		const effectiveH = isRotated ? img.naturalWidth : img.naturalHeight;
  -		const printBaseScale = Math.max(printWidth / effectiveW, printHeight / effectiveH);
  -
  -		// Apply print transform
  -		const printScale = printBaseScale * img.userScale;
  -		imgElement.style.transform = `translate(-50%, -50%) scale(${printScale}) rotate(${img.rotation}deg) translate(${img.panX}px, ${img.panY}px)`;
  -	});
  -});
  -
  -window.addEventListener('afterprint', () => {
  -	// Wait for layout to settle after exiting print mode
  -	requestAnimationFrame(() => {
  -		requestAnimationFrame(() => {
  -			images.forEach(img => updateImagePosition(img));
  -		});
  -	});
  -});
  -
  -// Theme system
  -let currentThemeIndex = 0;
  -let isF2Pressed = false;
  -const themes = ['sea-breeze', 'grape-soda', 'grapefruit', 'guac', 'mojito', 'banana'];
  -
  -function setTheme(theme) {
  -	document.documentElement.setAttribute('data-theme', theme);
  -	const backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--desk').trim();
  -	document.querySelector('meta[name="theme-color"]').setAttribute('content', backgroundColor);
  -}
  -
  -function cycleTheme() {
  -	currentThemeIndex = (currentThemeIndex + 1) % themes.length;
  -	const newTheme = themes[currentThemeIndex];
  -	setTheme(newTheme);
  -	saveThemeToLocalStorage(newTheme);
  -}
  -
  -function saveThemeToLocalStorage(theme) {
  -	localStorage.setItem('memori-theme', theme);
  -}
  -
  -function loadThemeFromLocalStorage() {
  -	const savedTheme = localStorage.getItem('memori-theme');
  -	if (savedTheme && themes.includes(savedTheme)) {
  -		currentThemeIndex = themes.indexOf(savedTheme);
  -		setTheme(savedTheme);
  -	} else {
  -		// Use default theme
  -		setTheme(themes[0]);
  -	}
  -}
  -
  -// F2 key handler for theme cycling
  -document.addEventListener('keydown', (e) => {
  -	if (e.key === 'F2' && !isF2Pressed) {
  -		e.preventDefault();
  -		isF2Pressed = true;
  -		cycleTheme();
  -	}
  -});
  -
  -document.addEventListener('keyup', (e) => {
  -	if (e.key === 'F2') {
  -		isF2Pressed = false;
  -	}
  -});
  -
  -// Load theme on page load
  -loadThemeFromLocalStorage();
  -
  -// Warn before leaving page if images are present
  -window.addEventListener('beforeunload', (e) => {
  -	if (images.length > 0) {
  -		e.preventDefault();
  -		e.returnValue = '';
  -		return '';
  -	}
  -});
  -
  -// Mobile file input handling
  -const fileInput = document.getElementById('fileInput');
  -const addImagesBtn = document.getElementById('addImagesBtn');
  -
  -addImagesBtn.addEventListener('click', () => {
  -	fileInput.click();
  -});
  -
  -fileInput.addEventListener('change', async (e) => {
  -	await processAndAddImages(e.target.files, 0, 0);
  -
  -	// Clear the input so the same files can be selected again
  -	fileInput.value = '';
  -});
  -
  -function showPopup(message, displayDuration = 1250) {
  -	if (!popupElement) {
  -		popupElement = document.createElement('div');
  -		popupElement.className = 'popup-notification';
  -		document.body.appendChild(popupElement);
  -	}
  -
  -	if (popupTimeout) clearTimeout(popupTimeout);
  -
  -	popupElement.textContent = message;
  -	popupElement.classList.remove('fade-out');
  -	popupElement.classList.add('show');
  -
  -	popupTimeout = setTimeout(() => {
  -		popupElement.classList.remove('show');
  -		popupElement.classList.add('fade-out');
  -	}, displayDuration);
  -}
style.css
  -/* Named color palette and default theme */
  -:root {
  -	/* Default colors */
  -	--white: white;
  -	--black: black;
  -	--default-grid: #d0d0d0;
  -	--shadow: #00000044;
  -
  -	/* guac theme colors */
  -	--onion: #a81d8f;
  -	--garlic: #fefef4;
  -	--lime: #a9cf51;
  -	--avocado: #7a8520;
  -
  -	/* banana theme colors */
  -	--peel: #ffcc12;
  -	--flesh: #f8f5e3;
  -	--unripe: #a5d269;
  -	--bruise: #4d4235;
  -
  -	/* mojito theme colors */
  -	--mint: #9febaa;
  -	--soda: #f8fffe;
  -	--zest: #9ee3ad;
  -	--watermelon: #f8599b;
  -
  -	/* grape-soda theme colors */
  -	--natural-flavors: #d4c5f9;
  -	--effervescence: #f9f7ff;
  -	--concentrate: #b8a9dd;
  -	--syrup: #7c5cbe;
  -
  -	/* grapefruit theme colors */
  -	--rind: #ff9b87;
  -	--pith: #fff8f6;
  -	--pulp: #f0bcb3;
  -	--ruby: #ff6b51;
  -
  -	/* sea-breeze theme colors */
  -	--cerulean: #a3d5ff;
  -	--mist: #f7fcff;
  -	--horizon: #85c0f5;
  -	--lagoon: #2196f3;
  -
  -	--grid-cols: 49;
  -	--grid-rows: 66;
  -	--cell-size-mm: 4mm;
  -
  -	/* Screen view: Grid as percentage of page (centered on US Letter simulation) */
  -	/* Grid width as percent of Letter: 196/215.9 = 90.78% */
  -	/* Grid height as percent of Letter: 264/279.4 = 94.48% */
  -	--grid-width-percent: 90.78%;
  -	--grid-height-percent: 94.48%;
  -
  -	/* Default theme (sea-breeze) */
  -	--desk: var(--cerulean);
  -	--page: var(--mist);
  -	--grid-line: var(--horizon);
  -	--grid-line-light: color-mix(in srgb, var(--grid-line) 65%, transparent);
  -	--accent: var(--lagoon);
  -
  -	/* Set text color for all themes */
  -	color: var(--white);
  -
  -	/* Set font */
  -	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  -}
  -
  -/* Theme definitions */
  -:root[data-theme="guac"] {
  -	--desk: var(--avocado);
  -	--page: var(--garlic);
  -	--grid-line: var(--lime);
  -	--accent: var(--onion);
  -}
  -
  -:root[data-theme="banana"] {
  -	--desk: var(--peel);
  -	--page: var(--flesh);
  -	--grid-line: var(--unripe);
  -	--accent: var(--bruise);
  -}
  -
  -:root[data-theme="mojito"] {
  -	--desk: var(--mint);
  -	--page: var(--soda);
  -	--grid-line: var(--zest);
  -	--accent: var(--watermelon);
  -}
  -
  -:root[data-theme="grape-soda"] {
  -	--desk: var(--natural-flavors);
  -	--page: var(--effervescence);
  -	--grid-line: var(--concentrate);
  -	--accent: var(--syrup);
  -}
  -
  -:root[data-theme="grapefruit"] {
  -	--desk: var(--rind);
  -	--page: var(--pith);
  -	--grid-line: var(--pulp);
  -	--accent: var(--ruby);
  -}
  -
  -:root[data-theme="sea-breeze"] {
  -	--desk: var(--cerulean);
  -	--page: var(--mist);
  -	--grid-line: var(--horizon);
  -	--accent: var(--lagoon);
  -}
  -
  -* {
  -	margin: 0;
  -	padding: 0;
  -	box-sizing: border-box;
  -	-webkit-user-select: none;
  -	user-select: none;
  -	scrollbar-color: var(--page) var(--desk);
  -}
  -
  -body {
  -	background: var(--desk);
  -	display: flex;
  -	justify-content: center;
  -	align-items: flex-start;
  -	align-items: safe center;
  -	min-height: 100vh;
  -	padding: 20px;
  -}
  -
  -.page {
  -	width: min(100%, var(--page-width, 8.5in));
  -	aspect-ratio: 8.5 / 11;
  -	background: var(--page);
  -	position: relative;
  -	box-shadow: 0 8px 24px rgba(0,0,0,0.15), 0 2px 8px rgba(0,0,0,0.08);
  -	display: flex;
  -	align-items: center;
  -	justify-content: center;
  -}
  -
  -.grid {
  -	/* Grid dimensions as percentage of page to scale responsively */
  -	width: var(--grid-width-percent);
  -	height: var(--grid-height-percent);
  -	display: grid;
  -	grid-template-columns: repeat(var(--grid-cols), 1fr);
  -	grid-template-rows: repeat(var(--grid-rows), 1fr);
  -	gap: 0;
  -	line-height: 0;
  -	position: relative; /* Image containers are positioned relative to grid */
  -	/* Grid is centered within .page by the parent's flexbox */
  -	box-sizing: border-box;
  -}
  -
  -.grid-cell {
  -	width: 100%;
  -	height: 100%;
  -	box-sizing: border-box;
  -	border-top: var(--line-width, 1px) dashed var(--grid-line);
  -	border-left: var(--line-width, 1px) dashed var(--grid-line);
  -	margin: 0;
  -	padding: 0;
  -	display: block;
  -}
  -
  -/* Add right border to rightmost column */
  -.grid-cell.right-edge {
  -	border-right: var(--line-width, 1px) dashed var(--grid-line);
  -	width: calc(100% + var(--line-width, 1px));
  -}
  -
  -/* Add bottom border to bottom row */
  -.grid-cell.bottom-edge {
  -	border-bottom: var(--line-width, 1px) dashed var(--grid-line);
  -	height: calc(100% + var(--line-width, 1px));
  -}
  -
  -/* Solid grid lines for cell sizes <= 3.56mm (screen preview only) */
  -.solid-grid .grid-cell {
  -	border-top-style: solid;
  -	border-left-style: solid;
  -	border-top-color: var(--grid-line-light);
  -	border-left-color: var(--grid-line-light);
  -}
  -
  -.solid-grid .grid-cell.right-edge {
  -	border-right-style: solid;
  -	border-right-color: var(--grid-line-light);
  -}
  -
  -.solid-grid .grid-cell.bottom-edge {
  -	border-bottom-style: solid;
  -	border-bottom-color: var(--grid-line-light);
  -}
  -
  -@media (max-width: 680px) {
  -	body {
  -		padding: 0;
  -	}
  -
  -	.page {
  -		box-shadow: none;
  -	}
  -
  -	.grid-cell {
  -		border-top-style: solid;
  -		border-left-style: solid;
  -		border-top-color: var(--grid-line-light);
  -		border-left-color: var(--grid-line-light);
  -	}
  -
  -	.grid-cell.right-edge {
  -		border-right-style: solid;
  -		border-right-color: var(--grid-line-light);
  -		width: calc(100% + var(--line-width, 1px));
  -	}
  -
  -	.grid-cell.bottom-edge {
  -		border-bottom-style: solid;
  -		border-bottom-color: var(--grid-line-light);
  -		height: calc(100% + var(--line-width, 1px));
  -	}
  -}
  -
  -.image-container {
  -	position: absolute;
  -	cursor: grab;
  -	outline: 2px solid transparent;
  -	outline-offset: -2px;
  -	transition: outline-color 0.2s;
  -	background: var(--page);
  -	touch-action: none;
  -}
  -
  -.image-container::after {
  -	content: '';
  -	position: absolute;
  -	top: 0;
  -	left: 0;
  -	right: 0;
  -	bottom: 0;
  -	pointer-events: none;
  -	border: 2px solid transparent;
  -	transition: border-color 0.2s;
  -	z-index: 1;
  -	/* overflow: hidden; */
  -}
  -
  -.image-container .image-wrapper {
  -	position: absolute;
  -	top: 0;
  -	left: 0;
  -	right: 0;
  -	bottom: 0;
  -	overflow: hidden;
  -}
  -
  -.image-container:hover::after {
  -	border-color: var(--accent);
  -}
  -
  -.image-container.dragging {
  -	opacity: 0.7;
  -}
  -
  -.image-container.dragging::after {
  -	border-color: var(--accent);
  -}
  -
  -/* Visual feedback for pan mode on touch */
  -.image-container.pan-mode::after {
  -	border-color: var(--accent);
  -	border-width: 3px;
  -	border-style: solid;
  -	animation: pulse-border 0.25s ease-out;
  -}
  -
  -@keyframes pulse-border {
  -	0% {
  -		border-width: 2px;
  -		opacity: 0.5;
  -	}
  -	50% {
  -		border-width: 5px;
  -		opacity: 1;
  -	}
  -	100% {
  -		border-width: 3px;
  -		opacity: 1;
  -	}
  -}
  -
  -/* Hide dimension labels and resize handles in pan mode */
  -.image-container.pan-mode .dimension-label {
  -	opacity: 0 !important;
  -}
  -
  -.image-container.pan-mode .resize-handle {
  -	opacity: 0 !important;
  -}
  -
  -.image-container.resizing::after {
  -	border-color: var(--accent);
  -}
  -
  -body.resizing *,
  -body.dragging * {
  -	cursor: inherit !important;
  -}
  -
  -.image-container img {
  -	position: absolute;
  -	top: 50%;
  -	left: 50%;
  -	display: block;
  -	pointer-events: none;
  -	transform-origin: center center;
  -	transition: none;
  -}
  -
  -.resize-handle {
  -	position: absolute;
  -	background: var(--accent);
  -	opacity: 0;
  -	transition: opacity 0.2s;
  -	z-index: 2;
  -}
  -
  -.image-container:hover .resize-handle,
  -.image-container.resizing .resize-handle {
  -	opacity: 1;
  -}
  -
  -.dimension-label {
  -	position: absolute;
  -	background: var(--accent);
  -	color: var(--text);
  -	padding: 4px 10px;
  -	border-radius: 12px;
  -	font-size: 16px;
  -	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  -	font-weight: 500;
  -	line-height: 1;
  -	opacity: 0;
  -	transition: opacity 0.2s;
  -	pointer-events: none;
  -	z-index: 9999;
  -	white-space: nowrap;
  -}
  -
  -.dimension-label.width {
  -	top: 1px;
  -	left: 50%;
  -	transform: translate(-50%, -50%);
  -}
  -
  -.dimension-label.height {
  -	right: 1px;
  -	top: 50%;
  -	transform: translate(50%, -50%) rotate(90deg);
  -}
  -
  -.image-container:hover .dimension-label,
  -.image-container.dragging .dimension-label,
  -.image-container.resizing .dimension-label {
  -	opacity: 1;
  -}
  -
  -.dimension-label[data-hidden="true"] {
  -	opacity: 0 !important;
  -}
  -
  -.resize-handle.corner {
  -	width: 10px;
  -	height: 10px;
  -	background: transparent;
  -}
  -
  -.resize-handle.corner::before {
  -	content: '';
  -	position: absolute;
  -	width: 10px;
  -	height: 10px;
  -	background: var(--accent);
  -	border-radius: 50%;
  -}
  -
  -.resize-handle.edge {
  -	background: transparent;
  -}
  -
  -/* Desktop: Original 10px hitboxes */
  -.resize-handle.n { top: -5px; left: 5px; right: 5px; height: 10px; cursor: n-resize; }
  -.resize-handle.s { bottom: -5px; left: 5px; right: 5px; height: 10px; cursor: s-resize; }
  -.resize-handle.e { right: -5px; top: 5px; bottom: 5px; width: 10px; cursor: e-resize; }
  -.resize-handle.w { left: -5px; top: 5px; bottom: 5px; width: 10px; cursor: w-resize; }
  -
  -.resize-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
  -.resize-handle.ne::before { top: 0; right: 0; }
  -
  -.resize-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
  -.resize-handle.nw::before { top: 0; left: 0; }
  -
  -.resize-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
  -.resize-handle.se::before { bottom: 0; right: 0; }
  -
  -.resize-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
  -.resize-handle.sw::before { bottom: 0; left: 0; }
  -
  -@media (pointer: coarse), (hover: none) {
  -	html {
  -		overscroll-behavior: none;
  -	}
  -
  -	/* Only fix position in portrait mode to prevent scroll issues in landscape */
  -	@media (orientation: portrait) {
  -		html, body {
  -			height: 100%;
  -			position: fixed;
  -			width: 100%;
  -		}
  -	}
  -
  -	.resize-handle.corner {
  -		width: 12px;
  -		height: 12px;
  -	}
  -
  -	.resize-handle.corner::before {
  -		/* Center the visual dot within the larger hitbox */
  -		top: 50%;
  -		left: 50%;
  -		transform: translate(-50%, -50%);
  -	}
  -
  -	/* Edge handles with 12px hitboxes */
  -	.resize-handle.n { top: -6px; height: 12px; }
  -	.resize-handle.s { bottom: -6px; height: 12px; }
  -	.resize-handle.e { right: -6px; width: 12px; }
  -	.resize-handle.w { left: -6px; width: 12px; }
  -
  -	/* Corner handles with 12px hitboxes, centered on the visual corner */
  -	.resize-handle.ne { top: -6px; right: -6px; }
  -	.resize-handle.nw { top: -6px; left: -6px; }
  -	.resize-handle.se { bottom: -6px; right: -6px; }
  -	.resize-handle.sw { bottom: -6px; left: -6px; }
  -}
  -
  -@media print {
  -	* {
  -		-webkit-print-color-adjust: exact !important;
  -		print-color-adjust: exact !important;
  -	}
  -
  -	html, body {
  -		margin: 0 !important;
  -		padding: 0 !important;
  -		width: 100% !important;
  -		height: 100vh !important;
  -		overflow: visible !important;
  -	}
  -
  -	body {
  -		background: var(--white);
  -		display: flex !important;
  -		align-items: center !important;
  -		justify-content: center !important;
  -	}
  -
  -	.page {
  -		width: auto !important;
  -		height: auto !important;
  -		max-width: none !important;
  -		box-shadow: none;
  -		margin: 0;
  -		padding: 0;
  -		background: var(--white);
  -		display: block !important;
  -	}
  -
  -	.grid {
  -		width: calc(var(--grid-cols) * var(--cell-size-mm)) !important;
  -		height: calc(var(--grid-rows) * var(--cell-size-mm)) !important;
  -		display: grid;
  -		grid-template-columns: repeat(var(--grid-cols), 1fr) !important;
  -		grid-template-rows: repeat(var(--grid-rows), 1fr) !important;
  -		position: relative !important;
  -		box-sizing: border-box !important;
  -	}
  -
  -	.grid-cell {
  -		border-top: 1px dashed var(--default-grid) !important;
  -		border-left: 1px dashed var(--default-grid) !important;
  -		box-sizing: border-box !important;
  -		width: 100% !important;
  -		height: 100% !important;
  -		margin: 0 !important;
  -		padding: 0 !important;
  -	}
  -
  -	.grid-cell.right-edge {
  -		border-right: 1px dashed var(--default-grid) !important;
  -		width: calc(100% + 1px) !important;
  -	}
  -
  -	.grid-cell.bottom-edge {
  -		border-bottom: 1px dashed var(--default-grid) !important;
  -		height: calc(100% + 1px) !important;
  -	}
  -
  -	.image-container {
  -		outline: none !important;
  -		position: absolute !important;
  -		background: var(--white) !important;
  -		/* Recalculate positions using cell coordinates and print cell size */
  -		left: calc(var(--x-cell) * var(--cell-size-mm)) !important;
  -		top: calc(var(--y-cell) * var(--cell-size-mm)) !important;
  -		width: calc(var(--width-cells) * var(--cell-size-mm)) !important;
  -		height: calc(var(--height-cells) * var(--cell-size-mm)) !important;
  -	}
  -
  -	.image-container::after {
  -		border: none !important;
  -	}
  -
  -	.image-container .image-wrapper {
  -		position: absolute !important;
  -		top: 0 !important;
  -		left: 0 !important;
  -		right: 0 !important;
  -		bottom: 0 !important;
  -		overflow: hidden !important;
  -	}
  -
  -	.image-container img {
  -		position: absolute !important;
  -		top: 50% !important;
  -		left: 50% !important;
  -		display: block !important;
  -		pointer-events: none !important;
  -		/* Preserve the transform from the screen view */
  -	}
  -
  -	.resize-handle {
  -		display: none !important;
  -	}
  -
  -	.dimension-label {
  -		display: none !important;
  -	}
  -
  -	.add-images-btn {
  -		display: none !important;
  -	}
  -
  -	.popup-notification {
  -		display: none !important;
  -	}
  -}
  -
  -@page {
  -	margin: 0;
  -}
  -
  -.add-images-btn {
  -	display: none;
  -	position: fixed;
  -	bottom: 24px;
  -	left: 50%;
  -	transform: translateX(-50%);
  -	background: var(--accent);
  -	color: var(--page);
  -	border: none;
  -	border-radius: 100px;
  -	padding: 14px 28px;
  -	font-size: 16px;
  -	font-weight: 600;
  -	cursor: pointer;
  -	box-shadow: 0 4px 12px rgba(0,0,0,0.2);
  -	z-index: 10000;
  -	-webkit-tap-highlight-color: transparent;
  -	user-select: none;
  -	-webkit-user-select: none;
  -	transition: transform 0.1s ease, box-shadow 0.1s ease;
  -}
  -
  -.add-images-btn:active {
  -	transform: translateX(-50%) scale(0.95);
  -	box-shadow: 0 2px 8px rgba(0,0,0,0.2);
  -}
  -
  -@media (pointer: coarse), (hover: none) {
  -	.add-images-btn {
  -		display: block;
  -		bottom: 0;
  -		margin-bottom: 24px;
  -	}
  -
  -	/* On touch devices, don't change opacity when dragging */
  -	.image-container.dragging {
  -		opacity: 1;
  -	}
  -}
  -
  -.popup-notification {
  -	position: fixed;
  -	bottom: 2rem;
  -	left: 50%;
  -	transform: translateX(-50%);
  -	background-color: var(--accent);
  -	color: var(--page);
  -	padding: 0.7rem 0.9rem;
  -	border-radius: 50rem;
  -	font-size: 1.2rem;
  -	text-align: center;
  -	white-space: nowrap;
  -	user-select: none;
  -	width: fit-content;
  -	height: fit-content;
  -	opacity: 0;
  -	pointer-events: none;
  -	z-index: 10000;
  -}
  -
  -.popup-notification.show {
  -	opacity: 1;
  -	transition: none;
  -}
  -
  -.popup-notification.fade-out {
  -	opacity: 0;
  -	filter: blur(4px);
  -	transition: opacity 1.5s ease-out, filter 1.5s ease-out;
  -}