script.js (40.1 KB)


   1 // Paper dimensions in mm
   2 const LETTER_WIDTH_MM = 215.9;  // 8.5 inches
   3 const LETTER_HEIGHT_MM = 279.4; // 11 inches
   4 const A4_WIDTH_MM = 210;
   5 const A4_HEIGHT_MM = 297;
   6 const MARGIN_MM = 6.35; // 0.25 inches
   7 
   8 // Calculate maximum grid dimensions that fit both Letter and A4 with margins
   9 function calculateGridDimensions(cellSize) {
  10 	// Available printable area (limiting factor is the smaller of Letter/A4 for each dimension)
  11 	const availableWidth = Math.min(LETTER_WIDTH_MM, A4_WIDTH_MM) - (2 * MARGIN_MM);
  12 	const availableHeight = Math.min(LETTER_HEIGHT_MM, A4_HEIGHT_MM) - (2 * MARGIN_MM);
  13 
  14 	// Calculate how many cells fit
  15 	const cols = Math.floor(availableWidth / cellSize);
  16 	const rows = Math.floor(availableHeight / cellSize);
  17 
  18 	return { cols, rows };
  19 }
  20 
  21 // Calculate grid dimensions as percentage of Letter paper (used for screen layout)
  22 function calculateGridPercentages(cellSize, cols, rows) {
  23 	const gridWidthMM = cols * cellSize;
  24 	const gridHeightMM = rows * cellSize;
  25 	return {
  26 		widthPercent: (gridWidthMM / LETTER_WIDTH_MM) * 100,
  27 		heightPercent: (gridHeightMM / LETTER_HEIGHT_MM) * 100
  28 	};
  29 }
  30 
  31 // Cell size bounds (in mm)
  32 const MIN_CELL_SIZE_MM = 2;
  33 const MAX_CELL_SIZE_MM = 10;
  34 
  35 let GRID_COLS = 49;
  36 let GRID_ROWS = 66;
  37 let CELL_SIZE_MM = 4; // Physical size of each cell when printed (can be overridden by URL parameter)
  38 
  39 const grid = document.getElementById('grid');
  40 const page = document.querySelector('.page');
  41 
  42 let popupElement = null;
  43 let popupTimeout = null;
  44 
  45 // Detect Safari browser
  46 function isSafari() {
  47 	const ua = navigator.userAgent;
  48 	return ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium');
  49 }
  50 
  51 const isUsingSafari = isSafari();
  52 
  53 // Parse URL parameters for custom cell size
  54 function parseCellSizeFromURL() {
  55 	const urlParams = new URLSearchParams(window.location.search);
  56 	const gridSizeParam = urlParams.get('grid-size');
  57 
  58 	if (gridSizeParam) {
  59 		// Remove 'mm' suffix if present
  60 		const sizeStr = gridSizeParam.toLowerCase().replace('mm', '').trim();
  61 		const size = parseFloat(sizeStr);
  62 
  63 		// Check if size is valid
  64 		if (isNaN(size)) {
  65 			if (!isUsingSafari) {
  66 				showPopup('Invalid grid size. Using default 4mm.', 3000);
  67 			}
  68 			return 4;
  69 		}
  70 
  71 		// Check if size is within reasonable bounds
  72 		if (size < MIN_CELL_SIZE_MM || size > MAX_CELL_SIZE_MM) {
  73 			if (!isUsingSafari) {
  74 				showPopup(`Grid size must be between ${MIN_CELL_SIZE_MM}mm and ${MAX_CELL_SIZE_MM}mm. Using default 4mm.`, 3500);
  75 			}
  76 			return 4;
  77 		}
  78 
  79 		// Valid size - only show popup if not Safari
  80 		if (!isUsingSafari) {
  81 			showPopup(`Grid set to ${size}mm`);
  82 		}
  83 		return size;
  84 	}
  85 
  86 	return 4; // Default
  87 }
  88 
  89 // Initialize cell size from URL
  90 CELL_SIZE_MM = parseCellSizeFromURL();
  91 
  92 // Calculate grid dimensions based on cell size
  93 const gridDimensions = calculateGridDimensions(CELL_SIZE_MM);
  94 GRID_COLS = gridDimensions.cols;
  95 GRID_ROWS = gridDimensions.rows;
  96 
  97 // Calculate grid percentages for screen layout
  98 const gridPercentages = calculateGridPercentages(CELL_SIZE_MM, GRID_COLS, GRID_ROWS);
  99 
 100 // Update CSS variables for both screen and print
 101 document.documentElement.style.setProperty('--cell-size-mm', `${CELL_SIZE_MM}mm`);
 102 document.documentElement.style.setProperty('--grid-cols', GRID_COLS);
 103 document.documentElement.style.setProperty('--grid-rows', GRID_ROWS);
 104 document.documentElement.style.setProperty('--grid-width-percent', `${gridPercentages.widthPercent}%`);
 105 document.documentElement.style.setProperty('--grid-height-percent', `${gridPercentages.heightPercent}%`);
 106 
 107 // Calculate cell size dynamically based on actual grid dimensions
 108 function getCellSize() {
 109 	const gridRect = grid.getBoundingClientRect();
 110 	return {
 111 		width: gridRect.width / GRID_COLS,
 112 		height: gridRect.height / GRID_ROWS,
 113 		totalWidth: gridRect.width,
 114 		totalHeight: gridRect.height
 115 	};
 116 }
 117 
 118 // Calculate pixel-perfect position for a cell range
 119 // This accounts for grid cell borders (1px per cell) to ensure perfect alignment
 120 function getPixelPerfectBounds(cellX, cellY, cellWidth, cellHeight) {
 121 	// Get all grid cells and measure their actual positions
 122 	const gridCells = grid.querySelectorAll('.grid-cell');
 123 
 124 	// Calculate the index of the top-left cell
 125 	const startCellIndex = cellY * GRID_COLS + cellX;
 126 	const startCell = gridCells[startCellIndex];
 127 
 128 	if (!startCell) {
 129 		// Fallback if cell doesn't exist
 130 		const cellSize = getCellSize();
 131 		return {
 132 			left: cellX * cellSize.width,
 133 			top: cellY * cellSize.height,
 134 			width: cellWidth * cellSize.width,
 135 			height: cellHeight * cellSize.height
 136 		};
 137 	}
 138 
 139 	// Get the actual position of the start cell relative to the grid
 140 	const gridRect = grid.getBoundingClientRect();
 141 	const startCellRect = startCell.getBoundingClientRect();
 142 
 143 	const left = startCellRect.left - gridRect.left;
 144 	const top = startCellRect.top - gridRect.top;
 145 
 146 	// Calculate end position by finding the bottom-right cell
 147 	const endCellIndex = (cellY + cellHeight - 1) * GRID_COLS + (cellX + cellWidth - 1);
 148 	const endCell = gridCells[endCellIndex];
 149 
 150 	if (!endCell) {
 151 		// Fallback if end cell doesn't exist
 152 		const cellSize = getCellSize();
 153 		return {
 154 			left: left,
 155 			top: top,
 156 			width: cellWidth * cellSize.width,
 157 			height: cellHeight * cellSize.height
 158 		};
 159 	}
 160 
 161 	const endCellRect = endCell.getBoundingClientRect();
 162 	const right = endCellRect.right - gridRect.left;
 163 	const bottom = endCellRect.bottom - gridRect.top;
 164 
 165 	return {
 166 		left: left,
 167 		top: top,
 168 		width: right - left,
 169 		height: bottom - top
 170 	};
 171 }
 172 
 173 // Create grid cells
 174 for (let i = 0; i < GRID_COLS * GRID_ROWS; i++) {
 175 	const cell = document.createElement('div');
 176 	cell.className = 'grid-cell';
 177 
 178 	// Add right border to rightmost column
 179 	const col = i % GRID_COLS;
 180 	if (col === GRID_COLS - 1) {
 181 		cell.classList.add('right-edge');
 182 	}
 183 
 184 	// Add bottom border to bottom row
 185 	const row = Math.floor(i / GRID_COLS);
 186 	if (row === GRID_ROWS - 1) {
 187 		cell.classList.add('bottom-edge');
 188 	}
 189 
 190 	grid.appendChild(cell);
 191 }
 192 
 193 // Apply solid border style for grids 3.56mm or smaller
 194 if (CELL_SIZE_MM <= 3.56) {
 195 	document.documentElement.classList.add('solid-grid');
 196 }
 197 
 198 let images = [];
 199 let dragState = null;
 200 let resizeState = null;
 201 let highestZIndex = 0;
 202 let touchState = null; // For tracking multi-touch gestures
 203 let longPressTimer = null; // For detecting long press to enable pan mode
 204 
 205 // Helper function to calculate image dimensions from aspect ratio
 206 function calculateImageDimensions(aspectRatio) {
 207 	let widthCells, heightCells;
 208 	if (aspectRatio >= 1) {
 209 		heightCells = 5;
 210 		widthCells = Math.round(heightCells * aspectRatio);
 211 	} else {
 212 		widthCells = 5;
 213 		heightCells = Math.round(widthCells / aspectRatio);
 214 	}
 215 	return {
 216 		widthCells: Math.min(widthCells, GRID_COLS),
 217 		heightCells: Math.min(heightCells, GRID_ROWS)
 218 	};
 219 }
 220 
 221 // Helper function to load an image and get its dimensions
 222 async function loadImageDimensions(file) {
 223 	const reader = new FileReader();
 224 	const dataUrl = await new Promise(resolve => {
 225 		reader.onload = (e) => resolve(e.target.result);
 226 		reader.readAsDataURL(file);
 227 	});
 228 
 229 	const img = new Image();
 230 	const dimensions = await new Promise(resolve => {
 231 		img.onload = () => {
 232 			const aspectRatio = img.width / img.height;
 233 			resolve(calculateImageDimensions(aspectRatio));
 234 		};
 235 		img.src = dataUrl;
 236 	});
 237 
 238 	return { dataUrl, ...dimensions };
 239 }
 240 
 241 // Shared function to process and add images to the grid
 242 async function processAndAddImages(files, dropX = 0, dropY = 0) {
 243 	const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
 244 	if (imageFiles.length === 0) return;
 245 
 246 	const cellSize = getCellSize();
 247 
 248 	// Load first image to get base dimensions for positioning
 249 	const firstImageData = await loadImageDimensions(imageFiles[0]);
 250 
 251 	// Calculate base drop position
 252 	// For single images, center on cursor; for multiple images, place top-left at cursor
 253 	let baseXCell, baseYCell;
 254 	if (imageFiles.length === 1) {
 255 		baseXCell = Math.round(dropX / cellSize.width - firstImageData.widthCells / 2);
 256 		baseYCell = Math.round(dropY / cellSize.height - firstImageData.heightCells / 2);
 257 	} else {
 258 		baseXCell = Math.round(dropX / cellSize.width);
 259 		baseYCell = Math.round(dropY / cellSize.height);
 260 	}
 261 
 262 	// Pre-allocate z-indexes to maintain drop order
 263 	const baseZIndex = highestZIndex + 1;
 264 	highestZIndex += imageFiles.length;
 265 
 266 	// Load all images
 267 	const imageDataArray = [];
 268 	for (let idx = 0; idx < imageFiles.length; idx++) {
 269 		const data = idx === 0 ? firstImageData : await loadImageDimensions(imageFiles[idx]);
 270 		imageDataArray.push({ idx, ...data });
 271 	}
 272 
 273 	let wrappedOffset = 0;
 274 
 275 	for (const { idx, dataUrl, widthCells, heightCells } of imageDataArray) {
 276 		// Calculate position with diagonal offset
 277 		let xCell = baseXCell + idx;
 278 		let yCell = baseYCell + idx;
 279 
 280 		// If out of bounds or would overlap with wrapped images, wrap to next diagonal position
 281 		const outOfBounds = xCell < 0 || yCell < 0 ||
 282 		                    xCell + widthCells > GRID_COLS ||
 283 		                    yCell + heightCells > GRID_ROWS;
 284 		const overlapsWrapped = xCell < wrappedOffset || yCell < wrappedOffset;
 285 
 286 		if (outOfBounds || overlapsWrapped) {
 287 			xCell = wrappedOffset;
 288 			yCell = wrappedOffset;
 289 			wrappedOffset++;
 290 		}
 291 
 292 		const imageData = addImage(dataUrl, xCell, yCell, widthCells, heightCells);
 293 		imageData.container.style.zIndex = baseZIndex + idx;
 294 	}
 295 }
 296 
 297 // Track mouse position for paste placement
 298 let lastMouseX = -1;
 299 let lastMouseY = -1;
 300 document.addEventListener('mousemove', (e) => {
 301 	lastMouseX = e.clientX;
 302 	lastMouseY = e.clientY;
 303 });
 304 
 305 // Clipboard paste support
 306 document.addEventListener('paste', async (e) => {
 307 	const items = e.clipboardData?.items;
 308 	if (!items) return;
 309 
 310 	const imageFiles = [];
 311 	for (const item of items) {
 312 		if (item.type.startsWith('image/')) {
 313 			const file = item.getAsFile();
 314 			if (file) imageFiles.push(file);
 315 		}
 316 	}
 317 
 318 	if (imageFiles.length === 0) {
 319 		if (!isUsingSafari) showPopup('No image found in clipboard');
 320 		return;
 321 	}
 322 
 323 	// Use last known mouse position relative to grid, fall back to (0, 0)
 324 	const gridRect = grid.getBoundingClientRect();
 325 	const mouseXInGrid = lastMouseX - gridRect.left;
 326 	const mouseYInGrid = lastMouseY - gridRect.top;
 327 	const isOnGrid = mouseXInGrid >= 0 && mouseYInGrid >= 0 &&
 328 		mouseXInGrid <= gridRect.width && mouseYInGrid <= gridRect.height;
 329 
 330 	const dropX = isOnGrid ? mouseXInGrid : 0;
 331 	const dropY = isOnGrid ? mouseYInGrid : 0;
 332 
 333 	await processAndAddImages(imageFiles, dropX, dropY);
 334 });
 335 
 336 // Prevent default drag behavior on entire document
 337 document.addEventListener('dragover', (e) => {
 338 	e.preventDefault();
 339 	e.stopPropagation();
 340 });
 341 
 342 document.addEventListener('drop', async (e) => {
 343 	e.preventDefault();
 344 	e.stopPropagation();
 345 
 346 	// Get drop position relative to the grid
 347 	const gridRect = grid.getBoundingClientRect();
 348 	const dropX = e.clientX - gridRect.left;
 349 	const dropY = e.clientY - gridRect.top;
 350 
 351 	await processAndAddImages(e.dataTransfer.files, dropX, dropY);
 352 });
 353 
 354 function addImage(src, xCell, yCell, widthCells, heightCells) {
 355 	const container = document.createElement('div');
 356 	container.className = 'image-container';
 357 	highestZIndex++;
 358 	container.style.zIndex = highestZIndex;
 359 
 360 	const wrapper = document.createElement('div');
 361 	wrapper.className = 'image-wrapper';
 362 	const img = document.createElement('img');
 363 	img.src = src;
 364 	wrapper.appendChild(img);
 365 	container.appendChild(wrapper);
 366 
 367 	// Add resize handles
 368 	const handles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
 369 	handles.forEach(dir => {
 370 		const handle = document.createElement('div');
 371 		handle.className = `resize-handle ${dir.length === 1 ? 'edge' : 'corner'} ${dir}`;
 372 		handle.dataset.direction = dir;
 373 		container.appendChild(handle);
 374 	});
 375 
 376 	// Add dimension labels
 377 	const widthLabel = document.createElement('div');
 378 	widthLabel.className = 'dimension-label width';
 379 	widthLabel.textContent = widthCells;
 380 	container.appendChild(widthLabel);
 381 
 382 	const heightLabel = document.createElement('div');
 383 	heightLabel.className = 'dimension-label height';
 384 	heightLabel.textContent = heightCells;
 385 	container.appendChild(heightLabel);
 386 
 387 	const imageData = {
 388 		container,
 389 		xCell,
 390 		yCell,
 391 		widthCells,
 392 		heightCells,
 393 		// Image positioning within container (in pixels, relative to center)
 394 		panX: 0,
 395 		panY: 0,
 396 		userScale: 1,  // user zoom level (1-5)
 397 		rotation: 0,  // rotation in degrees (0, 90, 180, 270)
 398 		// Store natural image dimensions for calculations
 399 		naturalWidth: 0,
 400 		naturalHeight: 0,
 401 		baseScale: 1  // scale needed to cover container
 402 	};
 403 
 404 	// Calculate dimensions and scale once image loads
 405 	img.onload = () => {
 406 		imageData.naturalWidth = img.naturalWidth;
 407 		imageData.naturalHeight = img.naturalHeight;
 408 
 409 		// Calculate base scale to cover container (mimics object-fit: cover)
 410 		const bounds = getPixelPerfectBounds(imageData.xCell, imageData.yCell, imageData.widthCells, imageData.heightCells);
 411 		const scaleX = bounds.width / img.naturalWidth;
 412 		const scaleY = bounds.height / img.naturalHeight;
 413 		imageData.baseScale = Math.max(scaleX, scaleY);
 414 
 415 		updateImagePosition(imageData);
 416 	};
 417 	images.push(imageData);
 418 
 419 	updateImagePosition(imageData);
 420 	grid.appendChild(container);
 421 
 422 	setupImageHandlers(imageData);
 423 
 424 	return imageData;
 425 }
 426 
 427 function calculatePanBounds(imageData) {
 428 	// Container dimensions in the current (possibly swapped) grid orientation
 429 	const bounds = getPixelPerfectBounds(imageData.xCell, imageData.yCell, imageData.widthCells, imageData.heightCells);
 430 	const containerWidth = bounds.width;
 431 	const containerHeight = bounds.height;
 432 
 433 	if (imageData.naturalWidth === 0 || imageData.naturalHeight === 0) {
 434 		return { maxPanX: 0, maxPanY: 0 };
 435 	}
 436 
 437 	// Pan coordinates are in the image's original coordinate system (before scale and rotation)
 438 	// So we need to calculate bounds based on the original image dimensions
 439 	const isRotated90or270 = imageData.rotation % 180 !== 0;
 440 
 441 	// For pan bounds, we need to match image dimensions to container dimensions
 442 	// in the image's coordinate space (not the screen's coordinate space)
 443 	// When rotated 90/270, panX constrains vertical screen movement (maps to container height)
 444 	// and panY constrains horizontal screen movement (maps to container width)
 445 	const effectiveContainerWidth = isRotated90or270 ? containerHeight : containerWidth;
 446 	const effectiveContainerHeight = isRotated90or270 ? containerWidth : containerHeight;
 447 
 448 	// Pan values are in pre-scale image space, but the CSS transform scales them
 449 	// So we need to calculate bounds in pre-scale space
 450 	// The image's natural size minus the container size (in pre-scale space) gives us the overhang
 451 	const totalScale = imageData.baseScale * imageData.userScale;
 452 	const containerWidthInImageSpace = effectiveContainerWidth / totalScale;
 453 	const containerHeightInImageSpace = effectiveContainerHeight / totalScale;
 454 
 455 	// Calculate maximum pan in each direction (in pre-scale image space)
 456 	const maxPanX = Math.max(0, (imageData.naturalWidth - containerWidthInImageSpace) / 2);
 457 	const maxPanY = Math.max(0, (imageData.naturalHeight - containerHeightInImageSpace) / 2);
 458 
 459 	return { maxPanX, maxPanY };
 460 }
 461 
 462 function clampPan(imageData) {
 463 	const { maxPanX, maxPanY } = calculatePanBounds(imageData);
 464 	imageData.panX = Math.max(-maxPanX, Math.min(maxPanX, imageData.panX));
 465 	imageData.panY = Math.max(-maxPanY, Math.min(maxPanY, imageData.panY));
 466 }
 467 
 468 // Helper to transform screen-space coordinates to image coordinate space (accounting for rotation)
 469 function rotatePoint(x, y, angleDegrees) {
 470 	const angle = -angleDegrees * Math.PI / 180;
 471 	const cos = Math.cos(angle);
 472 	const sin = Math.sin(angle);
 473 	return {
 474 		x: x * cos - y * sin,
 475 		y: x * sin + y * cos
 476 	};
 477 }
 478 
 479 // Helper to apply pan adjustment based on screen delta
 480 function applyPanDelta(imageData, screenDeltaX, screenDeltaY) {
 481 	const rotated = rotatePoint(screenDeltaX, screenDeltaY, imageData.rotation);
 482 	const totalScale = imageData.baseScale * imageData.userScale;
 483 	imageData.panX += rotated.x / totalScale;
 484 	imageData.panY += rotated.y / totalScale;
 485 }
 486 
 487 // Helper to calculate zoom-centered pan adjustment
 488 function adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale) {
 489 	const rotated = rotatePoint(cursorX, cursorY, imageData.rotation);
 490 	const scaleDiff = 1/newTotalScale - 1/oldTotalScale;
 491 	imageData.panX += rotated.x * scaleDiff;
 492 	imageData.panY += rotated.y * scaleDiff;
 493 }
 494 
 495 function updateImagePosition(img) {
 496 	// Use pixel-perfect bounds to prevent subpixel accumulation
 497 	const bounds = getPixelPerfectBounds(img.xCell, img.yCell, img.widthCells, img.heightCells);
 498 	img.container.style.left = bounds.left + 'px';
 499 	img.container.style.top = bounds.top + 'px';
 500 	img.container.style.width = bounds.width + 'px';
 501 	img.container.style.height = bounds.height + 'px';
 502 
 503 	// Store cell positions as CSS variables for print styles
 504 	img.container.style.setProperty('--x-cell', img.xCell);
 505 	img.container.style.setProperty('--y-cell', img.yCell);
 506 	img.container.style.setProperty('--width-cells', img.widthCells);
 507 	img.container.style.setProperty('--height-cells', img.heightCells);
 508 
 509 	// Update dimension labels
 510 	const widthLabel = img.container.querySelector('.dimension-label.width');
 511 	const heightLabel = img.container.querySelector('.dimension-label.height');
 512 	if (widthLabel) {
 513 		// Only update text if dimension is >= 5
 514 		if (img.widthCells >= 5) {
 515 			widthLabel.textContent = img.widthCells;
 516 		}
 517 		widthLabel.dataset.hidden = img.widthCells < 5 ? 'true' : 'false';
 518 	}
 519 	if (heightLabel) {
 520 		// Only update text if dimension is >= 5
 521 		if (img.heightCells >= 5) {
 522 			heightLabel.textContent = img.heightCells;
 523 		}
 524 		heightLabel.dataset.hidden = img.heightCells < 5 ? 'true' : 'false';
 525 	}
 526 
 527 	// Recalculate baseScale if container size changed
 528 	if (img.naturalWidth > 0 && img.naturalHeight > 0) {
 529 		// Use pixel-perfect bounds for container dimensions
 530 		const containerWidth = bounds.width;
 531 		const containerHeight = bounds.height;
 532 
 533 		// When rotated 90° or 270°, the image dimensions are effectively swapped
 534 		const isRotated90or270 = img.rotation % 180 !== 0;
 535 		const effectiveWidth = isRotated90or270 ? img.naturalHeight : img.naturalWidth;
 536 		const effectiveHeight = isRotated90or270 ? img.naturalWidth : img.naturalHeight;
 537 
 538 		const scaleX = containerWidth / effectiveWidth;
 539 		const scaleY = containerHeight / effectiveHeight;
 540 		img.baseScale = Math.max(scaleX, scaleY);
 541 
 542 		// Reclamp pan after recalculating base scale
 543 		clampPan(img);
 544 	}
 545 
 546 	// Apply image positioning and scale using transform
 547 	const imgElement = img.container.querySelector('img');
 548 	if (imgElement) {
 549 		const totalScale = img.baseScale * img.userScale;
 550 		// Transform: translate from center (-50%, -50%), scale, rotate, then pan
 551 		// Pan is applied after rotation so it stays relative to the image's rotated state
 552 		imgElement.style.transform = `translate(-50%, -50%) scale(${totalScale}) rotate(${img.rotation}deg) translate(${img.panX}px, ${img.panY}px)`;
 553 	}
 554 }
 555 
 556 function bringToFront(container) {
 557 	highestZIndex++;
 558 	container.style.zIndex = highestZIndex;
 559 }
 560 
 561 function setupImageHandlers(imageData) {
 562 	const container = imageData.container;
 563 
 564 	// Bring to front on hover
 565 	container.addEventListener('mouseenter', () => {
 566 		bringToFront(container);
 567 	});
 568 
 569 	// Unified pointer start handler
 570 	function handlePointerStart(clientX, clientY, isTouch = false, touchIdentifier = null) {
 571 		// Clear any existing timers from other images
 572 		clearDragState();
 573 
 574 		document.body.style.cursor = 'grabbing';
 575 		document.body.classList.add('dragging');
 576 
 577 		dragState = {
 578 			image: imageData,
 579 			startX: clientX,
 580 			startY: clientY,
 581 			startXCell: imageData.xCell,
 582 			startYCell: imageData.yCell,
 583 			isPanMode: false, // Will be set to true after long press
 584 			isTouch: isTouch,
 585 			timerId: null, // Store timer ID to ensure we only activate the correct timer
 586 			touchIdentifier: touchIdentifier, // Track which touch this drag belongs to
 587 			hasMoved: false // Track if any movement has occurred
 588 		};
 589 
 590 		container.classList.add('dragging');
 591 
 592 		// For touch, set up long press timer to enable pan mode
 593 		if (isTouch) {
 594 			const timerId = setTimeout(() => {
 595 				// Only activate pan mode if:
 596 				// 1. This drag state is still active
 597 				// 2. This drag state is for this specific image
 598 				// 3. The image hasn't moved to a new cell
 599 				// 4. This is the timer that was created for this drag state
 600 				if (dragState &&
 601 				    dragState.image === imageData &&
 602 				    dragState.startXCell === imageData.xCell &&
 603 				    dragState.startYCell === imageData.yCell &&
 604 				    dragState.timerId === timerId) {
 605 					// User held for 0.5 seconds without moving to a new cell - enable pan mode
 606 					dragState.isPanMode = true;
 607 					dragState.initialPanX = imageData.panX;
 608 					dragState.initialPanY = imageData.panY;
 609 					// Add visual feedback class
 610 					container.classList.add('pan-mode');
 611 				}
 612 			}, 500);
 613 			dragState.timerId = timerId;
 614 			longPressTimer = timerId;
 615 		}
 616 	}
 617 
 618 	// Touch event handlers for mobile
 619 	container.addEventListener('touchstart', (e) => {
 620 		if (e.target.classList.contains('resize-handle')) return;
 621 
 622 		// Bring to front on touch
 623 		bringToFront(container);
 624 
 625 		if (e.touches.length === 1) {
 626 			// Single touch - start drag (or long press for pan)
 627 			e.preventDefault();
 628 			const touch = e.touches[0];
 629 			handlePointerStart(touch.clientX, touch.clientY, true, touch.identifier);
 630 		} else if (e.touches.length === 2) {
 631 			// Two fingers - prepare for pinch/pan
 632 			e.preventDefault();
 633 
 634 			// Cancel any ongoing drag
 635 			clearDragState();
 636 
 637 			const touch1 = e.touches[0];
 638 			const touch2 = e.touches[1];
 639 
 640 			// Calculate initial distance for pinch detection
 641 			const dx = touch2.clientX - touch1.clientX;
 642 			const dy = touch2.clientY - touch1.clientY;
 643 			const distance = Math.sqrt(dx * dx + dy * dy);
 644 
 645 			// Calculate center point
 646 			const centerX = (touch1.clientX + touch2.clientX) / 2;
 647 			const centerY = (touch1.clientY + touch2.clientY) / 2;
 648 
 649 			touchState = {
 650 				image: imageData,
 651 				initialDistance: distance,
 652 				lastDistance: distance,
 653 				initialScale: imageData.userScale,
 654 				lastCenterX: centerX,
 655 				lastCenterY: centerY,
 656 				lastPanX: imageData.panX,
 657 				lastPanY: imageData.panY
 658 			};
 659 		}
 660 	}, { passive: false });
 661 
 662 	container.addEventListener('touchmove', (e) => {
 663 		if (e.touches.length === 2 && touchState && touchState.image === imageData) {
 664 			// Two finger pinch/pan
 665 			e.preventDefault();
 666 
 667 			const touch1 = e.touches[0];
 668 			const touch2 = e.touches[1];
 669 
 670 			// Calculate current distance
 671 			const dx = touch2.clientX - touch1.clientX;
 672 			const dy = touch2.clientY - touch1.clientY;
 673 			const distance = Math.sqrt(dx * dx + dy * dy);
 674 
 675 			// Calculate center point
 676 			const centerX = (touch1.clientX + touch2.clientX) / 2;
 677 			const centerY = (touch1.clientY + touch2.clientY) / 2;
 678 
 679 			// Detect if this is primarily a pinch or a pan
 680 			const distanceChange = Math.abs(distance - touchState.lastDistance);
 681 			const centerMoveX = centerX - touchState.lastCenterX;
 682 			const centerMoveY = centerY - touchState.lastCenterY;
 683 			const centerMovement = Math.sqrt(centerMoveX * centerMoveX + centerMoveY * centerMoveY);
 684 
 685 			// If distance changed significantly more than center moved, treat as pinch
 686 			if (distanceChange > centerMovement * 0.5) {
 687 				// Pinch zoom
 688 				const rect = container.getBoundingClientRect();
 689 				const cursorX = centerX - rect.left - rect.width / 2;
 690 				const cursorY = centerY - rect.top - rect.height / 2;
 691 
 692 				const oldUserScale = imageData.userScale;
 693 				const oldTotalScale = imageData.baseScale * oldUserScale;
 694 				const scaleFactor = distance / touchState.initialDistance;
 695 				const newUserScale = Math.max(1, Math.min(5, touchState.initialScale * scaleFactor));
 696 				const newTotalScale = imageData.baseScale * newUserScale;
 697 
 698 				// Restore previous pan state and apply zoom adjustment
 699 				imageData.panX = touchState.lastPanX;
 700 				imageData.panY = touchState.lastPanY;
 701 				adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale);
 702 				imageData.userScale = newUserScale;
 703 
 704 				clampPan(imageData);
 705 				touchState.lastPanX = imageData.panX;
 706 				touchState.lastPanY = imageData.panY;
 707 			} else {
 708 				// Two-finger pan
 709 				imageData.panX = touchState.lastPanX;
 710 				imageData.panY = touchState.lastPanY;
 711 				applyPanDelta(imageData, centerMoveX, centerMoveY);
 712 
 713 				clampPan(imageData);
 714 				touchState.lastPanX = imageData.panX;
 715 				touchState.lastPanY = imageData.panY;
 716 			}
 717 
 718 			touchState.lastDistance = distance;
 719 			touchState.lastCenterX = centerX;
 720 			touchState.lastCenterY = centerY;
 721 
 722 			updateImagePosition(imageData);
 723 		}
 724 	}, { passive: false });
 725 
 726 	container.addEventListener('touchend', () => {
 727 		if (touchState && touchState.image === imageData) {
 728 			touchState = null;
 729 		}
 730 	}, { passive: false });
 731 
 732 	container.addEventListener('touchcancel', () => {
 733 		if (touchState && touchState.image === imageData) {
 734 			touchState = null;
 735 		}
 736 	}, { passive: false });
 737 
 738 	// Moving / Deleting / Duplicating
 739 	container.addEventListener('mousedown', (e) => {
 740 		if (e.target.classList.contains('resize-handle')) return;
 741 
 742 		e.preventDefault();
 743 
 744 		// Shift-click to delete
 745 		if (e.shiftKey) {
 746 			const index = images.indexOf(imageData);
 747 			if (index > -1) {
 748 				images.splice(index, 1);
 749 			}
 750 			container.remove();
 751 			return;
 752 		}
 753 
 754 		// Option-click (or Alt-click on Windows/Linux) to rotate
 755 		if (e.altKey) {
 756 			// Check if rotation would cause dimension swap and if it would fit on grid
 757 			const oldRotation = imageData.rotation;
 758 			const newRotation = (imageData.rotation + 90) % 360;
 759 
 760 			// When rotating between portrait and landscape (90° or 270°), dimensions swap
 761 			const willSwapDimensions = (oldRotation % 180 === 0 && newRotation % 180 !== 0) ||
 762 			                           (oldRotation % 180 !== 0 && newRotation % 180 === 0);
 763 
 764 			if (willSwapDimensions) {
 765 				// Check if swapped dimensions would fit on grid at current position
 766 				const newWidthCells = imageData.heightCells;
 767 				const newHeightCells = imageData.widthCells;
 768 
 769 				// Don't allow rotation if it would exceed grid bounds
 770 				if (imageData.xCell + newWidthCells > GRID_COLS ||
 771 				    imageData.yCell + newHeightCells > GRID_ROWS) {
 772 					return; // Silently ignore the rotation
 773 				}
 774 
 775 				// Swap width and height
 776 				imageData.widthCells = newWidthCells;
 777 				imageData.heightCells = newHeightCells;
 778 			}
 779 
 780 			// Rotate 90 degrees clockwise
 781 			imageData.rotation = newRotation;
 782 
 783 			// Don't rotate pan coordinates - they stay in the image's original coordinate system
 784 			// The CSS transform applies rotation before pan, so pan is relative to the rotated image
 785 
 786 			updateImagePosition(imageData);
 787 			return;
 788 		}
 789 
 790 		// Cmd-click (or Ctrl-click on Windows/Linux) to duplicate
 791 		if (e.metaKey || e.ctrlKey) {
 792 			// Calculate target position (1 cell right and 1 cell down)
 793 			let newXCell = imageData.xCell + 1;
 794 			let newYCell = imageData.yCell + 1;
 795 
 796 			// If there's not enough room, fall back to top-left
 797 			if (newXCell + imageData.widthCells > GRID_COLS || newYCell + imageData.heightCells > GRID_ROWS) {
 798 				newXCell = 0;
 799 				newYCell = 0;
 800 			}
 801 
 802 			// Create duplicate with the same image source and dimensions
 803 			const imgElement = container.querySelector('img');
 804 			const newImageData = addImage(
 805 				imgElement.src,
 806 				newXCell,
 807 				newYCell,
 808 				imageData.widthCells,
 809 				imageData.heightCells
 810 			);
 811 
 812 			// Copy pan, zoom, and rotation settings from original
 813 			// Store the original settings to apply after image loads
 814 			const originalPanX = imageData.panX;
 815 			const originalPanY = imageData.panY;
 816 			const originalUserScale = imageData.userScale;
 817 			const originalRotation = imageData.rotation;
 818 
 819 			// Override the onload to copy settings
 820 			const newImg = newImageData.container.querySelector('img');
 821 			const originalOnload = newImg.onload;
 822 			newImg.onload = () => {
 823 				// Run the original onload first
 824 				if (originalOnload) originalOnload.call(newImg);
 825 
 826 				// Then apply the copied settings
 827 				newImageData.panX = originalPanX;
 828 				newImageData.panY = originalPanY;
 829 				newImageData.userScale = originalUserScale;
 830 				newImageData.rotation = originalRotation;
 831 				updateImagePosition(newImageData);
 832 			};
 833 
 834 			// If image is already loaded (cached), trigger the settings copy
 835 			if (newImg.complete && newImageData.naturalWidth > 0) {
 836 				newImageData.panX = originalPanX;
 837 				newImageData.panY = originalPanY;
 838 				newImageData.userScale = originalUserScale;
 839 				newImageData.rotation = originalRotation;
 840 				updateImagePosition(newImageData);
 841 			}
 842 
 843 			return;
 844 		}
 845 
 846 		handlePointerStart(e.clientX, e.clientY);
 847 	});
 848 
 849 	// Resizing - unified handler for mouse and touch
 850 	function startResize(clientX, clientY, direction, cursorStyle = null) {
 851 		if (cursorStyle) {
 852 			document.body.style.cursor = cursorStyle;
 853 		}
 854 		document.body.classList.add('resizing');
 855 
 856 		resizeState = {
 857 			image: imageData,
 858 			direction: direction,
 859 			startX: clientX,
 860 			startY: clientY,
 861 			startXCell: imageData.xCell,
 862 			startYCell: imageData.yCell,
 863 			startWidthCells: imageData.widthCells,
 864 			startHeightCells: imageData.heightCells
 865 		};
 866 		container.classList.add('resizing');
 867 	}
 868 
 869 	container.querySelectorAll('.resize-handle').forEach(handle => {
 870 		handle.addEventListener('mousedown', (e) => {
 871 			e.preventDefault();
 872 			e.stopPropagation();
 873 			const cursorStyle = window.getComputedStyle(handle).cursor;
 874 			startResize(e.clientX, e.clientY, handle.dataset.direction, cursorStyle);
 875 		});
 876 
 877 		handle.addEventListener('touchstart', (e) => {
 878 			e.preventDefault();
 879 			e.stopPropagation();
 880 			const touch = e.touches[0];
 881 			startResize(touch.clientX, touch.clientY, handle.dataset.direction);
 882 		}, { passive: false });
 883 	});
 884 
 885 	// Pan and Zoom with wheel events (macOS trackpad gestures)
 886 	container.addEventListener('wheel', (e) => {
 887 		e.preventDefault();
 888 		e.stopPropagation();
 889 
 890 		// Don't interfere with dragging or resizing
 891 		if (dragState || resizeState) return;
 892 
 893 		// Detect pinch zoom (ctrlKey is set for pinch gestures on macOS trackpad)
 894 		if (e.ctrlKey) {
 895 			// Zoom at cursor position
 896 			const rect = container.getBoundingClientRect();
 897 			const cursorX = e.clientX - rect.left - rect.width / 2;
 898 			const cursorY = e.clientY - rect.top - rect.height / 2;
 899 
 900 			const oldUserScale = imageData.userScale;
 901 			const oldTotalScale = imageData.baseScale * oldUserScale;
 902 			const zoomDelta = -e.deltaY * 0.01;
 903 			const newUserScale = Math.max(1, Math.min(5, oldUserScale * (1 + zoomDelta)));
 904 			const newTotalScale = imageData.baseScale * newUserScale;
 905 
 906 			adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale);
 907 			imageData.userScale = newUserScale;
 908 
 909 			clampPan(imageData);
 910 		} else {
 911 			// Pan (two-finger scroll on macOS trackpad)
 912 			applyPanDelta(imageData, -e.deltaX, -e.deltaY);
 913 			clampPan(imageData);
 914 		}
 915 
 916 		updateImagePosition(imageData);
 917 	}, { passive: false });
 918 }
 919 
 920 // Helper to clear drag state and timers
 921 function clearDragState() {
 922 	if (longPressTimer) {
 923 		clearTimeout(longPressTimer);
 924 		longPressTimer = null;
 925 	}
 926 	if (dragState) {
 927 		dragState.image.container.classList.remove('dragging');
 928 		dragState.image.container.classList.remove('pan-mode');
 929 		dragState = null;
 930 		document.body.style.cursor = '';
 931 		document.body.classList.remove('dragging');
 932 	}
 933 }
 934 
 935 function handleMove(clientX, clientY) {
 936 	if (dragState) {
 937 		const dx = clientX - dragState.startX;
 938 		const dy = clientY - dragState.startY;
 939 
 940 		// Safari on iOS can send the first touchmove with stale coordinates when zoomed
 941 		// Validate that the first move is reasonable by checking if it would move more than 1 cell
 942 		if (dragState.isTouch && !dragState.hasMoved) {
 943 			const cellSize = getCellSize();
 944 			const dxCells = Math.abs(Math.round(dx / cellSize.width));
 945 			const dyCells = Math.abs(Math.round(dy / cellSize.height));
 946 
 947 			// If the first move would jump more than 1 cell in either direction,
 948 			// it's likely stale coordinates from a previous tap - reset start position
 949 			if (dxCells > 1 || dyCells > 1) {
 950 				dragState.startX = clientX;
 951 				dragState.startY = clientY;
 952 				dragState.hasMoved = true;
 953 				return; // Don't process this move event
 954 			}
 955 			dragState.hasMoved = true;
 956 		}
 957 
 958 		if (dragState.isPanMode) {
 959 			// Pan mode - move the image within its container
 960 			const imageData = dragState.image;
 961 			imageData.panX = dragState.initialPanX;
 962 			imageData.panY = dragState.initialPanY;
 963 			applyPanDelta(imageData, dx, dy);
 964 			clampPan(imageData);
 965 			updateImagePosition(imageData);
 966 		} else {
 967 			// Normal drag mode - move the image container on the grid
 968 			const cellSize = getCellSize();
 969 			const dxCells = Math.round(dx / cellSize.width);
 970 			const dyCells = Math.round(dy / cellSize.height);
 971 
 972 			const newXCell = Math.max(0, Math.min(GRID_COLS - dragState.image.widthCells, dragState.startXCell + dxCells));
 973 			const newYCell = Math.max(0, Math.min(GRID_ROWS - dragState.image.heightCells, dragState.startYCell + dyCells));
 974 
 975 			// If the image moved to a new cell, cancel the long press timer
 976 			if (dragState.isTouch && longPressTimer &&
 977 			    (newXCell !== dragState.startXCell || newYCell !== dragState.startYCell)) {
 978 				clearTimeout(longPressTimer);
 979 				longPressTimer = null;
 980 			}
 981 
 982 			dragState.image.xCell = newXCell;
 983 			dragState.image.yCell = newYCell;
 984 			updateImagePosition(dragState.image);
 985 		}
 986 	}
 987 
 988 	if (resizeState) {
 989 		const dx = clientX - resizeState.startX;
 990 		const dy = clientY - resizeState.startY;
 991 
 992 		const cellSize = getCellSize();
 993 		const dxCells = Math.round(dx / cellSize.width);
 994 		const dyCells = Math.round(dy / cellSize.height);
 995 
 996 		const dir = resizeState.direction;
 997 		const img = resizeState.image;
 998 
 999 		let newX = img.xCell;
1000 		let newY = img.yCell;
1001 		let newW = img.widthCells;
1002 		let newH = img.heightCells;
1003 
1004 		if (dir.includes('e')) {
1005 			const proposedW = Math.max(1, resizeState.startWidthCells + dxCells);
1006 			// Clamp to grid boundary
1007 			newW = Math.min(proposedW, GRID_COLS - resizeState.startXCell);
1008 		}
1009 		if (dir.includes('w')) {
1010 			const delta = Math.min(dxCells, resizeState.startWidthCells - 1);
1011 			const proposedX = resizeState.startXCell + delta;
1012 			// Clamp to grid boundary
1013 			const clampedX = Math.max(0, proposedX);
1014 			newX = clampedX;
1015 			newW = resizeState.startWidthCells - (clampedX - resizeState.startXCell);
1016 		}
1017 		if (dir.includes('s')) {
1018 			const proposedH = Math.max(1, resizeState.startHeightCells + dyCells);
1019 			// Clamp to grid boundary
1020 			newH = Math.min(proposedH, GRID_ROWS - resizeState.startYCell);
1021 		}
1022 		if (dir.includes('n')) {
1023 			const delta = Math.min(dyCells, resizeState.startHeightCells - 1);
1024 			const proposedY = resizeState.startYCell + delta;
1025 			// Clamp to grid boundary
1026 			const clampedY = Math.max(0, proposedY);
1027 			newY = clampedY;
1028 			newH = resizeState.startHeightCells - (clampedY - resizeState.startYCell);
1029 		}
1030 
1031 		img.xCell = newX;
1032 		img.yCell = newY;
1033 		img.widthCells = newW;
1034 		img.heightCells = newH;
1035 		updateImagePosition(img);
1036 	}
1037 }
1038 
1039 function handleEnd() {
1040 	clearDragState();
1041 	if (resizeState) {
1042 		resizeState.image.container.classList.remove('resizing');
1043 		resizeState = null;
1044 		document.body.style.cursor = '';
1045 		document.body.classList.remove('resizing');
1046 	}
1047 }
1048 
1049 document.addEventListener('mousemove', (e) => {
1050 	handleMove(e.clientX, e.clientY);
1051 });
1052 
1053 document.addEventListener('mouseup', () => {
1054 	handleEnd();
1055 });
1056 
1057 document.addEventListener('touchmove', (e) => {
1058 	// Only handle global drag/resize, not image-specific multi-touch
1059 	if ((dragState || resizeState) && e.touches.length === 1) {
1060 		const touch = e.touches[0];
1061 
1062 		// For drag operations, verify this touch matches the one that started the drag
1063 		if (dragState && dragState.touchIdentifier !== null &&
1064 		    touch.identifier !== dragState.touchIdentifier) {
1065 			// This is a different touch - ignore it
1066 			return;
1067 		}
1068 
1069 		e.preventDefault();
1070 		handleMove(touch.clientX, touch.clientY);
1071 	}
1072 }, { passive: false });
1073 
1074 document.addEventListener('touchend', (e) => {
1075 	// If we have an active drag state, only end it if the touch that's ending
1076 	// matches the touch that started the drag
1077 	if (dragState && dragState.touchIdentifier !== null && e.changedTouches.length > 0) {
1078 		let matchingTouchEnded = false;
1079 		for (let i = 0; i < e.changedTouches.length; i++) {
1080 			if (e.changedTouches[i].identifier === dragState.touchIdentifier) {
1081 				matchingTouchEnded = true;
1082 				break;
1083 			}
1084 		}
1085 		// Only end the drag if the matching touch ended
1086 		if (!matchingTouchEnded) {
1087 			return;
1088 		}
1089 	}
1090 
1091 	handleEnd();
1092 
1093 	// Clear any lingering timers even if there's no active drag state
1094 	if (longPressTimer) {
1095 		clearTimeout(longPressTimer);
1096 		longPressTimer = null;
1097 	}
1098 });
1099 
1100 document.addEventListener('touchcancel', () => {
1101 	handleEnd();
1102 
1103 	if (longPressTimer) {
1104 		clearTimeout(longPressTimer);
1105 		longPressTimer = null;
1106 	}
1107 });
1108 
1109 // Intercept wheel events at the document level to prevent page scroll/zoom when the cursor is over an image container.
1110 document.addEventListener('wheel', (e) => {
1111 	const hoveredImage = images.find(img => img.container.contains(e.target) || img.container === e.target);
1112 	if (hoveredImage) {
1113 		e.preventDefault();
1114 	}
1115 }, { passive: false });
1116 
1117 // Update all image positions when window resizes (for responsive scaling)
1118 window.addEventListener('resize', () => {
1119 	images.forEach(img => {
1120 		updateImagePosition(img);
1121 	});
1122 });
1123 
1124 // Adjust image scales for print and restore after
1125 const PRINT_CELL_SIZE_PX = CELL_SIZE_MM * 96 / 25.4; // Cell size in pixels at 96 DPI
1126 
1127 window.addEventListener('beforeprint', () => {
1128 	images.forEach(img => {
1129 		const imgElement = img.container.querySelector('img');
1130 		if (!imgElement || !img.naturalWidth || !img.naturalHeight) return;
1131 
1132 		// Calculate print container size
1133 		const printWidth = img.widthCells * PRINT_CELL_SIZE_PX;
1134 		const printHeight = img.heightCells * PRINT_CELL_SIZE_PX;
1135 
1136 		// Recalculate base scale for print
1137 		const isRotated = img.rotation % 180 !== 0;
1138 		const effectiveW = isRotated ? img.naturalHeight : img.naturalWidth;
1139 		const effectiveH = isRotated ? img.naturalWidth : img.naturalHeight;
1140 		const printBaseScale = Math.max(printWidth / effectiveW, printHeight / effectiveH);
1141 
1142 		// Apply print transform
1143 		const printScale = printBaseScale * img.userScale;
1144 		imgElement.style.transform = `translate(-50%, -50%) scale(${printScale}) rotate(${img.rotation}deg) translate(${img.panX}px, ${img.panY}px)`;
1145 	});
1146 });
1147 
1148 window.addEventListener('afterprint', () => {
1149 	// Wait for layout to settle after exiting print mode
1150 	requestAnimationFrame(() => {
1151 		requestAnimationFrame(() => {
1152 			images.forEach(img => updateImagePosition(img));
1153 		});
1154 	});
1155 });
1156 
1157 // Theme system
1158 let currentThemeIndex = 0;
1159 let isF2Pressed = false;
1160 const themes = ['sea-breeze', 'grape-soda', 'grapefruit', 'guac', 'mojito', 'banana'];
1161 
1162 function setTheme(theme) {
1163 	document.documentElement.setAttribute('data-theme', theme);
1164 	const backgroundColor = getComputedStyle(document.documentElement).getPropertyValue('--desk').trim();
1165 	document.querySelector('meta[name="theme-color"]').setAttribute('content', backgroundColor);
1166 }
1167 
1168 function cycleTheme() {
1169 	currentThemeIndex = (currentThemeIndex + 1) % themes.length;
1170 	const newTheme = themes[currentThemeIndex];
1171 	setTheme(newTheme);
1172 	saveThemeToLocalStorage(newTheme);
1173 }
1174 
1175 function saveThemeToLocalStorage(theme) {
1176 	localStorage.setItem('memori-theme', theme);
1177 }
1178 
1179 function loadThemeFromLocalStorage() {
1180 	const savedTheme = localStorage.getItem('memori-theme');
1181 	if (savedTheme && themes.includes(savedTheme)) {
1182 		currentThemeIndex = themes.indexOf(savedTheme);
1183 		setTheme(savedTheme);
1184 	} else {
1185 		// Use default theme
1186 		setTheme(themes[0]);
1187 	}
1188 }
1189 
1190 // F2 key handler for theme cycling
1191 document.addEventListener('keydown', (e) => {
1192 	if (e.key === 'F2' && !isF2Pressed) {
1193 		e.preventDefault();
1194 		isF2Pressed = true;
1195 		cycleTheme();
1196 	}
1197 });
1198 
1199 document.addEventListener('keyup', (e) => {
1200 	if (e.key === 'F2') {
1201 		isF2Pressed = false;
1202 	}
1203 });
1204 
1205 // Load theme on page load
1206 loadThemeFromLocalStorage();
1207 
1208 // Show Safari warning if applicable
1209 if (isUsingSafari) {
1210 	const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
1211 	const safariMessage = isMobile
1212 		? 'Printing from Safari is not currently supported.'
1213 		: 'Printing from Safari is not currently supported. Please use Firefox or Chrome instead.';
1214 	showPopup(safariMessage, 5000);
1215 }
1216 
1217 // Warn before leaving page if images are present
1218 window.addEventListener('beforeunload', (e) => {
1219 	if (images.length > 0) {
1220 		e.preventDefault();
1221 		e.returnValue = '';
1222 		return '';
1223 	}
1224 });
1225 
1226 // Mobile file input handling
1227 const fileInput = document.getElementById('fileInput');
1228 const addImagesBtn = document.getElementById('addImagesBtn');
1229 
1230 addImagesBtn.addEventListener('click', () => {
1231 	fileInput.click();
1232 });
1233 
1234 fileInput.addEventListener('change', async (e) => {
1235 	await processAndAddImages(e.target.files, 0, 0);
1236 
1237 	// Clear the input so the same files can be selected again
1238 	fileInput.value = '';
1239 });
1240 
1241 function showPopup(message, displayDuration = 1250) {
1242 	if (!popupElement) {
1243 		popupElement = document.createElement('div');
1244 		popupElement.className = 'popup-notification';
1245 		document.body.appendChild(popupElement);
1246 	}
1247 
1248 	if (popupTimeout) clearTimeout(popupTimeout);
1249 
1250 	popupElement.textContent = message;
1251 	popupElement.classList.remove('fade-out');
1252 	popupElement.classList.add('show');
1253 
1254 	popupTimeout = setTimeout(() => {
1255 		popupElement.classList.remove('show');
1256 		popupElement.classList.add('fade-out');
1257 	}, displayDuration);
1258 }