feat: Implement edge panning and middle mouse button panning for zoomed views.
This commit is contained in:
@@ -1772,7 +1772,7 @@ import {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --- MOBILE STYLES --- */
|
/* --- MOBILE STYLES --- */
|
||||||
@media (max-width: 1600px) {
|
@media (max-width: 1200px) {
|
||||||
.control-panel {
|
.control-panel {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ import ControlPanel from "../components/ControlPanel.astro";
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1600px) {
|
@media (max-width: 1200px) {
|
||||||
.ascii-layout {
|
.ascii-layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
|
|||||||
@@ -65,6 +65,24 @@ export class AsciiController {
|
|||||||
private lastTouchPos = { x: 0, y: 0 };
|
private lastTouchPos = { x: 0, y: 0 };
|
||||||
private resizeObserver: ResizeObserver | null = null;
|
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
|
// Callbacks
|
||||||
private onSettingsChange?: () => void;
|
private onSettingsChange?: () => void;
|
||||||
|
|
||||||
@@ -103,6 +121,16 @@ export class AsciiController {
|
|||||||
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
|
this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false });
|
||||||
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
|
||||||
this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
|
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 {
|
private handleResize(): void {
|
||||||
@@ -490,6 +518,83 @@ export class AsciiController {
|
|||||||
if (this.zoomState.showMagnifier || wasShowing) {
|
if (this.zoomState.showMagnifier || wasShowing) {
|
||||||
this.requestRender('uniforms');
|
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 {
|
handleMouseLeave(): void {
|
||||||
@@ -497,6 +602,59 @@ export class AsciiController {
|
|||||||
this.zoomState.showMagnifier = false;
|
this.zoomState.showMagnifier = false;
|
||||||
this.requestRender('uniforms');
|
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
|
// Touch Support
|
||||||
@@ -648,6 +806,14 @@ export class AsciiController {
|
|||||||
if (this.resizeObserver) {
|
if (this.resizeObserver) {
|
||||||
this.resizeObserver.disconnect();
|
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?.dispose();
|
||||||
this.renderer = null;
|
this.renderer = null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user