restructure Hunter Tuesday, July 28th, 2026 at 7:56 PM
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>
-(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; } - }); -})();
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:
Binary files differ.
Binary files differ.
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 +});
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();
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 +}
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 +}
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 +});
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 +})();
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 });
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 +}
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 +});
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 +}
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();
-// 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); -}
-/* 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; -}