add multiselect; rotate images and image groups about their centers
Hunter
Wednesday, July 29th, 2026 at 2:43 PM
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>
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
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);
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
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 +}
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;