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 }