zoooom 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +215 -0
- package/dist/Zoooom-BypxEt9q.d.cts +128 -0
- package/dist/Zoooom-BypxEt9q.d.ts +128 -0
- package/dist/index.cjs +775 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +770 -0
- package/dist/index.js.map +1 -0
- package/dist/joystick.cjs +504 -0
- package/dist/joystick.cjs.map +1 -0
- package/dist/joystick.d.cts +31 -0
- package/dist/joystick.d.ts +31 -0
- package/dist/joystick.js +502 -0
- package/dist/joystick.js.map +1 -0
- package/dist/zoooom-full.iife.global.js +1272 -0
- package/dist/zoooom-full.iife.global.js.map +1 -0
- package/dist/zoooom.iife.global.js +773 -0
- package/dist/zoooom.iife.global.js.map +1 -0
- package/package.json +66 -0
- package/src/core/Zoooom.ts +294 -0
- package/src/core/constants.ts +29 -0
- package/src/core/loader.ts +129 -0
- package/src/core/transform.ts +146 -0
- package/src/full.ts +8 -0
- package/src/iife-entry.ts +2 -0
- package/src/iife-full-entry.ts +3 -0
- package/src/index.ts +11 -0
- package/src/input/gesture.ts +49 -0
- package/src/input/keyboard.ts +60 -0
- package/src/input/mouse.ts +54 -0
- package/src/input/touch.ts +111 -0
- package/src/input/wheel.ts +65 -0
- package/src/joystick/dom.ts +81 -0
- package/src/joystick/index.ts +2 -0
- package/src/joystick/joystick.ts +280 -0
- package/src/styles/core.ts +101 -0
- package/src/styles/joystick.ts +213 -0
- package/src/types.ts +118 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { MIN_SCALE, OVERSCALE_FACTOR, VELOCITY_DAMPING } from './constants.js';
|
|
2
|
+
import type { ZoooomState, ZoooomElements } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Apply the current transform state to the image element.
|
|
6
|
+
* Uses translate(calc(-50% + Xpx), calc(-50% + Ypx)) scale(S) for center-anchored zoom.
|
|
7
|
+
*/
|
|
8
|
+
export function updateTransform(state: ZoooomState, elements: ZoooomElements): void {
|
|
9
|
+
if (!elements.image) return;
|
|
10
|
+
elements.image.style.transform =
|
|
11
|
+
`translate(calc(-50% + ${state.translateX}px), calc(-50% + ${state.translateY}px)) scale(${state.scale})`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Animate momentum after pan release — velocity decays per frame via damping.
|
|
16
|
+
* Skipped when reduced motion is preferred.
|
|
17
|
+
*/
|
|
18
|
+
export function animateMovement(
|
|
19
|
+
state: ZoooomState,
|
|
20
|
+
elements: ZoooomElements,
|
|
21
|
+
onPan?: (x: number, y: number) => void,
|
|
22
|
+
): void {
|
|
23
|
+
if (state.reducedMotion) {
|
|
24
|
+
state.velocityX = 0;
|
|
25
|
+
state.velocityY = 0;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
state.isAnimating = true;
|
|
30
|
+
|
|
31
|
+
function step() {
|
|
32
|
+
state.translateX += state.velocityX;
|
|
33
|
+
state.translateY += state.velocityY;
|
|
34
|
+
state.velocityX *= VELOCITY_DAMPING;
|
|
35
|
+
state.velocityY *= VELOCITY_DAMPING;
|
|
36
|
+
|
|
37
|
+
updateTransform(state, elements);
|
|
38
|
+
onPan?.(state.translateX, state.translateY);
|
|
39
|
+
|
|
40
|
+
if (Math.abs(state.velocityX) > 0.1 || Math.abs(state.velocityY) > 0.1) {
|
|
41
|
+
requestAnimationFrame(step);
|
|
42
|
+
} else {
|
|
43
|
+
state.velocityX = 0;
|
|
44
|
+
state.velocityY = 0;
|
|
45
|
+
state.isAnimating = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
requestAnimationFrame(step);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Center the image in the container (reset translate without changing scale) */
|
|
53
|
+
export function centerImage(state: ZoooomState, elements: ZoooomElements): void {
|
|
54
|
+
if (!elements.container || !elements.image) return;
|
|
55
|
+
state.translateX = 0;
|
|
56
|
+
state.translateY = 0;
|
|
57
|
+
updateTransform(state, elements);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reset view to scale=1, centered, zero velocity */
|
|
61
|
+
export function resetView(state: ZoooomState, elements: ZoooomElements): void {
|
|
62
|
+
state.scale = 1;
|
|
63
|
+
state.translateX = 0;
|
|
64
|
+
state.translateY = 0;
|
|
65
|
+
state.velocityX = 0;
|
|
66
|
+
state.velocityY = 0;
|
|
67
|
+
updateTransform(state, elements);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Calculate the maximum zoom scale from the image's natural vs displayed dimensions.
|
|
72
|
+
* Allows zooming to full native resolution * overscale factor.
|
|
73
|
+
*/
|
|
74
|
+
export function calculateMaxScale(
|
|
75
|
+
elements: ZoooomElements,
|
|
76
|
+
overscaleFactor: number = OVERSCALE_FACTOR,
|
|
77
|
+
): number {
|
|
78
|
+
if (!elements.image) return 10;
|
|
79
|
+
|
|
80
|
+
const naturalWidth = elements.image.naturalWidth;
|
|
81
|
+
const naturalHeight = elements.image.naturalHeight;
|
|
82
|
+
const displayWidth = elements.image.clientWidth;
|
|
83
|
+
const displayHeight = elements.image.clientHeight;
|
|
84
|
+
|
|
85
|
+
if (!naturalWidth || !naturalHeight || !displayWidth || !displayHeight) return 10;
|
|
86
|
+
|
|
87
|
+
const widthRatio = naturalWidth / displayWidth;
|
|
88
|
+
const heightRatio = naturalHeight / displayHeight;
|
|
89
|
+
const maxScaleFactor = Math.max(widthRatio, heightRatio);
|
|
90
|
+
|
|
91
|
+
// Higher overscale on mobile for full-res zoom
|
|
92
|
+
const mobileOverscale = window.innerWidth <= 768 ? overscaleFactor * 2 : overscaleFactor;
|
|
93
|
+
return Math.max(maxScaleFactor * mobileOverscale, MIN_SCALE);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Zoom toward a specific point (cursor, pinch center, or container center).
|
|
98
|
+
* Maintains the image point under the target coordinate.
|
|
99
|
+
*/
|
|
100
|
+
export function zoomTowardsPoint(
|
|
101
|
+
state: ZoooomState,
|
|
102
|
+
elements: ZoooomElements,
|
|
103
|
+
delta: number,
|
|
104
|
+
pointX?: number,
|
|
105
|
+
pointY?: number,
|
|
106
|
+
onZoom?: (scale: number) => void,
|
|
107
|
+
): void {
|
|
108
|
+
if (!elements.container || !elements.image) return;
|
|
109
|
+
|
|
110
|
+
const currentScale = state.scale;
|
|
111
|
+
const newScale = Math.max(MIN_SCALE, Math.min(state.scale * delta, state.maxScale));
|
|
112
|
+
|
|
113
|
+
if (newScale === currentScale) return;
|
|
114
|
+
|
|
115
|
+
const containerRect = elements.container.getBoundingClientRect();
|
|
116
|
+
const containerCenterX = containerRect.width / 2;
|
|
117
|
+
const containerCenterY = containerRect.height / 2;
|
|
118
|
+
|
|
119
|
+
const targetX = pointX ?? containerCenterX;
|
|
120
|
+
const targetY = pointY ?? containerCenterY;
|
|
121
|
+
|
|
122
|
+
// Calculate the image point under the target coordinate
|
|
123
|
+
const imageX = (targetX - containerCenterX - state.translateX) / currentScale;
|
|
124
|
+
const imageY = (targetY - containerCenterY - state.translateY) / currentScale;
|
|
125
|
+
|
|
126
|
+
// Update translation to keep that point stationary
|
|
127
|
+
state.translateX = targetX - containerCenterX - imageX * newScale;
|
|
128
|
+
state.translateY = targetY - containerCenterY - imageY * newScale;
|
|
129
|
+
|
|
130
|
+
// Smooth zoom transition (skipped for reduced motion)
|
|
131
|
+
if (!state.reducedMotion) {
|
|
132
|
+
elements.image.style.transition = 'transform 0.2s ease-out';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
state.scale = newScale;
|
|
136
|
+
updateTransform(state, elements);
|
|
137
|
+
onZoom?.(state.scale);
|
|
138
|
+
|
|
139
|
+
if (!state.reducedMotion) {
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
if (elements.image) {
|
|
142
|
+
elements.image.style.transition = '';
|
|
143
|
+
}
|
|
144
|
+
}, 200);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/full.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { Zoooom } from './core/Zoooom.js';
|
|
2
|
+
export type {
|
|
3
|
+
ZoooomOptions,
|
|
4
|
+
ZoooomEvent,
|
|
5
|
+
ZoooomEventHandler,
|
|
6
|
+
JoystickOptions,
|
|
7
|
+
} from './types.js';
|
|
8
|
+
|
|
9
|
+
// Default export for convenient usage: import Zoooom from 'zoooom'
|
|
10
|
+
import { Zoooom } from './core/Zoooom.js';
|
|
11
|
+
export default Zoooom;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ZOOM_FACTOR } from '../core/constants.js';
|
|
2
|
+
import { zoomTowardsPoint } from '../core/transform.js';
|
|
3
|
+
import type { ZoooomState, ZoooomElements, InputCleanup } from '../types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Attach Safari gesture events (gesturestart/change/end).
|
|
7
|
+
* Only activates in Safari where GestureEvent exists.
|
|
8
|
+
*/
|
|
9
|
+
export function attachGesture(
|
|
10
|
+
state: ZoooomState,
|
|
11
|
+
elements: ZoooomElements,
|
|
12
|
+
onZoom?: (scale: number) => void,
|
|
13
|
+
): InputCleanup {
|
|
14
|
+
const { container } = elements;
|
|
15
|
+
|
|
16
|
+
// Only Safari supports gesture events
|
|
17
|
+
if (!('ongesturestart' in window)) {
|
|
18
|
+
return () => {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let startScale = 1;
|
|
22
|
+
|
|
23
|
+
function handleGestureStart(e: Event) {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
startScale = state.scale;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function handleGestureChange(e: Event) {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
const ge = e as unknown as { scale: number };
|
|
31
|
+
const rect = container.getBoundingClientRect();
|
|
32
|
+
const delta = ge.scale > 1 ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;
|
|
33
|
+
zoomTowardsPoint(state, elements, delta, rect.width / 2, rect.height / 2, onZoom);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleGestureEnd(e: Event) {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
container.addEventListener('gesturestart', handleGestureStart, { passive: false } as EventListenerOptions);
|
|
41
|
+
container.addEventListener('gesturechange', handleGestureChange, { passive: false } as EventListenerOptions);
|
|
42
|
+
container.addEventListener('gestureend', handleGestureEnd, { passive: false } as EventListenerOptions);
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
container.removeEventListener('gesturestart', handleGestureStart);
|
|
46
|
+
container.removeEventListener('gesturechange', handleGestureChange);
|
|
47
|
+
container.removeEventListener('gestureend', handleGestureEnd);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { PAN_STEP, ZOOM_FACTOR } from '../core/constants.js';
|
|
2
|
+
import { zoomTowardsPoint, animateMovement, resetView } from '../core/transform.js';
|
|
3
|
+
import type { ZoooomState, ZoooomElements, InputCleanup } from '../types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Attach keyboard navigation handlers.
|
|
7
|
+
* Arrows pan, +/- zoom, R resets, Escape is reserved for parent contexts.
|
|
8
|
+
*/
|
|
9
|
+
export function attachKeyboard(
|
|
10
|
+
state: ZoooomState,
|
|
11
|
+
elements: ZoooomElements,
|
|
12
|
+
panStep: number = PAN_STEP,
|
|
13
|
+
zoomFactor: number = ZOOM_FACTOR,
|
|
14
|
+
onPan?: (x: number, y: number) => void,
|
|
15
|
+
onZoom?: (scale: number) => void,
|
|
16
|
+
): InputCleanup {
|
|
17
|
+
const { container } = elements;
|
|
18
|
+
|
|
19
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
20
|
+
let handled = true;
|
|
21
|
+
|
|
22
|
+
switch (e.key) {
|
|
23
|
+
case 'ArrowLeft':
|
|
24
|
+
state.velocityX = panStep * 0.2;
|
|
25
|
+
animateMovement(state, elements, onPan);
|
|
26
|
+
break;
|
|
27
|
+
case 'ArrowRight':
|
|
28
|
+
state.velocityX = -panStep * 0.2;
|
|
29
|
+
animateMovement(state, elements, onPan);
|
|
30
|
+
break;
|
|
31
|
+
case 'ArrowUp':
|
|
32
|
+
state.velocityY = panStep * 0.2;
|
|
33
|
+
animateMovement(state, elements, onPan);
|
|
34
|
+
break;
|
|
35
|
+
case 'ArrowDown':
|
|
36
|
+
state.velocityY = -panStep * 0.2;
|
|
37
|
+
animateMovement(state, elements, onPan);
|
|
38
|
+
break;
|
|
39
|
+
case '+': case '=':
|
|
40
|
+
zoomTowardsPoint(state, elements, zoomFactor, undefined, undefined, onZoom);
|
|
41
|
+
break;
|
|
42
|
+
case '-':
|
|
43
|
+
zoomTowardsPoint(state, elements, 1 / zoomFactor, undefined, undefined, onZoom);
|
|
44
|
+
break;
|
|
45
|
+
case 'r': case 'R':
|
|
46
|
+
resetView(state, elements);
|
|
47
|
+
break;
|
|
48
|
+
default:
|
|
49
|
+
handled = false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (handled) e.preventDefault();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ZoooomState, ZoooomElements, InputCleanup } from '../types.js';
|
|
2
|
+
import { updateTransform } from '../core/transform.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Attach mouse drag-to-pan handlers.
|
|
6
|
+
* Returns a cleanup function to remove all listeners.
|
|
7
|
+
*/
|
|
8
|
+
export function attachMouse(
|
|
9
|
+
state: ZoooomState,
|
|
10
|
+
elements: ZoooomElements,
|
|
11
|
+
onPan?: (x: number, y: number) => void,
|
|
12
|
+
): InputCleanup {
|
|
13
|
+
const { container } = elements;
|
|
14
|
+
|
|
15
|
+
function handleMouseDown(e: MouseEvent) {
|
|
16
|
+
if (e.button !== 0) return; // Left button only
|
|
17
|
+
state.isDragging = true;
|
|
18
|
+
state.startX = e.clientX;
|
|
19
|
+
state.startY = e.clientY;
|
|
20
|
+
container.style.cursor = 'grabbing';
|
|
21
|
+
e.preventDefault();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function handleMouseMove(e: MouseEvent) {
|
|
25
|
+
if (!state.isDragging) return;
|
|
26
|
+
const dx = e.clientX - state.startX;
|
|
27
|
+
const dy = e.clientY - state.startY;
|
|
28
|
+
state.translateX += dx;
|
|
29
|
+
state.translateY += dy;
|
|
30
|
+
state.startX = e.clientX;
|
|
31
|
+
state.startY = e.clientY;
|
|
32
|
+
updateTransform(state, elements);
|
|
33
|
+
onPan?.(state.translateX, state.translateY);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function handleMouseUp() {
|
|
37
|
+
if (state.isDragging) {
|
|
38
|
+
state.isDragging = false;
|
|
39
|
+
container.style.cursor = 'grab';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
container.addEventListener('mousedown', handleMouseDown);
|
|
44
|
+
container.addEventListener('mousemove', handleMouseMove);
|
|
45
|
+
container.addEventListener('mouseup', handleMouseUp);
|
|
46
|
+
container.addEventListener('mouseleave', handleMouseUp);
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
container.removeEventListener('mousedown', handleMouseDown);
|
|
50
|
+
container.removeEventListener('mousemove', handleMouseMove);
|
|
51
|
+
container.removeEventListener('mouseup', handleMouseUp);
|
|
52
|
+
container.removeEventListener('mouseleave', handleMouseUp);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { MIN_SCALE } from '../core/constants.js';
|
|
2
|
+
import { calculateMaxScale, updateTransform } from '../core/transform.js';
|
|
3
|
+
import type { ZoooomState, ZoooomElements, InputCleanup } from '../types.js';
|
|
4
|
+
|
|
5
|
+
function getDistance(e: TouchEvent): number {
|
|
6
|
+
const t1 = e.touches[0];
|
|
7
|
+
const t2 = e.touches[1];
|
|
8
|
+
const dx = t1.clientX - t2.clientX;
|
|
9
|
+
const dy = t1.clientY - t2.clientY;
|
|
10
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Attach touch pan and pinch-to-zoom handlers.
|
|
15
|
+
* Returns a cleanup function to remove all listeners.
|
|
16
|
+
*/
|
|
17
|
+
export function attachTouch(
|
|
18
|
+
state: ZoooomState,
|
|
19
|
+
elements: ZoooomElements,
|
|
20
|
+
overscaleFactor: number,
|
|
21
|
+
onPan?: (x: number, y: number) => void,
|
|
22
|
+
onZoom?: (scale: number) => void,
|
|
23
|
+
): InputCleanup {
|
|
24
|
+
const { container } = elements;
|
|
25
|
+
|
|
26
|
+
function handleTouchStart(e: TouchEvent) {
|
|
27
|
+
if (e.touches.length === 1) {
|
|
28
|
+
state.isDragging = true;
|
|
29
|
+
state.startX = e.touches[0].clientX;
|
|
30
|
+
state.startY = e.touches[0].clientY;
|
|
31
|
+
state.initialDistance = 0;
|
|
32
|
+
} else if (e.touches.length === 2) {
|
|
33
|
+
state.isDragging = false;
|
|
34
|
+
state.initialDistance = getDistance(e);
|
|
35
|
+
state.initialScale = state.scale;
|
|
36
|
+
state.initialTranslateX = state.translateX;
|
|
37
|
+
state.initialTranslateY = state.translateY;
|
|
38
|
+
|
|
39
|
+
const t1 = e.touches[0];
|
|
40
|
+
const t2 = e.touches[1];
|
|
41
|
+
const rect = container.getBoundingClientRect();
|
|
42
|
+
state.pinchCenter = {
|
|
43
|
+
x: ((t1.clientX + t2.clientX) / 2) - rect.left,
|
|
44
|
+
y: ((t1.clientY + t2.clientY) / 2) - rect.top,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function handleTouchMove(e: TouchEvent) {
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
|
|
52
|
+
if (e.touches.length === 1 && state.isDragging) {
|
|
53
|
+
const dx = e.touches[0].clientX - state.startX;
|
|
54
|
+
const dy = e.touches[0].clientY - state.startY;
|
|
55
|
+
state.translateX += dx;
|
|
56
|
+
state.translateY += dy;
|
|
57
|
+
state.startX = e.touches[0].clientX;
|
|
58
|
+
state.startY = e.touches[0].clientY;
|
|
59
|
+
updateTransform(state, elements);
|
|
60
|
+
onPan?.(state.translateX, state.translateY);
|
|
61
|
+
} else if (e.touches.length === 2 && state.initialDistance > 0) {
|
|
62
|
+
const currentDistance = getDistance(e);
|
|
63
|
+
const scaleFactor = currentDistance / state.initialDistance;
|
|
64
|
+
const targetScale = state.initialScale * scaleFactor;
|
|
65
|
+
state.maxScale = calculateMaxScale(elements, overscaleFactor);
|
|
66
|
+
const newScale = Math.max(MIN_SCALE, Math.min(targetScale, state.maxScale));
|
|
67
|
+
|
|
68
|
+
if (newScale === state.scale) return;
|
|
69
|
+
|
|
70
|
+
const t1 = e.touches[0];
|
|
71
|
+
const t2 = e.touches[1];
|
|
72
|
+
const rect = container.getBoundingClientRect();
|
|
73
|
+
const currentPinchCenter = {
|
|
74
|
+
x: ((t1.clientX + t2.clientX) / 2) - rect.left,
|
|
75
|
+
y: ((t1.clientY + t2.clientY) / 2) - rect.top,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const containerCenterX = rect.width / 2;
|
|
79
|
+
const containerCenterY = rect.height / 2;
|
|
80
|
+
|
|
81
|
+
const imageX = (state.pinchCenter.x - containerCenterX - state.initialTranslateX) / state.initialScale;
|
|
82
|
+
const imageY = (state.pinchCenter.y - containerCenterY - state.initialTranslateY) / state.initialScale;
|
|
83
|
+
|
|
84
|
+
state.translateX = currentPinchCenter.x - containerCenterX - imageX * newScale;
|
|
85
|
+
state.translateY = currentPinchCenter.y - containerCenterY - imageY * newScale;
|
|
86
|
+
|
|
87
|
+
state.scale = newScale;
|
|
88
|
+
updateTransform(state, elements);
|
|
89
|
+
onZoom?.(state.scale);
|
|
90
|
+
|
|
91
|
+
state.pinchCenter = currentPinchCenter;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleTouchEnd() {
|
|
96
|
+
state.isDragging = false;
|
|
97
|
+
state.initialDistance = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
container.addEventListener('touchstart', handleTouchStart, { passive: false });
|
|
101
|
+
container.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
102
|
+
container.addEventListener('touchend', handleTouchEnd);
|
|
103
|
+
container.addEventListener('touchcancel', handleTouchEnd);
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
container.removeEventListener('touchstart', handleTouchStart);
|
|
107
|
+
container.removeEventListener('touchmove', handleTouchMove);
|
|
108
|
+
container.removeEventListener('touchend', handleTouchEnd);
|
|
109
|
+
container.removeEventListener('touchcancel', handleTouchEnd);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ZOOM_FACTOR, TRACKPAD_SENSITIVITY } from '../core/constants.js';
|
|
2
|
+
import { zoomTowardsPoint } from '../core/transform.js';
|
|
3
|
+
import type { ZoooomState, ZoooomElements, InputCleanup } from '../types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Attach wheel/trackpad zoom handler.
|
|
7
|
+
* Detects trackpad vs mouse wheel via delta magnitude.
|
|
8
|
+
* Handles Ctrl+wheel for pinch gestures on Windows/Linux.
|
|
9
|
+
*/
|
|
10
|
+
export function attachWheel(
|
|
11
|
+
state: ZoooomState,
|
|
12
|
+
elements: ZoooomElements,
|
|
13
|
+
zoomFactor: number = ZOOM_FACTOR,
|
|
14
|
+
trackpadSensitivity: number = TRACKPAD_SENSITIVITY,
|
|
15
|
+
onZoom?: (scale: number) => void,
|
|
16
|
+
): InputCleanup {
|
|
17
|
+
const { container } = elements;
|
|
18
|
+
|
|
19
|
+
function handleWheel(e: WheelEvent) {
|
|
20
|
+
e.preventDefault();
|
|
21
|
+
|
|
22
|
+
const rect = container.getBoundingClientRect();
|
|
23
|
+
const pointX = e.clientX - rect.left;
|
|
24
|
+
const pointY = e.clientY - rect.top;
|
|
25
|
+
|
|
26
|
+
let zoomDelta: number;
|
|
27
|
+
|
|
28
|
+
if (e.ctrlKey) {
|
|
29
|
+
// Pinch gesture (Ctrl+wheel on Windows/Linux)
|
|
30
|
+
zoomDelta = e.deltaY < 0 ? zoomFactor : 1 / zoomFactor;
|
|
31
|
+
} else {
|
|
32
|
+
// Normalize delta based on deltaMode
|
|
33
|
+
let normalizedDelta: number;
|
|
34
|
+
switch (e.deltaMode) {
|
|
35
|
+
case 1: normalizedDelta = e.deltaY * 20; break; // LINE
|
|
36
|
+
case 2: normalizedDelta = e.deltaY * 100; break; // PAGE
|
|
37
|
+
default: normalizedDelta = e.deltaY; // PIXEL
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Math.abs(normalizedDelta) < 40) {
|
|
41
|
+
// Trackpad — continuous zoom
|
|
42
|
+
zoomDelta = Math.exp(-normalizedDelta * trackpadSensitivity);
|
|
43
|
+
} else {
|
|
44
|
+
// Mouse wheel — discrete steps
|
|
45
|
+
zoomDelta = normalizedDelta > 0 ? 1 / zoomFactor : zoomFactor;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Manage scroll state class for CSS transition control
|
|
50
|
+
container.classList.add('zoooom-scrolling');
|
|
51
|
+
if (state.wheelTimeout) clearTimeout(state.wheelTimeout);
|
|
52
|
+
state.wheelTimeout = setTimeout(() => {
|
|
53
|
+
container.classList.remove('zoooom-scrolling');
|
|
54
|
+
}, 200);
|
|
55
|
+
|
|
56
|
+
zoomTowardsPoint(state, elements, zoomDelta, pointX, pointY, onZoom);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
container.addEventListener('wheel', handleWheel, { passive: false });
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
container.removeEventListener('wheel', handleWheel);
|
|
63
|
+
if (state.wheelTimeout) clearTimeout(state.wheelTimeout);
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create the joystick DOM structure.
|
|
3
|
+
* Returns references to key elements for event binding.
|
|
4
|
+
*/
|
|
5
|
+
export interface JoystickDOM {
|
|
6
|
+
wrap: HTMLElement;
|
|
7
|
+
toggle: HTMLButtonElement;
|
|
8
|
+
disc: HTMLElement;
|
|
9
|
+
innerCircle: HTMLElement;
|
|
10
|
+
zoomIn: HTMLElement;
|
|
11
|
+
zoomOut: HTMLElement;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createJoystickDOM(container: HTMLElement): JoystickDOM {
|
|
15
|
+
// Toggle button (compass)
|
|
16
|
+
const toggle = document.createElement('button');
|
|
17
|
+
toggle.className = 'zoooom-joystick-toggle';
|
|
18
|
+
toggle.setAttribute('aria-label', 'Toggle navigation joystick');
|
|
19
|
+
toggle.setAttribute('aria-expanded', 'false');
|
|
20
|
+
toggle.textContent = '\u2316'; // ⌖ position indicator
|
|
21
|
+
container.appendChild(toggle);
|
|
22
|
+
|
|
23
|
+
// Joystick wrapper
|
|
24
|
+
const wrap = document.createElement('div');
|
|
25
|
+
wrap.className = 'zoooom-joystick-wrap';
|
|
26
|
+
wrap.setAttribute('aria-hidden', 'true');
|
|
27
|
+
|
|
28
|
+
// Disc (outer pan area)
|
|
29
|
+
const disc = document.createElement('div');
|
|
30
|
+
disc.className = 'zoooom-disc';
|
|
31
|
+
disc.setAttribute('role', 'slider');
|
|
32
|
+
disc.setAttribute('aria-label', 'Pan navigation control');
|
|
33
|
+
disc.setAttribute('aria-valuenow', '0');
|
|
34
|
+
disc.setAttribute('aria-valuemin', '0');
|
|
35
|
+
disc.setAttribute('aria-valuemax', '1');
|
|
36
|
+
disc.setAttribute('aria-valuetext', 'Center position — not moving');
|
|
37
|
+
disc.setAttribute('tabindex', '0');
|
|
38
|
+
|
|
39
|
+
// Inner circle (zoom)
|
|
40
|
+
const innerCircle = document.createElement('div');
|
|
41
|
+
innerCircle.className = 'zoooom-inner-circle';
|
|
42
|
+
|
|
43
|
+
const zoomOut = document.createElement('div');
|
|
44
|
+
zoomOut.className = 'zoooom-zoom-half zoooom-zoom-out';
|
|
45
|
+
zoomOut.setAttribute('role', 'button');
|
|
46
|
+
zoomOut.setAttribute('aria-label', 'Zoom out');
|
|
47
|
+
zoomOut.setAttribute('tabindex', '0');
|
|
48
|
+
zoomOut.textContent = '\u2212'; // −
|
|
49
|
+
|
|
50
|
+
const zoomIn = document.createElement('div');
|
|
51
|
+
zoomIn.className = 'zoooom-zoom-half zoooom-zoom-in';
|
|
52
|
+
zoomIn.setAttribute('role', 'button');
|
|
53
|
+
zoomIn.setAttribute('aria-label', 'Zoom in');
|
|
54
|
+
zoomIn.setAttribute('tabindex', '0');
|
|
55
|
+
zoomIn.textContent = '+';
|
|
56
|
+
|
|
57
|
+
innerCircle.appendChild(zoomOut);
|
|
58
|
+
innerCircle.appendChild(zoomIn);
|
|
59
|
+
|
|
60
|
+
// Direction arrows
|
|
61
|
+
const arrows = document.createElement('div');
|
|
62
|
+
arrows.className = 'zoooom-arrows';
|
|
63
|
+
arrows.setAttribute('aria-hidden', 'true');
|
|
64
|
+
for (const dir of ['n', 'e', 's', 'w']) {
|
|
65
|
+
const arrow = document.createElement('div');
|
|
66
|
+
arrow.className = `zoooom-arrow zoooom-arrow-${dir}`;
|
|
67
|
+
arrows.appendChild(arrow);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
disc.appendChild(innerCircle);
|
|
71
|
+
disc.appendChild(arrows);
|
|
72
|
+
wrap.appendChild(disc);
|
|
73
|
+
container.appendChild(wrap);
|
|
74
|
+
|
|
75
|
+
return { wrap, toggle, disc, innerCircle, zoomIn, zoomOut };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function destroyJoystickDOM(dom: JoystickDOM): void {
|
|
79
|
+
dom.wrap.remove();
|
|
80
|
+
dom.toggle.remove();
|
|
81
|
+
}
|