add multiselect; rotate images and image groups about their centers

Hunter
Wednesday, July 29th, 2026 at 2:43 PM

index.html+1 
readme.md+2-1
resources/images.js+83 
resources/pointer.js+47-101
resources/selection.jsnew
resources/style.css+27 

index.html
21  	<script src="resources/grid.js"></script>
22  	<script src="resources/display.js"></script>
23  	<script src="resources/images.js"></script>
24 +	<script src="resources/selection.js"></script>
25  	<script src="resources/pointer.js"></script>
26  	<script src="resources/input.js"></script>
27  	<script src="resources/print.js"></script>
readme.md
31  	- or, when using a mouse, hover an image and use the `scrollwheel` to pan vertically, or use `shift + scrollwheel` to pan horizontally<br>
32  <img src="readme_images/pan.gif">
33  
   -
34  - ```⌘ + click``` an image to duplicate it<br>
35  <img src="readme_images/duplicate.gif">
36  

40  - ```shift + click``` an image to delete it<br>
41  <img src="readme_images/delete.gif">
42  
43 +- click an empty part of the grid and drag to select multiple images at once, then move, duplicate, rotate, or delete them as a group
44 +
45  - press ```F2``` to select from one of seven user interface themes (you may also have to hold ```Fn```)<br>
46  <img src="readme_images/select_theme.gif">
47  
resources/images.js
172  	return imageData;
173  }
174  
175 +// Rotate one or more images 90° clockwise about the center of their bounding box,
176 +// preserving layout; silently ignored when the turned block can't fit the grid at all
177 +function rotateImages(group) {
178 +	const minX = Math.min(...group.map(img => img.xCell));
179 +	const minY = Math.min(...group.map(img => img.yCell));
180 +	const maxX = Math.max(...group.map(img => img.xCell + img.widthCells));
181 +	const maxY = Math.max(...group.map(img => img.yCell + img.heightCells));
182 +	const width = maxX - minX;
183 +	const height = maxY - minY;
184 +
185 +	// Half-cell centers must snap to the grid; rounding away from zero keeps the offset
186 +	// symmetric, so four turns land back where they started
187 +	const offset = Math.sign(width - height) * Math.round(Math.abs(width - height) / 2);
188 +	const left = minX + offset;
189 +	const top = minY - offset;
190 +
191 +	const placed = group.map(img => ({
192 +		img,
193 +		xCell: left + height - (img.yCell - minY) - img.heightCells,
194 +		yCell: top + (img.xCell - minX),
195 +		widthCells: img.heightCells,
196 +		heightCells: img.widthCells
197 +	}));
198 +
199 +	// The turned block occupies the old box with its dimensions swapped
200 +	if (height > GRID_COLS || width > GRID_ROWS) return;
201 +
202 +	// Slide the whole block back on if the turn pushed it past an edge
203 +	const shiftX = left < 0 ? -left : Math.min(0, GRID_COLS - (left + height));
204 +	const shiftY = top < 0 ? -top : Math.min(0, GRID_ROWS - (top + width));
205 +
206 +	placed.forEach(p => {
207 +		p.img.xCell = p.xCell + shiftX;
208 +		p.img.yCell = p.yCell + shiftY;
209 +		p.img.widthCells = p.widthCells;
210 +		p.img.heightCells = p.heightCells;
211 +		// Pan stays in the image's original coordinate system - the CSS transform applies
212 +		// rotation before pan, so pan is relative to the rotated image
213 +		p.img.rotation = (p.img.rotation + 90) % 360;
214 +		updateImagePosition(p.img);
215 +	});
216 +}
217 +
218 +// Copy an image (and its pan/zoom/rotation) to a new grid position
219 +function duplicateImage(imageData, newXCell, newYCell) {
220 +	const imgElement = imageData.container.querySelector('img');
221 +	const newImageData = addImage(
222 +		imgElement.src,
223 +		newXCell,
224 +		newYCell,
225 +		imageData.widthCells,
226 +		imageData.heightCells
227 +	);
228 +
229 +	const { panX, panY, userScale, rotation } = imageData;
230 +	const applySettings = () => {
231 +		newImageData.panX = panX;
232 +		newImageData.panY = panY;
233 +		newImageData.userScale = userScale;
234 +		newImageData.rotation = rotation;
235 +		updateImagePosition(newImageData);
236 +	};
237 +
238 +	// Settings can only be applied once the copy's natural dimensions are known
239 +	const newImg = newImageData.container.querySelector('img');
240 +	const originalOnload = newImg.onload;
241 +	newImg.onload = () => {
242 +		if (originalOnload) originalOnload.call(newImg);
243 +		applySettings();
244 +	};
245 +
246 +	if (newImg.complete && newImageData.naturalWidth > 0) applySettings();
247 +
248 +	return newImageData;
249 +}
250 +
251 +function deleteImage(imageData) {
252 +	const index = images.indexOf(imageData);
253 +	if (index > -1) images.splice(index, 1);
254 +	selectedImages.delete(imageData);
255 +	imageData.container.remove();
256 +}
257 +
258  function calculatePanBounds(imageData) {
259  	// Container dimensions in the current (possibly swapped) grid orientation
260  	const bounds = getPixelPerfectBounds(imageData.xCell, imageData.yCell, imageData.widthCells, imageData.heightCells);
resources/pointer.js
 16  		document.body.style.cursor = 'grabbing';
 17  		document.body.classList.add('dragging');
 18  
 19 +		// Dragging a selected image moves the whole selection
 20 +		const group = isSelected(imageData) ? Array.from(selectedImages) : [imageData];
 21 +		const bounds = selectionBounds(group);
 22 +
 23  		dragState = {
 24  			image: imageData,
 25 +			group: group.map(img => ({ image: img, startXCell: img.xCell, startYCell: img.yCell })),
 26 +			minDx: -bounds.minX,
 27 +			maxDx: GRID_COLS - bounds.maxX,
 28 +			minDy: -bounds.minY,
 29 +			maxDy: GRID_ROWS - bounds.maxY,
 30  			startX: clientX,
 31  			startY: clientY,
 32  			startXCell: imageData.xCell,

 38  			hasMoved: false // Track if any movement has occurred
 39  		};
 40  
    -		container.classList.add('dragging');
 41 +		group.forEach(img => img.container.classList.add('dragging'));
 42  
    -		// For touch, set up long press timer to enable pan mode
    -		if (isTouch) {
 43 +		// For touch, set up long press timer to enable pan mode (single images only)
 44 +		if (isTouch && group.length === 1) {
 45  			const timerId = setTimeout(() => {
 46  				// Only activate pan mode if:
 47  				// 1. This drag state is still active

 73  		// Bring to front on touch
 74  		bringToFront(container);
 75  
 76 +		if (!isSelected(imageData)) clearSelection();
 77 +
 78  		if (e.touches.length === 1) {
 79  			// Single touch - start drag (or long press for pan)
 80  			e.preventDefault();

194  
195  		e.preventDefault();
196  
197 +		// Clicking outside the selection drops it; clicking inside acts on the whole selection
198 +		if (!isSelected(imageData)) clearSelection();
199 +		const usingSelection = isSelected(imageData);
200 +		const group = usingSelection ? Array.from(selectedImages) : [imageData];
201 +
202  		// Shift-click to delete
203  		if (e.shiftKey) {
    -			const index = images.indexOf(imageData);
    -			if (index > -1) {
    -				images.splice(index, 1);
    -			}
    -			container.remove();
204 +			group.forEach(deleteImage);
205  			return;
206  		}
207  
208  		// Option-click (or Alt-click on Windows/Linux) to rotate
209  		if (e.altKey) {
    -			// Check if rotation would cause dimension swap and if it would fit on grid
    -			const oldRotation = imageData.rotation;
    -			const newRotation = (imageData.rotation + 90) % 360;
    -
    -			// When rotating between portrait and landscape (90° or 270°), dimensions swap
    -			const willSwapDimensions = (oldRotation % 180 === 0 && newRotation % 180 !== 0) ||
    -			                           (oldRotation % 180 !== 0 && newRotation % 180 === 0);
    -
    -			if (willSwapDimensions) {
    -				// Check if swapped dimensions would fit on grid at current position
    -				const newWidthCells = imageData.heightCells;
    -				const newHeightCells = imageData.widthCells;
    -
    -				// Don't allow rotation if it would exceed grid bounds
    -				if (imageData.xCell + newWidthCells > GRID_COLS ||
    -				    imageData.yCell + newHeightCells > GRID_ROWS) {
    -					return; // Silently ignore the rotation
    -				}
    -
    -				// Swap width and height
    -				imageData.widthCells = newWidthCells;
    -				imageData.heightCells = newHeightCells;
    -			}
    -
    -			// Rotate 90 degrees clockwise
    -			imageData.rotation = newRotation;
    -
    -			// Don't rotate pan coordinates - they stay in the image's original coordinate system
    -			// The CSS transform applies rotation before pan, so pan is relative to the rotated image
    -
    -			updateImagePosition(imageData);
210 +			rotateImages(group);
211  			return;
212  		}
213  
214  		// Cmd-click (or Ctrl-click on Windows/Linux) to duplicate
215  		if (e.metaKey || e.ctrlKey) {
    -			// Calculate target position (1 cell right and 1 cell down)
    -			let newXCell = imageData.xCell + 1;
    -			let newYCell = imageData.yCell + 1;
    -
    -			// If there's not enough room, fall back to top-left
    -			if (newXCell + imageData.widthCells > GRID_COLS || newYCell + imageData.heightCells > GRID_ROWS) {
    -				newXCell = 0;
    -				newYCell = 0;
    -			}
    -
    -			// Create duplicate with the same image source and dimensions
    -			const imgElement = container.querySelector('img');
    -			const newImageData = addImage(
    -				imgElement.src,
    -				newXCell,
    -				newYCell,
    -				imageData.widthCells,
    -				imageData.heightCells
    -			);
    -
    -			// Copy pan, zoom, and rotation settings from original
    -			// Store the original settings to apply after image loads
    -			const originalPanX = imageData.panX;
    -			const originalPanY = imageData.panY;
    -			const originalUserScale = imageData.userScale;
    -			const originalRotation = imageData.rotation;
    -
    -			// Override the onload to copy settings
    -			const newImg = newImageData.container.querySelector('img');
    -			const originalOnload = newImg.onload;
    -			newImg.onload = () => {
    -				// Run the original onload first
    -				if (originalOnload) originalOnload.call(newImg);
    -
    -				// Then apply the copied settings
    -				newImageData.panX = originalPanX;
    -				newImageData.panY = originalPanY;
    -				newImageData.userScale = originalUserScale;
    -				newImageData.rotation = originalRotation;
    -				updateImagePosition(newImageData);
    -			};
    -
    -			// If image is already loaded (cached), trigger the settings copy
    -			if (newImg.complete && newImageData.naturalWidth > 0) {
    -				newImageData.panX = originalPanX;
    -				newImageData.panY = originalPanY;
    -				newImageData.userScale = originalUserScale;
    -				newImageData.rotation = originalRotation;
    -				updateImagePosition(newImageData);
216 +			// Offset the copies 1 cell down-right, pulled back in if that would leave the grid
217 +			const bounds = selectionBounds(group);
218 +			let dx = Math.min(1, GRID_COLS - bounds.maxX);
219 +			let dy = Math.min(1, GRID_ROWS - bounds.maxY);
220 +
221 +			// No room to offset - fall back to the top-left corner
222 +			if (dx === 0 && dy === 0) {
223 +				dx = -bounds.minX;
224 +				dy = -bounds.minY;
225  			}
226  
227 +			const copies = group.map(img => duplicateImage(img, img.xCell + dx, img.yCell + dy));
228 +			// The copies land under the cursor, so they inherit the selection
229 +			if (usingSelection) setSelection(copies);
230  			return;
231  		}
232  

311  		longPressTimer = null;
312  	}
313  	if (dragState) {
    -		dragState.image.container.classList.remove('dragging');
    -		dragState.image.container.classList.remove('pan-mode');
314 +		dragState.group.forEach(({ image }) => {
315 +			image.container.classList.remove('dragging');
316 +			image.container.classList.remove('pan-mode');
317 +		});
318  		dragState = null;
319  		document.body.style.cursor = '';
320  		document.body.classList.remove('dragging');

353  			clampPan(imageData);
354  			updateImagePosition(imageData);
355  		} else {
    -			// Normal drag mode - move the image container on the grid
356 +			// Normal drag mode - move the image container(s) on the grid
357  			const cellSize = getCellSize();
    -			const dxCells = Math.round(dx / cellSize.width);
    -			const dyCells = Math.round(dy / cellSize.height);
    -
    -			const newXCell = Math.max(0, Math.min(GRID_COLS - dragState.image.widthCells, dragState.startXCell + dxCells));
    -			const newYCell = Math.max(0, Math.min(GRID_ROWS - dragState.image.heightCells, dragState.startYCell + dyCells));
358 +			// Clamped so the selection's bounding box stays on the grid
359 +			const dxCells = Math.max(dragState.minDx, Math.min(dragState.maxDx, Math.round(dx / cellSize.width)));
360 +			const dyCells = Math.max(dragState.minDy, Math.min(dragState.maxDy, Math.round(dy / cellSize.height)));
361  
362  			// If the image moved to a new cell, cancel the long press timer
    -			if (dragState.isTouch && longPressTimer &&
    -			    (newXCell !== dragState.startXCell || newYCell !== dragState.startYCell)) {
363 +			if (dragState.isTouch && longPressTimer && (dxCells !== 0 || dyCells !== 0)) {
364  				clearTimeout(longPressTimer);
365  				longPressTimer = null;
366  			}
367  
    -			dragState.image.xCell = newXCell;
    -			dragState.image.yCell = newYCell;
    -			updateImagePosition(dragState.image);
368 +			dragState.group.forEach(({ image, startXCell, startYCell }) => {
369 +				image.xCell = startXCell + dxCells;
370 +				image.yCell = startYCell + dyCells;
371 +				updateImagePosition(image);
372 +			});
373  		}
374  	}
375  
resources/selection.js
  1 +// Marquee multi-select: drag a box over empty grid to select images, then move them as a unit
  2 +
  3 +let selectedImages = new Set();
  4 +let marqueeState = null;
  5 +
  6 +const selectionBox = document.createElement('div');
  7 +selectionBox.className = 'selection-box';
  8 +grid.appendChild(selectionBox);
  9 +
 10 +function isSelected(imageData) {
 11 +	return selectedImages.has(imageData);
 12 +}
 13 +
 14 +function setSelection(imageList) {
 15 +	clearSelection();
 16 +	imageList.forEach(img => {
 17 +		selectedImages.add(img);
 18 +		img.container.classList.add('selected');
 19 +	});
 20 +}
 21 +
 22 +function clearSelection() {
 23 +	selectedImages.forEach(img => img.container.classList.remove('selected'));
 24 +	selectedImages.clear();
 25 +}
 26 +
 27 +// Snap a client point to the nearest cell boundary (0..GRID_COLS / 0..GRID_ROWS)
 28 +function pointToBoundary(clientX, clientY) {
 29 +	const gridRect = grid.getBoundingClientRect();
 30 +	const cellSize = getCellSize();
 31 +	return {
 32 +		col: Math.max(0, Math.min(GRID_COLS, Math.round((clientX - gridRect.left) / cellSize.width))),
 33 +		row: Math.max(0, Math.min(GRID_ROWS, Math.round((clientY - gridRect.top) / cellSize.height)))
 34 +	};
 35 +}
 36 +
 37 +function marqueeRect() {
 38 +	return {
 39 +		x0: Math.min(marqueeState.anchorCol, marqueeState.col),
 40 +		y0: Math.min(marqueeState.anchorRow, marqueeState.row),
 41 +		x1: Math.max(marqueeState.anchorCol, marqueeState.col),
 42 +		y1: Math.max(marqueeState.anchorRow, marqueeState.row)
 43 +	};
 44 +}
 45 +
 46 +function updateMarquee() {
 47 +	const { x0, y0, x1, y1 } = marqueeRect();
 48 +	const widthCells = x1 - x0;
 49 +	const heightCells = y1 - y0;
 50 +
 51 +	if (widthCells === 0 || heightCells === 0) {
 52 +		selectionBox.style.display = 'none';
 53 +		clearSelection();
 54 +		return;
 55 +	}
 56 +
 57 +	const bounds = getPixelPerfectBounds(x0, y0, widthCells, heightCells);
 58 +	selectionBox.style.display = 'block';
 59 +	selectionBox.style.left = bounds.left + 'px';
 60 +	selectionBox.style.top = bounds.top + 'px';
 61 +	selectionBox.style.width = bounds.width + 'px';
 62 +	selectionBox.style.height = bounds.height + 'px';
 63 +
 64 +	setSelection(images.filter(img =>
 65 +		img.xCell < x1 && img.xCell + img.widthCells > x0 &&
 66 +		img.yCell < y1 && img.yCell + img.heightCells > y0
 67 +	));
 68 +}
 69 +
 70 +grid.addEventListener('mousedown', (e) => {
 71 +	if (e.button !== 0) return;
 72 +	if (e.target.closest('.image-container')) return;
 73 +
 74 +	e.preventDefault();
 75 +	clearSelection();
 76 +
 77 +	const { col, row } = pointToBoundary(e.clientX, e.clientY);
 78 +	marqueeState = { anchorCol: col, anchorRow: row, col, row };
 79 +	document.body.classList.add('selecting');
 80 +});
 81 +
 82 +function handleMarqueeMove(clientX, clientY) {
 83 +	if (!marqueeState) return;
 84 +	const { col, row } = pointToBoundary(clientX, clientY);
 85 +	if (col === marqueeState.col && row === marqueeState.row) return;
 86 +	marqueeState.col = col;
 87 +	marqueeState.row = row;
 88 +	updateMarquee();
 89 +}
 90 +
 91 +function endMarquee() {
 92 +	if (!marqueeState) return;
 93 +	marqueeState = null;
 94 +	selectionBox.style.display = 'none';
 95 +	document.body.classList.remove('selecting');
 96 +}
 97 +
 98 +document.addEventListener('mousemove', (e) => handleMarqueeMove(e.clientX, e.clientY));
 99 +document.addEventListener('mouseup', endMarquee);
100 +
101 +// Delete/Backspace deletes all selected images (same as shift+click)
102 +document.addEventListener('keydown', (e) => {
103 +	if (e.key !== 'Delete' && e.key !== 'Backspace') return;
104 +	if (selectedImages.size === 0) return;
105 +
106 +	e.preventDefault();
107 +	Array.from(selectedImages).forEach(deleteImage);
108 +});
109 +
110 +// Bounds shared by group drag, duplicate, and rotate
111 +function selectionBounds(group) {
112 +	return {
113 +		minX: Math.min(...group.map(img => img.xCell)),
114 +		minY: Math.min(...group.map(img => img.yCell)),
115 +		maxX: Math.max(...group.map(img => img.xCell + img.widthCells)),
116 +		maxY: Math.max(...group.map(img => img.yCell + img.heightCells))
117 +	};
118 +}
resources/style.css
277  	border-color: var(--accent);
278  }
279  
280 +.image-container.selected::after {
281 +	border-color: var(--accent);
282 +}
283 +
284  .image-container.dragging {
285  	opacity: 0.7;
286  }

330  	cursor: inherit !important;
331  }
332  
333 +.selection-box {
334 +	position: absolute;
335 +	display: none;
336 +	background: color-mix(in srgb, var(--accent) 50%, transparent);
337 +	pointer-events: none;
338 +	z-index: 999999;
339 +}
340 +
341 +body.selecting,
342 +body.selecting * {
343 +	cursor: crosshair !important;
344 +}
345 +
346 +body.selecting .image-container {
347 +	pointer-events: none;
348 +}
349 +
350  .image-container img {
351  	position: absolute;
352  	top: 50%;

366  }
367  
368  .image-container:hover .resize-handle,
369 +.image-container.selected .resize-handle,
370  .image-container.resizing .resize-handle {
371  	opacity: 1;
372  }

401  }
402  
403  .image-container:hover .dimension-label,
404 +.image-container.selected .dimension-label,
405  .image-container.dragging .dimension-label,
406  .image-container.resizing .dimension-label {
407  	opacity: 1;

564  		border: none !important;
565  	}
566  
567 +	.selection-box {
568 +		display: none !important;
569 +	}
570 +
571  	.image-container .image-wrapper {
572  		position: absolute !important;
573  		top: 0 !important;