feat: Implement edge panning and middle mouse button panning for zoomed views.

This commit is contained in:
syntaxbullet
2026-02-11 22:43:30 +01:00
parent 55ec01e3cd
commit 9d014e4f1b
3 changed files with 168 additions and 2 deletions

View File

@@ -1772,7 +1772,7 @@ import {
}
/* --- MOBILE STYLES --- */
@media (max-width: 1600px) {
@media (max-width: 1200px) {
.control-panel {
padding: 0;
position: fixed;

View File

@@ -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;

View File

@@ -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;
}