commit f9ee38c2f2fbff0aacbae6844fc902eeb59679cd
parent 64f618ff748c68713b5a9f75e3d2beac725ee594
Author: Hunter
Date:   Tue, 28 Jul 2026 19:56:18 -0400

restructure

Diffstat:
Mindex.html | 15++++++++++++---
Dpdf.js | 224-------------------------------------------------------------------------------
Mreadme.md | 16+---------------
Dreadme_images/cut_and_paste.jpg | 0
Dreadme_images/premium_plan.jpg | 0
Aresources/app.js | 33+++++++++++++++++++++++++++++++++
Aresources/display.js | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/grid.js | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/images.js | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/input.js | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/pdf.js | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/pointer.js | 557+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/popup.js | 23+++++++++++++++++++++++
Aresources/print.js | 33+++++++++++++++++++++++++++++++++
Aresources/style.css | 634+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aresources/theme.js | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Dscript.js | 1298-------------------------------------------------------------------------------
Dstyle.css | 635-------------------------------------------------------------------------------
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; -}