From 9d014e4f1bde71914d7dbc3121661066dc95f6ac Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 11 Feb 2026 22:43:30 +0100 Subject: [PATCH] feat: Implement edge panning and middle mouse button panning for zoomed views. --- src/components/ControlPanel.astro | 2 +- src/pages/ascii.astro | 2 +- src/scripts/ascii-controller.ts | 166 ++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) diff --git a/src/components/ControlPanel.astro b/src/components/ControlPanel.astro index 21ce09f..59d5d3f 100644 --- a/src/components/ControlPanel.astro +++ b/src/components/ControlPanel.astro @@ -1772,7 +1772,7 @@ import { } /* --- MOBILE STYLES --- */ - @media (max-width: 1600px) { + @media (max-width: 1200px) { .control-panel { padding: 0; position: fixed; diff --git a/src/pages/ascii.astro b/src/pages/ascii.astro index bc61781..ac0eed5 100644 --- a/src/pages/ascii.astro +++ b/src/pages/ascii.astro @@ -353,7 +353,7 @@ import ControlPanel from "../components/ControlPanel.astro"; opacity: 1; } - @media (max-width: 1600px) { + @media (max-width: 1200px) { .ascii-layout { flex-direction: column; height: 100dvh; diff --git a/src/scripts/ascii-controller.ts b/src/scripts/ascii-controller.ts index aa2f24b..cfaa40c 100644 --- a/src/scripts/ascii-controller.ts +++ b/src/scripts/ascii-controller.ts @@ -65,6 +65,24 @@ export class AsciiController { private lastTouchPos = { x: 0, y: 0 }; private resizeObserver: ResizeObserver | null = null; + // Edge panning state + private edgePanState = { + active: false, + directionX: 0, // -1 = left, 1 = right, 0 = none + directionY: 0, // -1 = up, 1 = down, 0 = none + animationId: null as number | null + }; + private readonly EDGE_THRESHOLD = 0.08; // 8% of canvas edge triggers pan + private readonly PAN_SPEED = 0.012; // Speed of panning per frame + + // Middle mouse panning state + private middleMousePanState = { + isDragging: false, + lastMousePos: { x: 0, y: 0 } + }; + private mouseDragHandler: ((e: MouseEvent) => void) | null = null; + private mouseUpHandler: ((e: MouseEvent) => void) | null = null; + // Callbacks private onSettingsChange?: () => void; @@ -103,6 +121,16 @@ export class AsciiController { this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false }); this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false }); this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this)); + + // Middle mouse button panning + this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this)); + this.mouseDragHandler = this.handleMouseDrag.bind(this); + this.mouseUpHandler = this.handleMouseUp.bind(this); + document.addEventListener('mousemove', this.mouseDragHandler); + document.addEventListener('mouseup', this.mouseUpHandler); + + // Prevent context menu on right-click to allow all mouse buttons + this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); } private handleResize(): void { @@ -490,6 +518,83 @@ export class AsciiController { if (this.zoomState.showMagnifier || wasShowing) { this.requestRender('uniforms'); } + + // Handle edge panning when zoomed in + this.handleEdgePanning(mx, my); + } + + private handleEdgePanning(mx: number, my: number): void { + // Only pan when zoomed in + if (this.zoomState.zoom <= 1.0) { + this.stopEdgePanning(); + return; + } + + // Check if mouse is near edges + const nearLeft = mx < this.EDGE_THRESHOLD && mx >= 0; + const nearRight = mx > (1 - this.EDGE_THRESHOLD) && mx <= 1; + const nearTop = my < this.EDGE_THRESHOLD && my >= 0; + const nearBottom = my > (1 - this.EDGE_THRESHOLD) && my <= 1; + + // Determine pan direction + const dirX = nearLeft ? -1 : nearRight ? 1 : 0; + const dirY = nearTop ? -1 : nearBottom ? 1 : 0; + + // Start or update panning + if (dirX !== 0 || dirY !== 0) { + this.edgePanState.directionX = dirX; + this.edgePanState.directionY = dirY; + if (!this.edgePanState.active) { + this.startEdgePanning(); + } + } else { + this.stopEdgePanning(); + } + } + + private startEdgePanning(): void { + if (this.edgePanState.active) return; + + this.edgePanState.active = true; + this.runEdgePanLoop(); + } + + private runEdgePanLoop(): void { + if (!this.edgePanState.active) return; + + // Calculate pan amount based on zoom level (slower when zoomed in more) + const panAmount = this.PAN_SPEED / this.zoomState.zoom; + + // Update zoom center + let newX = this.zoomState.zoomCenter.x + this.edgePanState.directionX * panAmount; + let newY = this.zoomState.zoomCenter.y + this.edgePanState.directionY * panAmount; + + // Clamp zoom center to keep image visible + // When zoomed, the visible area is 1/zoom of the total image + const visibleRange = 1.0 / this.zoomState.zoom; + const minCenter = visibleRange / 2; + const maxCenter = 1.0 - visibleRange / 2; + + this.zoomState.zoomCenter.x = Math.max(minCenter, Math.min(maxCenter, newX)); + this.zoomState.zoomCenter.y = Math.max(minCenter, Math.min(maxCenter, newY)); + + this.requestRender('uniforms'); + + // Continue panning + this.edgePanState.animationId = requestAnimationFrame(() => this.runEdgePanLoop()); + } + + private stopEdgePanning(): void { + if (!this.edgePanState.active) return; + + this.edgePanState.active = false; + this.edgePanState.directionX = 0; + this.edgePanState.directionY = 0; + + if (this.edgePanState.animationId !== null) { + cancelAnimationFrame(this.edgePanState.animationId); + this.edgePanState.animationId = null; + } } handleMouseLeave(): void { @@ -497,6 +602,59 @@ export class AsciiController { this.zoomState.showMagnifier = false; this.requestRender('uniforms'); } + this.stopEdgePanning(); + } + + // Middle mouse button panning handlers + handleMouseDown(e: MouseEvent): void { + // Middle mouse button is button 1 + if (e.button === 1 && this.zoomState.zoom > 1.0) { + e.preventDefault(); + this.middleMousePanState.isDragging = true; + this.middleMousePanState.lastMousePos = { x: e.clientX, y: e.clientY }; + // Hide magnifier while dragging + this.zoomState.showMagnifier = false; + // Stop edge panning while manually panning + this.stopEdgePanning(); + // Change cursor to closed hand (grabbing) + this.canvas.style.cursor = 'grabbing'; + this.requestRender('uniforms'); + } + } + + handleMouseDrag(e: MouseEvent): void { + if (!this.middleMousePanState.isDragging || this.zoomState.zoom <= 1.0) return; + + const curX = e.clientX; + const curY = e.clientY; + + // Calculate movement delta in normalized coordinates + const dx = (curX - this.middleMousePanState.lastMousePos.x) / this.canvas.width; + const dy = (curY - this.middleMousePanState.lastMousePos.y) / this.canvas.height; + + // Move zoom center opposite to drag direction + // Speed is inversely proportional to zoom (more zoom = slower pan for same mouse movement) + this.zoomState.zoomCenter.x -= dx / this.zoomState.zoom; + this.zoomState.zoomCenter.y -= dy / this.zoomState.zoom; + + // Clamp zoom center to keep image visible + const visibleRange = 1.0 / this.zoomState.zoom; + const minCenter = visibleRange / 2; + const maxCenter = 1.0 - visibleRange / 2; + + this.zoomState.zoomCenter.x = Math.max(minCenter, Math.min(maxCenter, this.zoomState.zoomCenter.x)); + this.zoomState.zoomCenter.y = Math.max(minCenter, Math.min(maxCenter, this.zoomState.zoomCenter.y)); + + this.middleMousePanState.lastMousePos = { x: curX, y: curY }; + this.requestRender('uniforms'); + } + + handleMouseUp(e: MouseEvent): void { + if (e.button === 1) { + this.middleMousePanState.isDragging = false; + // Reset cursor + this.canvas.style.cursor = ''; + } } // Touch Support @@ -648,6 +806,14 @@ export class AsciiController { if (this.resizeObserver) { this.resizeObserver.disconnect(); } + this.stopEdgePanning(); + // Clean up middle mouse panning handlers + if (this.mouseDragHandler) { + document.removeEventListener('mousemove', this.mouseDragHandler); + } + if (this.mouseUpHandler) { + document.removeEventListener('mouseup', this.mouseUpHandler); + } this.renderer?.dispose(); this.renderer = null; }