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:
Mindex.html | 1+
Mreadme.md | 3++-
Mresources/images.js | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mresources/pointer.js | 148+++++++++++++++++++++++++------------------------------------------------------
Aresources/selection.js | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mresources/style.css | 27+++++++++++++++++++++++++++
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;