commit f9ee38c2f2fbff0aacbae6844fc902eeb59679cd
parent 64f618ff748c68713b5a9f75e3d2beac725ee594
Author: Hunter
Date: Tue, 28 Jul 2026 19:56:18 -0400
restructure
Diffstat:
18 files changed, 2173 insertions(+), 2175 deletions(-)
diff --git a/index.html b/index.html
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#a3d5ff">
<title>memori</title>
<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">
+ <link rel="stylesheet" href="resources/style.css">
</head>
<body>
<div class="page">
@@ -16,7 +16,16 @@
<input type="file" id="fileInput" accept="image/*" multiple style="display: none;">
<button id="addImagesBtn" class="add-images-btn">Add images</button>
- <script src="script.js"></script>
- <script src="pdf.js"></script>
+ <!-- Loaded in dependency order: each file runs setup at load and reads the ones above it -->
+ <script src="resources/popup.js"></script>
+ <script src="resources/grid.js"></script>
+ <script src="resources/display.js"></script>
+ <script src="resources/images.js"></script>
+ <script src="resources/pointer.js"></script>
+ <script src="resources/input.js"></script>
+ <script src="resources/print.js"></script>
+ <script src="resources/theme.js"></script>
+ <script src="resources/app.js"></script>
+ <script src="resources/pdf.js"></script>
</body>
</html>
diff --git a/pdf.js b/pdf.js
@@ -1,224 +0,0 @@
-(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; }
- });
-})();
diff --git a/readme.md b/readme.md
@@ -1,20 +1,6 @@
# 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.
-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.
+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.
## usage
Different Techo types use differently-sized grid paper. Use the links below to select the appropriate grid size for your Techo:
diff --git a/readme_images/cut_and_paste.jpg b/readme_images/cut_and_paste.jpg
Binary files differ.
diff --git a/readme_images/premium_plan.jpg b/readme_images/premium_plan.jpg
Binary files differ.
diff --git a/resources/app.js b/resources/app.js
@@ -0,0 +1,33 @@
+// Wiring that spans the other files
+
+// 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);
+ });
+});
+
+// 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 = '';
+});
diff --git a/resources/display.js b/resources/display.js
@@ -0,0 +1,63 @@
+// Estimate physical display density so we can attempt to render the page at print size
+
+// 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');
+
+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();
diff --git a/resources/grid.js b/resources/grid.js
@@ -0,0 +1,176 @@
+// 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');
+
+// 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();
+
+// 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
+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');
+}
diff --git a/resources/images.js b/resources/images.js
@@ -0,0 +1,307 @@
+// Image state, the add pipeline, and the transforms that place images on the 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;
+ }
+}
+
+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;
+}
diff --git a/resources/input.js b/resources/input.js
@@ -0,0 +1,58 @@
+// Getting images into the app: cursor tracking, clipboard paste, drag and drop
+
+// 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);
+});
diff --git a/resources/pdf.js b/resources/pdf.js
@@ -0,0 +1,226 @@
+// Self-contained PDF writer: vector grid plus embedded photos, for export and Safari printing
+
+(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; }
+ });
+})();
diff --git a/resources/pointer.js b/resources/pointer.js
@@ -0,0 +1,557 @@
+// Per-image mouse, touch, and wheel handling: move, resize, rotate, pan, zoom
+
+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 });
diff --git a/resources/popup.js b/resources/popup.js
@@ -0,0 +1,23 @@
+// Transient notification displayed for grid size changes, paste failures, etc.
+
+let popupElement = null;
+let popupTimeout = null;
+
+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);
+}
diff --git a/resources/print.js b/resources/print.js
@@ -0,0 +1,33 @@
+// Rescale images against the mm-sized print cell on beforeprint, restore screen scale on afterprint
+
+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));
+ });
+ });
+});
diff --git a/resources/style.css b/resources/style.css
@@ -0,0 +1,634 @@
+: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;
+}
diff --git a/resources/theme.js b/resources/theme.js
@@ -0,0 +1,50 @@
+// 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();
diff --git a/script.js b/script.js
@@ -1,1297 +0,0 @@
-// 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);
-}
-\ No newline at end of file
diff --git a/style.css b/style.css
@@ -1,635 +0,0 @@
-/* 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;
-}