images.js (14.4 KB)
1 // Image state, the add pipeline, and the transforms that place images on the grid 2 3 let images = []; 4 let dragState = null; 5 let resizeState = null; 6 let highestZIndex = 0; 7 let touchState = null; // For tracking multi-touch gestures 8 let longPressTimer = null; // For detecting long press to enable pan mode 9 10 // Helper function to calculate image dimensions from aspect ratio 11 function calculateImageDimensions(aspectRatio) { 12 let widthCells, heightCells; 13 if (aspectRatio >= 1) { 14 heightCells = 5; 15 widthCells = Math.round(heightCells * aspectRatio); 16 } else { 17 widthCells = 5; 18 heightCells = Math.round(widthCells / aspectRatio); 19 } 20 return { 21 widthCells: Math.min(widthCells, GRID_COLS), 22 heightCells: Math.min(heightCells, GRID_ROWS) 23 }; 24 } 25 26 // Helper function to load an image and get its dimensions 27 async function loadImageDimensions(file) { 28 const reader = new FileReader(); 29 const dataUrl = await new Promise(resolve => { 30 reader.onload = (e) => resolve(e.target.result); 31 reader.readAsDataURL(file); 32 }); 33 34 const img = new Image(); 35 const dimensions = await new Promise(resolve => { 36 img.onload = () => { 37 const aspectRatio = img.naturalWidth / img.naturalHeight; 38 resolve({ 39 ...calculateImageDimensions(aspectRatio), 40 naturalWidth: img.naturalWidth, 41 naturalHeight: img.naturalHeight 42 }); 43 }; 44 img.src = dataUrl; 45 }); 46 47 return { dataUrl, ...dimensions }; 48 } 49 50 // Shared function to process and add images to the grid 51 async function processAndAddImages(files, dropX = 0, dropY = 0) { 52 const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/')); 53 if (imageFiles.length === 0) return; 54 55 const cellSize = getCellSize(); 56 57 // Load first image to get base dimensions for positioning 58 const firstImageData = await loadImageDimensions(imageFiles[0]); 59 60 // Calculate base drop position 61 // For single images, center on cursor; for multiple images, place top-left at cursor 62 let baseXCell, baseYCell; 63 if (imageFiles.length === 1) { 64 baseXCell = Math.round(dropX / cellSize.width - firstImageData.widthCells / 2); 65 baseYCell = Math.round(dropY / cellSize.height - firstImageData.heightCells / 2); 66 } else { 67 baseXCell = Math.round(dropX / cellSize.width); 68 baseYCell = Math.round(dropY / cellSize.height); 69 } 70 71 // Pre-allocate z-indexes to maintain drop order 72 const baseZIndex = highestZIndex + 1; 73 highestZIndex += imageFiles.length; 74 75 // Load all images 76 const imageDataArray = []; 77 for (let idx = 0; idx < imageFiles.length; idx++) { 78 const data = idx === 0 ? firstImageData : await loadImageDimensions(imageFiles[idx]); 79 imageDataArray.push({ idx, ...data }); 80 } 81 82 // Shift the whole fan back onto the grid before placing anything, so a drop near an edge keeps 83 // its diagonal spacing instead of collapsing image by image 84 const fanMaxX = Math.max(...imageDataArray.map(({ idx, widthCells }) => baseXCell + idx + widthCells)); 85 const fanMaxY = Math.max(...imageDataArray.map(({ idx, heightCells }) => baseYCell + idx + heightCells)); 86 const fanShiftX = Math.max(Math.min(0, GRID_COLS - fanMaxX), -baseXCell); 87 const fanShiftY = Math.max(Math.min(0, GRID_ROWS - fanMaxY), -baseYCell); 88 89 let wrappedOffset = 0; 90 const placed = []; 91 92 for (const { idx, dataUrl, widthCells, heightCells, naturalWidth, naturalHeight } of imageDataArray) { 93 // Calculate position with diagonal offset, kept as close to the drop point as the grid allows 94 const maxXCell = GRID_COLS - widthCells; 95 const maxYCell = GRID_ROWS - heightCells; 96 let xCell = Math.max(0, Math.min(baseXCell + idx + fanShiftX, maxXCell)); 97 let yCell = Math.max(0, Math.min(baseYCell + idx + fanShiftY, maxYCell)); 98 99 // Clamping can pile a multi-image drop onto one cell; cascade those from the top-left instead 100 const stacked = placed.some(p => p.xCell === xCell && p.yCell === yCell); 101 if (stacked) { 102 xCell = Math.min(wrappedOffset, maxXCell); 103 yCell = Math.min(wrappedOffset, maxYCell); 104 wrappedOffset++; 105 } 106 placed.push({ xCell, yCell }); 107 108 const imageData = addImage(dataUrl, xCell, yCell, widthCells, heightCells, { naturalWidth, naturalHeight }); 109 imageData.container.style.zIndex = baseZIndex + idx; 110 } 111 } 112 113 function addImage(src, xCell, yCell, widthCells, heightCells, initialState) { 114 const container = document.createElement('div'); 115 container.className = 'image-container'; 116 highestZIndex++; 117 container.style.zIndex = highestZIndex; 118 119 const wrapper = document.createElement('div'); 120 wrapper.className = 'image-wrapper'; 121 const img = document.createElement('img'); 122 img.src = src; 123 wrapper.appendChild(img); 124 container.appendChild(wrapper); 125 126 // Add resize handles 127 const handles = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']; 128 handles.forEach(dir => { 129 const handle = document.createElement('div'); 130 handle.className = `resize-handle ${dir.length === 1 ? 'edge' : 'corner'} ${dir}`; 131 handle.dataset.direction = dir; 132 container.appendChild(handle); 133 }); 134 135 // Add dimension labels 136 const widthLabel = document.createElement('div'); 137 widthLabel.className = 'dimension-label width'; 138 widthLabel.textContent = widthCells; 139 container.appendChild(widthLabel); 140 141 const heightLabel = document.createElement('div'); 142 heightLabel.className = 'dimension-label height'; 143 heightLabel.textContent = heightCells; 144 container.appendChild(heightLabel); 145 146 const imageData = { 147 container, 148 xCell, 149 yCell, 150 widthCells, 151 heightCells, 152 // Image positioning within container (in pixels, relative to center) 153 panX: 0, 154 panY: 0, 155 userScale: 1, // user zoom level (1-5) 156 rotation: 0, // rotation in degrees (0, 90, 180, 270) 157 // Store natural image dimensions for calculations 158 naturalWidth: 0, 159 naturalHeight: 0, 160 baseScale: 1, // scale needed to cover container 161 // Known up front when copying an already-loaded image, so the first paint is correct 162 ...initialState 163 }; 164 165 // Calculate dimensions and scale once image loads 166 img.onload = () => { 167 imageData.naturalWidth = img.naturalWidth; 168 imageData.naturalHeight = img.naturalHeight; 169 updateImagePosition(imageData); 170 }; 171 images.push(imageData); 172 173 updateImagePosition(imageData); 174 grid.appendChild(container); 175 176 setupImageHandlers(imageData); 177 178 return imageData; 179 } 180 181 // Rotate one or more images 90° clockwise about the center of their bounding box, 182 // preserving layout; silently ignored when the turned block can't fit the grid at all 183 function rotateImages(group) { 184 const minX = Math.min(...group.map(img => img.xCell)); 185 const minY = Math.min(...group.map(img => img.yCell)); 186 const maxX = Math.max(...group.map(img => img.xCell + img.widthCells)); 187 const maxY = Math.max(...group.map(img => img.yCell + img.heightCells)); 188 const width = maxX - minX; 189 const height = maxY - minY; 190 191 // Half-cell centers must snap to the grid; rounding away from zero keeps the offset 192 // symmetric, so four turns land back where they started 193 const offset = Math.sign(width - height) * Math.round(Math.abs(width - height) / 2); 194 const left = minX + offset; 195 const top = minY - offset; 196 197 const placed = group.map(img => ({ 198 img, 199 xCell: left + height - (img.yCell - minY) - img.heightCells, 200 yCell: top + (img.xCell - minX), 201 widthCells: img.heightCells, 202 heightCells: img.widthCells 203 })); 204 205 // The turned block occupies the old box with its dimensions swapped 206 if (height > GRID_COLS || width > GRID_ROWS) return; 207 208 // Slide the whole block back on if the turn pushed it past an edge 209 const shiftX = left < 0 ? -left : Math.min(0, GRID_COLS - (left + height)); 210 const shiftY = top < 0 ? -top : Math.min(0, GRID_ROWS - (top + width)); 211 212 placed.forEach(p => { 213 p.img.xCell = p.xCell + shiftX; 214 p.img.yCell = p.yCell + shiftY; 215 p.img.widthCells = p.widthCells; 216 p.img.heightCells = p.heightCells; 217 // Pan stays in the image's original coordinate system - the CSS transform applies 218 // rotation before pan, so pan is relative to the rotated image 219 p.img.rotation = (p.img.rotation + 90) % 360; 220 updateImagePosition(p.img); 221 }); 222 } 223 224 // Copy an image (and its pan/zoom/rotation) to a new grid position 225 function duplicateImage(imageData, newXCell, newYCell) { 226 const imgElement = imageData.container.querySelector('img'); 227 const { panX, panY, userScale, rotation, naturalWidth, naturalHeight } = imageData; 228 229 // The source is already loaded, so the copy's crop can be set before it ever paints 230 return addImage( 231 imgElement.src, 232 newXCell, 233 newYCell, 234 imageData.widthCells, 235 imageData.heightCells, 236 { panX, panY, userScale, rotation, naturalWidth, naturalHeight } 237 ); 238 } 239 240 function deleteImage(imageData) { 241 const index = images.indexOf(imageData); 242 if (index > -1) images.splice(index, 1); 243 selectedImages.delete(imageData); 244 imageData.container.remove(); 245 } 246 247 function calculatePanBounds(imageData) { 248 // Container dimensions in the current (possibly swapped) grid orientation 249 const bounds = getPixelPerfectBounds(imageData.xCell, imageData.yCell, imageData.widthCells, imageData.heightCells); 250 const containerWidth = bounds.width; 251 const containerHeight = bounds.height; 252 253 if (imageData.naturalWidth === 0 || imageData.naturalHeight === 0) { 254 return { maxPanX: 0, maxPanY: 0 }; 255 } 256 257 // Pan coordinates are in the image's original coordinate system (before scale and rotation) 258 // So we need to calculate bounds based on the original image dimensions 259 const isRotated90or270 = imageData.rotation % 180 !== 0; 260 261 // For pan bounds, we need to match image dimensions to container dimensions 262 // in the image's coordinate space (not the screen's coordinate space) 263 // When rotated 90/270, panX constrains vertical screen movement (maps to container height) 264 // and panY constrains horizontal screen movement (maps to container width) 265 const effectiveContainerWidth = isRotated90or270 ? containerHeight : containerWidth; 266 const effectiveContainerHeight = isRotated90or270 ? containerWidth : containerHeight; 267 268 // Pan values are in pre-scale image space, but the CSS transform scales them 269 // So we need to calculate bounds in pre-scale space 270 // The image's natural size minus the container size (in pre-scale space) gives us the overhang 271 const totalScale = imageData.baseScale * imageData.userScale; 272 const containerWidthInImageSpace = effectiveContainerWidth / totalScale; 273 const containerHeightInImageSpace = effectiveContainerHeight / totalScale; 274 275 // Calculate maximum pan in each direction (in pre-scale image space) 276 const maxPanX = Math.max(0, (imageData.naturalWidth - containerWidthInImageSpace) / 2); 277 const maxPanY = Math.max(0, (imageData.naturalHeight - containerHeightInImageSpace) / 2); 278 279 return { maxPanX, maxPanY }; 280 } 281 282 function clampPan(imageData) { 283 const { maxPanX, maxPanY } = calculatePanBounds(imageData); 284 imageData.panX = Math.max(-maxPanX, Math.min(maxPanX, imageData.panX)); 285 imageData.panY = Math.max(-maxPanY, Math.min(maxPanY, imageData.panY)); 286 } 287 288 // Helper to transform screen-space coordinates to image coordinate space (accounting for rotation) 289 function rotatePoint(x, y, angleDegrees) { 290 const angle = -angleDegrees * Math.PI / 180; 291 const cos = Math.cos(angle); 292 const sin = Math.sin(angle); 293 return { 294 x: x * cos - y * sin, 295 y: x * sin + y * cos 296 }; 297 } 298 299 // Helper to apply pan adjustment based on screen delta 300 function applyPanDelta(imageData, screenDeltaX, screenDeltaY) { 301 const rotated = rotatePoint(screenDeltaX, screenDeltaY, imageData.rotation); 302 const totalScale = imageData.baseScale * imageData.userScale; 303 imageData.panX += rotated.x / totalScale; 304 imageData.panY += rotated.y / totalScale; 305 } 306 307 // Helper to calculate zoom-centered pan adjustment 308 function adjustPanForZoom(imageData, cursorX, cursorY, oldTotalScale, newTotalScale) { 309 const rotated = rotatePoint(cursorX, cursorY, imageData.rotation); 310 const scaleDiff = 1/newTotalScale - 1/oldTotalScale; 311 imageData.panX += rotated.x * scaleDiff; 312 imageData.panY += rotated.y * scaleDiff; 313 } 314 315 function updateImagePosition(img) { 316 // Use pixel-perfect bounds to prevent subpixel accumulation 317 const bounds = getPixelPerfectBounds(img.xCell, img.yCell, img.widthCells, img.heightCells); 318 img.container.style.left = bounds.left + 'px'; 319 img.container.style.top = bounds.top + 'px'; 320 img.container.style.width = bounds.width + 'px'; 321 img.container.style.height = bounds.height + 'px'; 322 323 // Store cell positions as CSS variables for print styles 324 img.container.style.setProperty('--x-cell', img.xCell); 325 img.container.style.setProperty('--y-cell', img.yCell); 326 img.container.style.setProperty('--width-cells', img.widthCells); 327 img.container.style.setProperty('--height-cells', img.heightCells); 328 329 // Update dimension labels 330 const widthLabel = img.container.querySelector('.dimension-label.width'); 331 const heightLabel = img.container.querySelector('.dimension-label.height'); 332 if (widthLabel) { 333 // Only update text if dimension is >= 5 334 if (img.widthCells >= 5) { 335 widthLabel.textContent = img.widthCells; 336 } 337 widthLabel.dataset.hidden = img.widthCells < 5 ? 'true' : 'false'; 338 } 339 if (heightLabel) { 340 // Only update text if dimension is >= 5 341 if (img.heightCells >= 5) { 342 heightLabel.textContent = img.heightCells; 343 } 344 heightLabel.dataset.hidden = img.heightCells < 5 ? 'true' : 'false'; 345 } 346 347 // Recalculate baseScale if container size changed 348 if (img.naturalWidth > 0 && img.naturalHeight > 0) { 349 // Use pixel-perfect bounds for container dimensions 350 const containerWidth = bounds.width; 351 const containerHeight = bounds.height; 352 353 // When rotated 90° or 270°, the image dimensions are effectively swapped 354 const isRotated90or270 = img.rotation % 180 !== 0; 355 const effectiveWidth = isRotated90or270 ? img.naturalHeight : img.naturalWidth; 356 const effectiveHeight = isRotated90or270 ? img.naturalWidth : img.naturalHeight; 357 358 const scaleX = containerWidth / effectiveWidth; 359 const scaleY = containerHeight / effectiveHeight; 360 img.baseScale = Math.max(scaleX, scaleY); 361 362 // Reclamp pan after recalculating base scale 363 clampPan(img); 364 } 365 366 // Apply image positioning and scale using transform 367 const imgElement = img.container.querySelector('img'); 368 if (imgElement) { 369 const totalScale = img.baseScale * img.userScale; 370 // Transform: translate from center (-50%, -50%), scale, rotate, then pan 371 // Pan is applied after rotation so it stays relative to the image's rotated state 372 imgElement.style.transform = `translate(-50%, -50%) scale(${totalScale}) rotate(${img.rotation}deg) translate(${img.panX}px, ${img.panY}px)`; 373 } 374 } 375 376 function bringToFront(container) { 377 highestZIndex++; 378 container.style.zIndex = highestZIndex; 379 } 380 381 // Raise a whole group above everything else, keeping its members' order relative to one another 382 function bringGroupToFront(group) { 383 [...group] 384 .sort((a, b) => (parseInt(a.container.style.zIndex, 10) || 0) - (parseInt(b.container.style.zIndex, 10) || 0)) 385 .forEach(img => bringToFront(img.container)); 386 }