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 }