add add images button on mobile; drop diagonally

Hunter
Wednesday, October 22nd, 2025 at 1:01 PM

index.html+3 
script.js+116-92
style.css+34 

index.html
13  		<div class="grid" id="grid"></div>
14  	</div>
15  
16 +	<input type="file" id="fileInput" accept="image/*" multiple style="display: none;">
17 +	<button id="addImagesBtn" class="add-images-btn">Add images</button>
18 +
19  	<script src="script.js"></script>
20  </body>
21  </html>
script.js
 25  let resizeState = null;
 26  let highestZIndex = 0;
 27  
 28 +// Helper function to calculate image dimensions from aspect ratio
 29 +function calculateImageDimensions(aspectRatio) {
 30 +	let widthCells, heightCells;
 31 +	if (aspectRatio >= 1) {
 32 +		heightCells = 5;
 33 +		widthCells = Math.round(heightCells * aspectRatio);
 34 +	} else {
 35 +		widthCells = 5;
 36 +		heightCells = Math.round(widthCells / aspectRatio);
 37 +	}
 38 +	return {
 39 +		widthCells: Math.min(widthCells, GRID_COLS),
 40 +		heightCells: Math.min(heightCells, GRID_ROWS)
 41 +	};
 42 +}
 43 +
 44 +// Helper function to load an image and get its dimensions
 45 +async function loadImageDimensions(file) {
 46 +	const reader = new FileReader();
 47 +	const dataUrl = await new Promise(resolve => {
 48 +		reader.onload = (e) => resolve(e.target.result);
 49 +		reader.readAsDataURL(file);
 50 +	});
 51 +
 52 +	const img = new Image();
 53 +	const dimensions = await new Promise(resolve => {
 54 +		img.onload = () => {
 55 +			const aspectRatio = img.width / img.height;
 56 +			resolve(calculateImageDimensions(aspectRatio));
 57 +		};
 58 +		img.src = dataUrl;
 59 +	});
 60 +
 61 +	return { dataUrl, ...dimensions };
 62 +}
 63 +
 64 +// Shared function to process and add images to the grid
 65 +async function processAndAddImages(files, dropX = 0, dropY = 0) {
 66 +	const imageFiles = Array.from(files).filter(f => f.type.startsWith('image/'));
 67 +	if (imageFiles.length === 0) return;
 68 +
 69 +	const cellSize = getCellSize();
 70 +
 71 +	// Load first image to get base dimensions for positioning
 72 +	const firstImageData = await loadImageDimensions(imageFiles[0]);
 73 +
 74 +	// Calculate base drop position
 75 +	// For single images, center on cursor; for multiple images, place top-left at cursor
 76 +	let baseXCell, baseYCell;
 77 +	if (imageFiles.length === 1) {
 78 +		baseXCell = Math.round(dropX / cellSize.width - firstImageData.widthCells / 2);
 79 +		baseYCell = Math.round(dropY / cellSize.height - firstImageData.heightCells / 2);
 80 +	} else {
 81 +		baseXCell = Math.round(dropX / cellSize.width);
 82 +		baseYCell = Math.round(dropY / cellSize.height);
 83 +	}
 84 +
 85 +	// Pre-allocate z-indexes to maintain drop order
 86 +	const baseZIndex = highestZIndex + 1;
 87 +	highestZIndex += imageFiles.length;
 88 +
 89 +	// Load all images
 90 +	const imageDataArray = [];
 91 +	for (let idx = 0; idx < imageFiles.length; idx++) {
 92 +		const data = idx === 0 ? firstImageData : await loadImageDimensions(imageFiles[idx]);
 93 +		imageDataArray.push({ idx, ...data });
 94 +	}
 95 +
 96 +	let wrappedOffset = 0;
 97 +
 98 +	for (const { idx, dataUrl, widthCells, heightCells } of imageDataArray) {
 99 +		// Calculate position with diagonal offset
100 +		let xCell = baseXCell + idx;
101 +		let yCell = baseYCell + idx;
102 +
103 +		// If out of bounds or would overlap with wrapped images, wrap to next diagonal position
104 +		const outOfBounds = xCell < 0 || yCell < 0 ||
105 +		                    xCell + widthCells > GRID_COLS ||
106 +		                    yCell + heightCells > GRID_ROWS;
107 +		const overlapsWrapped = xCell < wrappedOffset || yCell < wrappedOffset;
108 +
109 +		if (outOfBounds || overlapsWrapped) {
110 +			xCell = wrappedOffset;
111 +			yCell = wrappedOffset;
112 +			wrappedOffset++;
113 +		}
114 +
115 +		const imageData = addImage(dataUrl, xCell, yCell, widthCells, heightCells);
116 +		imageData.container.style.zIndex = baseZIndex + idx;
117 +	}
118 +}
119 +
120  // Prevent default drag behavior on entire document
121  document.addEventListener('dragover', (e) => {
122  	e.preventDefault();

127  	e.preventDefault();
128  	e.stopPropagation();
129  
    -	const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
    -	if (files.length === 0) return;
    -
130  	// Get drop position relative to the grid
131  	const gridRect = grid.getBoundingClientRect();
    -	const cellSize = getCellSize();
    -
    -	// Clamp drop position to grid boundaries
    -	let dropX = e.clientX - gridRect.left;
    -	let dropY = e.clientY - gridRect.top;
    -	dropX = Math.max(0, Math.min(gridRect.width, dropX));
    -	dropY = Math.max(0, Math.min(gridRect.height, dropY));
    -
    -	// Load first image to get base dimensions
    -	let firstImageDimensions = null;
    -	if (files.length > 0) {
    -		const firstFile = files[0];
    -		const reader = new FileReader();
    -		const dataUrl = await new Promise(resolve => {
    -			reader.onload = (e) => resolve(e.target.result);
    -			reader.readAsDataURL(firstFile);
    -		});
    -
    -		const img = new Image();
    -		await new Promise(resolve => {
    -			img.onload = () => {
    -				const aspectRatio = img.width / img.height;
    -				let widthCells, heightCells;
    -				if (aspectRatio >= 1) {
    -					heightCells = 5;
    -					widthCells = Math.round(heightCells * aspectRatio);
    -				} else {
    -					widthCells = 5;
    -					heightCells = Math.round(widthCells / aspectRatio);
    -				}
    -				widthCells = Math.min(widthCells, GRID_COLS);
    -				heightCells = Math.min(heightCells, GRID_ROWS);
    -				firstImageDimensions = { widthCells, heightCells };
    -				resolve();
    -			};
    -			img.src = dataUrl;
    -		});
    -	}
    -
    -	// Now process all files
    -	files.forEach((file, idx) => {
    -		const reader = new FileReader();
    -		reader.onload = (event) => {
    -			const img = new Image();
    -			img.onload = () => {
    -				const aspectRatio = img.width / img.height;
    -
    -				// Set smaller dimension to 5 cells, calculate larger dimension
    -				let widthCells, heightCells;
    -				if (aspectRatio >= 1) {
    -					// Width is larger or equal
    -					heightCells = 5;
    -					widthCells = Math.round(heightCells * aspectRatio);
    -				} else {
    -					// Height is larger
    -					widthCells = 5;
    -					heightCells = Math.round(widthCells / aspectRatio);
    -				}
    -
    -				// Clamp to grid boundaries
    -				widthCells = Math.min(widthCells, GRID_COLS);
    -				heightCells = Math.min(heightCells, GRID_ROWS);
132 +	const dropX = e.clientX - gridRect.left;
133 +	const dropY = e.clientY - gridRect.top;
134  
    -				// Calculate position with first image's center under drop point
    -				let xCell = Math.round(dropX / cellSize.width - firstImageDimensions.widthCells / 2) + (idx * 2);
    -				let yCell = Math.round(dropY / cellSize.height - firstImageDimensions.heightCells / 2);
    -
    -				// Ensure image stays within grid bounds
    -				xCell = Math.max(0, Math.min(GRID_COLS - widthCells, xCell));
    -				yCell = Math.max(0, Math.min(GRID_ROWS - heightCells, yCell));
    -
    -				addImage(event.target.result, xCell, yCell, widthCells, heightCells);
    -			};
    -			img.src = event.target.result;
    -		};
    -		reader.readAsDataURL(file);
    -	});
135 +	await processAndAddImages(e.dataTransfer.files, dropX, dropY);
136  });
137  
138  function addImage(src, xCell, yCell, widthCells, heightCells) {

377  
378  		// Cmd-click (or Ctrl-click on Windows/Linux) to duplicate
379  		if (e.metaKey || e.ctrlKey) {
    -			// Calculate target position (2 cells to the right if there's room)
    -			let newXCell = imageData.xCell + 2;
    -			let newYCell = imageData.yCell;
380 +			// Calculate target position (1 cell right and 1 cell down)
381 +			let newXCell = imageData.xCell + 1;
382 +			let newYCell = imageData.yCell + 1;
383  
    -			// If there's not enough room to the right, try to place appropriately
    -			if (newXCell + imageData.widthCells > GRID_COLS) {
    -				// Try wrapping to next row
384 +			// If there's not enough room, fall back to top-left
385 +			if (newXCell + imageData.widthCells > GRID_COLS || newYCell + imageData.heightCells > GRID_ROWS) {
386  				newXCell = 0;
    -				newYCell = imageData.yCell + imageData.heightCells;
    -
    -				// If that goes off the bottom, place at origin
    -				if (newYCell + imageData.heightCells > GRID_ROWS) {
    -					newXCell = 0;
    -					newYCell = 0;
    -				}
387 +				newYCell = 0;
388  			}
389  
390  			// Create duplicate with the same image source and dimensions

720  		return '';
721  	}
722  });
723 +
724 +// Mobile file input handling
725 +const fileInput = document.getElementById('fileInput');
726 +const addImagesBtn = document.getElementById('addImagesBtn');
727 +
728 +addImagesBtn.addEventListener('click', () => {
729 +	fileInput.click();
730 +});
731 +
732 +fileInput.addEventListener('change', async (e) => {
733 +	await processAndAddImages(e.target.files, 0, 0);
734 +
735 +	// Clear the input so the same files can be selected again
736 +	fileInput.value = '';
737 +});
style.css
436  	size: 8.5in 11in;
437  	margin: 0;
438  }
439 +
440 +.add-images-btn {
441 +	display: none;
442 +	position: fixed;
443 +	bottom: 24px;
444 +	left: 50%;
445 +	transform: translateX(-50%);
446 +	background: var(--accent);
447 +	color: var(--page);
448 +	border: none;
449 +	border-radius: 100px;
450 +	padding: 14px 28px;
451 +	font-size: 16px;
452 +	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
453 +	font-weight: 600;
454 +	cursor: pointer;
455 +	box-shadow: 0 4px 12px rgba(0,0,0,0.2);
456 +	z-index: 10000;
457 +	-webkit-tap-highlight-color: transparent;
458 +	user-select: none;
459 +	-webkit-user-select: none;
460 +	transition: transform 0.1s ease, box-shadow 0.1s ease;
461 +}
462 +
463 +.add-images-btn:active {
464 +	transform: translateX(-50%) scale(0.95);
465 +	box-shadow: 0 2px 8px rgba(0,0,0,0.2);
466 +}
467 +
468 +@media (pointer: coarse), (hover: none) {
469 +	.add-images-btn {
470 +		display: block;
471 +	}
472 +}