commit 558740cb1b6d017c8eaeea2aeaee8277f374972b
parent 476b77dbefe152e6382edb54eb4376b366f852c9
Author: Hunter
Date: Wed, 29 Jul 2026 14:43:09 -0400
add multiselect; rotate images and image groups about their centers
Diffstat:
6 files changed, 278 insertions(+), 102 deletions(-)
diff --git a/index.html b/index.html
@@ -21,6 +21,7 @@
<script src="resources/grid.js"></script>
<script src="resources/display.js"></script>
<script src="resources/images.js"></script>
+ <script src="resources/selection.js"></script>
<script src="resources/pointer.js"></script>
<script src="resources/input.js"></script>
<script src="resources/print.js"></script>
diff --git a/readme.md b/readme.md
@@ -31,7 +31,6 @@ Different Techo types use differently-sized grid paper. Use the links below to s
- or, when using a mouse, hover an image and use the `scrollwheel` to pan vertically, or use `shift + scrollwheel` to pan horizontally<br>
<img src="readme_images/pan.gif">
-
- ```⌘ + click``` an image to duplicate it<br>
<img src="readme_images/duplicate.gif">
@@ -41,6 +40,8 @@ Different Techo types use differently-sized grid paper. Use the links below to s
- ```shift + click``` an image to delete it<br>
<img src="readme_images/delete.gif">
+- 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
+
- press ```F2``` to select from one of seven user interface themes (you may also have to hold ```Fn```)<br>
<img src="readme_images/select_theme.gif">
diff --git a/resources/images.js b/resources/images.js
@@ -172,6 +172,89 @@ function addImage(src, xCell, yCell, widthCells, heightCells) {
return imageData;
}
+// Rotate one or more images 90° clockwise about the center of their bounding box,
+// preserving layout; silently ignored when the turned block can't fit the grid at all
+function rotateImages(group) {
+ const minX = Math.min(...group.map(img => img.xCell));
+ const minY = Math.min(...group.map(img => img.yCell));
+ const maxX = Math.max(...group.map(img => img.xCell + img.widthCells));
+ const maxY = Math.max(...group.map(img => img.yCell + img.heightCells));
+ const width = maxX - minX;
+ const height = maxY - minY;
+
+ // Half-cell centers must snap to the grid; rounding away from zero keeps the offset
+ // symmetric, so four turns land back where they started
+ const offset = Math.sign(width - height) * Math.round(Math.abs(width - height) / 2);
+ const left = minX + offset;
+ const top = minY - offset;
+
+ const placed = group.map(img => ({
+ img,
+ xCell: left + height - (img.yCell - minY) - img.heightCells,
+ yCell: top + (img.xCell - minX),
+ widthCells: img.heightCells,
+ heightCells: img.widthCells
+ }));
+
+ // The turned block occupies the old box with its dimensions swapped
+ if (height > GRID_COLS || width > GRID_ROWS) return;
+
+ // Slide the whole block back on if the turn pushed it past an edge
+ const shiftX = left < 0 ? -left : Math.min(0, GRID_COLS - (left + height));
+ const shiftY = top < 0 ? -top : Math.min(0, GRID_ROWS - (top + width));
+
+ placed.forEach(p => {
+ p.img.xCell = p.xCell + shiftX;
+ p.img.yCell = p.yCell + shiftY;
+ p.img.widthCells = p.widthCells;
+ p.img.heightCells = p.heightCells;
+ // Pan stays in the image's original coordinate system - the CSS transform applies
+ // rotation before pan, so pan is relative to the rotated image
+ p.img.rotation = (p.img.rotation + 90) % 360;
+ updateImagePosition(p.img);
+ });
+}
+
+// Copy an image (and its pan/zoom/rotation) to a new grid position
+function duplicateImage(imageData, newXCell, newYCell) {
+ const imgElement = imageData.container.querySelector('img');
+ const newImageData = addImage(
+ imgElement.src,
+ newXCell,
+ newYCell,
+ imageData.widthCells,
+ imageData.heightCells
+ );
+
+ const { panX, panY, userScale, rotation } = imageData;
+ const applySettings = () => {
+ newImageData.panX = panX;
+ newImageData.panY = panY;
+ newImageData.userScale = userScale;
+ newImageData.rotation = rotation;
+ updateImagePosition(newImageData);
+ };
+
+ // Settings can only be applied once the copy's natural dimensions are known
+ const newImg = newImageData.container.querySelector('img');
+ const originalOnload = newImg.onload;
+ newImg.onload = () => {
+ if (originalOnload) originalOnload.call(newImg);
+ applySettings();
+ };
+
+ if (newImg.complete && newImageData.naturalWidth > 0) applySettings();
+
+ return newImageData;
+}
+
+function deleteImage(imageData) {
+ const index = images.indexOf(imageData);
+ if (index > -1) images.splice(index, 1);
+ selectedImages.delete(imageData);
+ imageData.container.remove();
+}
+
function calculatePanBounds(imageData) {
// Container dimensions in the current (possibly swapped) grid orientation
const bounds = getPixelPerfectBounds(imageData.xCell, imageData.yCell, imageData.widthCells, imageData.heightCells);
diff --git a/resources/pointer.js b/resources/pointer.js
@@ -16,8 +16,17 @@ function setupImageHandlers(imageData) {
document.body.style.cursor = 'grabbing';
document.body.classList.add('dragging');
+ // Dragging a selected image moves the whole selection
+ const group = isSelected(imageData) ? Array.from(selectedImages) : [imageData];
+ const bounds = selectionBounds(group);
+
dragState = {
image: imageData,
+ group: group.map(img => ({ image: img, startXCell: img.xCell, startYCell: img.yCell })),
+ minDx: -bounds.minX,
+ maxDx: GRID_COLS - bounds.maxX,
+ minDy: -bounds.minY,
+ maxDy: GRID_ROWS - bounds.maxY,
startX: clientX,
startY: clientY,
startXCell: imageData.xCell,
@@ -29,10 +38,10 @@ function setupImageHandlers(imageData) {
hasMoved: false // Track if any movement has occurred
};
- container.classList.add('dragging');
+ group.forEach(img => img.container.classList.add('dragging'));
- // For touch, set up long press timer to enable pan mode
- if (isTouch) {
+ // For touch, set up long press timer to enable pan mode (single images only)
+ if (isTouch && group.length === 1) {
const timerId = setTimeout(() => {
// Only activate pan mode if:
// 1. This drag state is still active
@@ -64,6 +73,8 @@ function setupImageHandlers(imageData) {
// Bring to front on touch
bringToFront(container);
+ if (!isSelected(imageData)) clearSelection();
+
if (e.touches.length === 1) {
// Single touch - start drag (or long press for pan)
e.preventDefault();
@@ -183,105 +194,39 @@ function setupImageHandlers(imageData) {
e.preventDefault();
+ // Clicking outside the selection drops it; clicking inside acts on the whole selection
+ if (!isSelected(imageData)) clearSelection();
+ const usingSelection = isSelected(imageData);
+ const group = usingSelection ? Array.from(selectedImages) : [imageData];
+
// Shift-click to delete
if (e.shiftKey) {
- const index = images.indexOf(imageData);
- if (index > -1) {
- images.splice(index, 1);
- }
- container.remove();
+ group.forEach(deleteImage);
return;
}
// Option-click (or Alt-click on Windows/Linux) to rotate
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);
+ rotateImages(group);
return;
}
// Cmd-click (or Ctrl-click on Windows/Linux) to duplicate
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);
+ // Offset the copies 1 cell down-right, pulled back in if that would leave the grid
+ const bounds = selectionBounds(group);
+ let dx = Math.min(1, GRID_COLS - bounds.maxX);
+ let dy = Math.min(1, GRID_ROWS - bounds.maxY);
+
+ // No room to offset - fall back to the top-left corner
+ if (dx === 0 && dy === 0) {
+ dx = -bounds.minX;
+ dy = -bounds.minY;
}
+ const copies = group.map(img => duplicateImage(img, img.xCell + dx, img.yCell + dy));
+ // The copies land under the cursor, so they inherit the selection
+ if (usingSelection) setSelection(copies);
return;
}
@@ -366,8 +311,10 @@ function clearDragState() {
longPressTimer = null;
}
if (dragState) {
- dragState.image.container.classList.remove('dragging');
- dragState.image.container.classList.remove('pan-mode');
+ dragState.group.forEach(({ image }) => {
+ image.container.classList.remove('dragging');
+ image.container.classList.remove('pan-mode');
+ });
dragState = null;
document.body.style.cursor = '';
document.body.classList.remove('dragging');
@@ -406,24 +353,23 @@ function handleMove(clientX, clientY) {
clampPan(imageData);
updateImagePosition(imageData);
} else {
- // Normal drag mode - move the image container on the grid
+ // Normal drag mode - move the image container(s) on the grid
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));
+ // Clamped so the selection's bounding box stays on the grid
+ const dxCells = Math.max(dragState.minDx, Math.min(dragState.maxDx, Math.round(dx / cellSize.width)));
+ const dyCells = Math.max(dragState.minDy, Math.min(dragState.maxDy, Math.round(dy / cellSize.height)));
// If the image moved to a new cell, cancel the long press timer
- if (dragState.isTouch && longPressTimer &&
- (newXCell !== dragState.startXCell || newYCell !== dragState.startYCell)) {
+ if (dragState.isTouch && longPressTimer && (dxCells !== 0 || dyCells !== 0)) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
- dragState.image.xCell = newXCell;
- dragState.image.yCell = newYCell;
- updateImagePosition(dragState.image);
+ dragState.group.forEach(({ image, startXCell, startYCell }) => {
+ image.xCell = startXCell + dxCells;
+ image.yCell = startYCell + dyCells;
+ updateImagePosition(image);
+ });
}
}
diff --git a/resources/selection.js b/resources/selection.js
@@ -0,0 +1,118 @@
+// Marquee multi-select: drag a box over empty grid to select images, then move them as a unit
+
+let selectedImages = new Set();
+let marqueeState = null;
+
+const selectionBox = document.createElement('div');
+selectionBox.className = 'selection-box';
+grid.appendChild(selectionBox);
+
+function isSelected(imageData) {
+ return selectedImages.has(imageData);
+}
+
+function setSelection(imageList) {
+ clearSelection();
+ imageList.forEach(img => {
+ selectedImages.add(img);
+ img.container.classList.add('selected');
+ });
+}
+
+function clearSelection() {
+ selectedImages.forEach(img => img.container.classList.remove('selected'));
+ selectedImages.clear();
+}
+
+// Snap a client point to the nearest cell boundary (0..GRID_COLS / 0..GRID_ROWS)
+function pointToBoundary(clientX, clientY) {
+ const gridRect = grid.getBoundingClientRect();
+ const cellSize = getCellSize();
+ return {
+ col: Math.max(0, Math.min(GRID_COLS, Math.round((clientX - gridRect.left) / cellSize.width))),
+ row: Math.max(0, Math.min(GRID_ROWS, Math.round((clientY - gridRect.top) / cellSize.height)))
+ };
+}
+
+function marqueeRect() {
+ return {
+ x0: Math.min(marqueeState.anchorCol, marqueeState.col),
+ y0: Math.min(marqueeState.anchorRow, marqueeState.row),
+ x1: Math.max(marqueeState.anchorCol, marqueeState.col),
+ y1: Math.max(marqueeState.anchorRow, marqueeState.row)
+ };
+}
+
+function updateMarquee() {
+ const { x0, y0, x1, y1 } = marqueeRect();
+ const widthCells = x1 - x0;
+ const heightCells = y1 - y0;
+
+ if (widthCells === 0 || heightCells === 0) {
+ selectionBox.style.display = 'none';
+ clearSelection();
+ return;
+ }
+
+ const bounds = getPixelPerfectBounds(x0, y0, widthCells, heightCells);
+ selectionBox.style.display = 'block';
+ selectionBox.style.left = bounds.left + 'px';
+ selectionBox.style.top = bounds.top + 'px';
+ selectionBox.style.width = bounds.width + 'px';
+ selectionBox.style.height = bounds.height + 'px';
+
+ setSelection(images.filter(img =>
+ img.xCell < x1 && img.xCell + img.widthCells > x0 &&
+ img.yCell < y1 && img.yCell + img.heightCells > y0
+ ));
+}
+
+grid.addEventListener('mousedown', (e) => {
+ if (e.button !== 0) return;
+ if (e.target.closest('.image-container')) return;
+
+ e.preventDefault();
+ clearSelection();
+
+ const { col, row } = pointToBoundary(e.clientX, e.clientY);
+ marqueeState = { anchorCol: col, anchorRow: row, col, row };
+ document.body.classList.add('selecting');
+});
+
+function handleMarqueeMove(clientX, clientY) {
+ if (!marqueeState) return;
+ const { col, row } = pointToBoundary(clientX, clientY);
+ if (col === marqueeState.col && row === marqueeState.row) return;
+ marqueeState.col = col;
+ marqueeState.row = row;
+ updateMarquee();
+}
+
+function endMarquee() {
+ if (!marqueeState) return;
+ marqueeState = null;
+ selectionBox.style.display = 'none';
+ document.body.classList.remove('selecting');
+}
+
+document.addEventListener('mousemove', (e) => handleMarqueeMove(e.clientX, e.clientY));
+document.addEventListener('mouseup', endMarquee);
+
+// Delete/Backspace deletes all selected images (same as shift+click)
+document.addEventListener('keydown', (e) => {
+ if (e.key !== 'Delete' && e.key !== 'Backspace') return;
+ if (selectedImages.size === 0) return;
+
+ e.preventDefault();
+ Array.from(selectedImages).forEach(deleteImage);
+});
+
+// Bounds shared by group drag, duplicate, and rotate
+function selectionBounds(group) {
+ return {
+ minX: Math.min(...group.map(img => img.xCell)),
+ minY: Math.min(...group.map(img => img.yCell)),
+ maxX: Math.max(...group.map(img => img.xCell + img.widthCells)),
+ maxY: Math.max(...group.map(img => img.yCell + img.heightCells))
+ };
+}
diff --git a/resources/style.css b/resources/style.css
@@ -277,6 +277,10 @@ body {
border-color: var(--accent);
}
+.image-container.selected::after {
+ border-color: var(--accent);
+}
+
.image-container.dragging {
opacity: 0.7;
}
@@ -326,6 +330,23 @@ body.dragging * {
cursor: inherit !important;
}
+.selection-box {
+ position: absolute;
+ display: none;
+ background: color-mix(in srgb, var(--accent) 50%, transparent);
+ pointer-events: none;
+ z-index: 999999;
+}
+
+body.selecting,
+body.selecting * {
+ cursor: crosshair !important;
+}
+
+body.selecting .image-container {
+ pointer-events: none;
+}
+
.image-container img {
position: absolute;
top: 50%;
@@ -345,6 +366,7 @@ body.dragging * {
}
.image-container:hover .resize-handle,
+.image-container.selected .resize-handle,
.image-container.resizing .resize-handle {
opacity: 1;
}
@@ -379,6 +401,7 @@ body.dragging * {
}
.image-container:hover .dimension-label,
+.image-container.selected .dimension-label,
.image-container.dragging .dimension-label,
.image-container.resizing .dimension-label {
opacity: 1;
@@ -541,6 +564,10 @@ body.dragging * {
border: none !important;
}
+ .selection-box {
+ display: none !important;
+ }
+
.image-container .image-wrapper {
position: absolute !important;
top: 0 !important;