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.
@@ -0,0 +1,280 @@
1
+ import type { JoystickOptions } from '../types.js';
2
+ import { JOYSTICK_RADIUS, JOYSTICK_DEADZONE, MAX_JOYSTICK_SPEED, DWELL_TIMEOUT } from '../core/constants.js';
3
+ import type { Zoooom } from '../core/Zoooom.js';
4
+ import { createJoystickDOM, destroyJoystickDOM, type JoystickDOM } from './dom.js';
5
+ import { injectJoystickStyles } from '../styles/joystick.js';
6
+
7
+ const DIRECTIONS = [
8
+ 'east', 'south-east', 'south', 'south-west',
9
+ 'west', 'north-west', 'north', 'north-east',
10
+ ] as const;
11
+
12
+ function angleToDirection(angle: number): string {
13
+ if (angle >= -22.5 && angle < 22.5) return 'east';
14
+ if (angle >= 22.5 && angle < 67.5) return 'south-east';
15
+ if (angle >= 67.5 && angle < 112.5) return 'south';
16
+ if (angle >= 112.5 && angle < 157.5) return 'south-west';
17
+ if (angle >= 157.5 || angle < -157.5) return 'west';
18
+ if (angle >= -157.5 && angle < -112.5) return 'north-west';
19
+ if (angle >= -112.5 && angle < -67.5) return 'north';
20
+ return 'north-east';
21
+ }
22
+
23
+ export class ZoooomJoystick {
24
+ private viewer: Zoooom;
25
+ private dom: JoystickDOM;
26
+ private options: Required<JoystickOptions>;
27
+ private visible = false;
28
+ private active = false;
29
+ private animationId: number | null = null;
30
+ private joystickX = 0;
31
+ private joystickY = 0;
32
+ private currentDirection = '';
33
+ private dwellTimer: ReturnType<typeof setTimeout> | null = null;
34
+ private isDwelling = false;
35
+ private hideTimer: ReturnType<typeof setTimeout> | null = null;
36
+
37
+ constructor(viewer: Zoooom, options?: JoystickOptions) {
38
+ this.viewer = viewer;
39
+ this.options = {
40
+ radius: options?.radius ?? JOYSTICK_RADIUS,
41
+ deadzone: options?.deadzone ?? JOYSTICK_DEADZONE,
42
+ maxSpeed: options?.maxSpeed ?? MAX_JOYSTICK_SPEED,
43
+ position: options?.position ?? 'bottom-center',
44
+ showToggle: options?.showToggle ?? true,
45
+ dwellTimeout: options?.dwellTimeout ?? DWELL_TIMEOUT,
46
+ };
47
+
48
+ injectJoystickStyles();
49
+
50
+ const elements = this.viewer.getElements();
51
+ this.dom = createJoystickDOM(elements.container);
52
+ this.bindEvents();
53
+ }
54
+
55
+ show(): void {
56
+ this.visible = true;
57
+ this.dom.wrap.classList.add('visible');
58
+ this.dom.wrap.setAttribute('aria-hidden', 'false');
59
+ this.dom.toggle.setAttribute('aria-expanded', 'true');
60
+ if (this.hideTimer) clearTimeout(this.hideTimer);
61
+ }
62
+
63
+ hide(): void {
64
+ this.visible = false;
65
+ this.dom.wrap.classList.remove('visible');
66
+ this.dom.wrap.setAttribute('aria-hidden', 'true');
67
+ this.dom.toggle.setAttribute('aria-expanded', 'false');
68
+ this.stopMovement();
69
+ this.clearDirection();
70
+ }
71
+
72
+ destroy(): void {
73
+ this.hide();
74
+ this.stopMovement();
75
+ if (this.dwellTimer) clearTimeout(this.dwellTimer);
76
+ if (this.hideTimer) clearTimeout(this.hideTimer);
77
+ destroyJoystickDOM(this.dom);
78
+ }
79
+
80
+ private bindEvents(): void {
81
+ const { toggle, disc, zoomIn, zoomOut, innerCircle } = this.dom;
82
+
83
+ // Toggle button
84
+ toggle.addEventListener('click', () => {
85
+ if (this.visible) this.hide();
86
+ else this.show();
87
+ });
88
+
89
+ toggle.addEventListener('mouseenter', () => this.show());
90
+ toggle.addEventListener('mouseleave', () => {
91
+ this.hideTimer = setTimeout(() => this.hide(), 15000);
92
+ });
93
+
94
+ // Disc — panning
95
+ disc.addEventListener('mousemove', (e) => {
96
+ if (e.target !== disc) return;
97
+ if (!this.isDwelling) {
98
+ this.startDwell(e);
99
+ } else {
100
+ this.handleMove(e);
101
+ }
102
+ });
103
+
104
+ disc.addEventListener('mousedown', (e) => {
105
+ if (e.target !== disc) return;
106
+ e.preventDefault();
107
+ this.handleMove(e);
108
+
109
+ const moveHandler = (me: MouseEvent) => this.handleMove(me);
110
+ document.addEventListener('mousemove', moveHandler);
111
+ document.addEventListener('mouseup', () => {
112
+ document.removeEventListener('mousemove', moveHandler);
113
+ this.stopDwell();
114
+ this.resetJoystick();
115
+ }, { once: true });
116
+ });
117
+
118
+ disc.addEventListener('mouseleave', () => {
119
+ this.stopDwell();
120
+ this.resetJoystick();
121
+ });
122
+
123
+ // Touch on disc
124
+ disc.addEventListener('touchstart', (e) => {
125
+ if (e.target !== disc) return;
126
+ e.preventDefault();
127
+ this.handleMove(e);
128
+
129
+ const moveHandler = (te: TouchEvent) => this.handleMove(te);
130
+ document.addEventListener('touchmove', moveHandler, { passive: false });
131
+ document.addEventListener('touchend', () => {
132
+ document.removeEventListener('touchmove', moveHandler);
133
+ this.stopDwell();
134
+ this.resetJoystick();
135
+ }, { once: true });
136
+ }, { passive: false });
137
+
138
+ // Zoom buttons
139
+ zoomIn.addEventListener('click', (e) => {
140
+ e.stopPropagation();
141
+ this.viewer.zoomIn();
142
+ });
143
+
144
+ zoomOut.addEventListener('click', (e) => {
145
+ e.stopPropagation();
146
+ this.viewer.zoomOut();
147
+ });
148
+
149
+ // Stop panning when entering inner circle
150
+ innerCircle.addEventListener('mouseenter', () => {
151
+ this.stopDwell();
152
+ this.resetJoystick();
153
+ });
154
+
155
+ innerCircle.addEventListener('mousedown', (e) => e.stopPropagation());
156
+ innerCircle.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: false });
157
+ }
158
+
159
+ private handleMove(event: MouseEvent | TouchEvent): void {
160
+ if (!this.visible) return;
161
+
162
+ const clientX = 'clientX' in event ? event.clientX : event.touches[0]?.clientX ?? 0;
163
+ const clientY = 'clientY' in event ? event.clientY : event.touches[0]?.clientY ?? 0;
164
+
165
+ const rect = this.dom.disc.getBoundingClientRect();
166
+ const centerX = rect.left + rect.width / 2;
167
+ const centerY = rect.top + rect.height / 2;
168
+ const distX = clientX - centerX;
169
+ const distY = clientY - centerY;
170
+ const distance = Math.sqrt(distX * distX + distY * distY);
171
+
172
+ // Normalize to -1..1
173
+ let normalizedX = distX / this.options.radius;
174
+ let normalizedY = distY / this.options.radius;
175
+
176
+ if (distance > this.options.radius) {
177
+ normalizedX = normalizedX * (this.options.radius / distance);
178
+ normalizedY = normalizedY * (this.options.radius / distance);
179
+ }
180
+
181
+ if (Math.abs(normalizedX) < this.options.deadzone) normalizedX = 0;
182
+ if (Math.abs(normalizedY) < this.options.deadzone) normalizedY = 0;
183
+
184
+ this.joystickX = normalizedX;
185
+ this.joystickY = normalizedY;
186
+
187
+ // Direction feedback
188
+ if (distance > this.options.radius * this.options.deadzone) {
189
+ const angle = Math.atan2(distY, distX) * 180 / Math.PI;
190
+ const dir = angleToDirection(angle);
191
+ if (dir !== this.currentDirection) {
192
+ this.clearDirection();
193
+ this.dom.disc.classList.add(dir);
194
+ this.currentDirection = dir;
195
+ }
196
+ } else {
197
+ this.clearDirection();
198
+ }
199
+
200
+ // Start movement if not already running
201
+ if (!this.animationId && (Math.abs(normalizedX) > this.options.deadzone || Math.abs(normalizedY) > this.options.deadzone)) {
202
+ this.active = true;
203
+ this.dom.disc.classList.add('active');
204
+ this.startMovement();
205
+ this.updateAria(normalizedX, normalizedY);
206
+ } else if (Math.abs(normalizedX) <= this.options.deadzone && Math.abs(normalizedY) <= this.options.deadzone) {
207
+ this.active = false;
208
+ this.dom.disc.classList.remove('active');
209
+ this.stopMovement();
210
+ this.dom.disc.setAttribute('aria-valuenow', '0');
211
+ this.dom.disc.setAttribute('aria-valuetext', 'Center position — not moving');
212
+ }
213
+ }
214
+
215
+ private startDwell(e: MouseEvent | TouchEvent): void {
216
+ if (this.dwellTimer) clearTimeout(this.dwellTimer);
217
+ this.dwellTimer = setTimeout(() => {
218
+ this.isDwelling = true;
219
+ this.handleMove(e);
220
+ }, this.options.dwellTimeout);
221
+ }
222
+
223
+ private stopDwell(): void {
224
+ if (this.dwellTimer) {
225
+ clearTimeout(this.dwellTimer);
226
+ this.dwellTimer = null;
227
+ }
228
+ this.isDwelling = false;
229
+ }
230
+
231
+ private startMovement(): void {
232
+ if (this.animationId) return;
233
+ const step = () => {
234
+ if (!this.active) { this.stopMovement(); return; }
235
+ const vx = -this.joystickX * this.options.maxSpeed;
236
+ const vy = -this.joystickY * this.options.maxSpeed;
237
+ this.viewer.applyVelocity(vx, vy);
238
+ this.animationId = requestAnimationFrame(step);
239
+ };
240
+ this.animationId = requestAnimationFrame(step);
241
+ }
242
+
243
+ private stopMovement(): void {
244
+ if (this.animationId) {
245
+ cancelAnimationFrame(this.animationId);
246
+ this.animationId = null;
247
+ }
248
+ }
249
+
250
+ private resetJoystick(): void {
251
+ this.joystickX = 0;
252
+ this.joystickY = 0;
253
+ this.active = false;
254
+ this.stopMovement();
255
+ this.clearDirection();
256
+ this.dom.disc.classList.remove('active');
257
+ }
258
+
259
+ private clearDirection(): void {
260
+ if (this.currentDirection) {
261
+ this.dom.disc.classList.remove(this.currentDirection);
262
+ this.currentDirection = '';
263
+ }
264
+ }
265
+
266
+ private updateAria(x: number, y: number): void {
267
+ const magnitude = Math.sqrt(x * x + y * y);
268
+ this.dom.disc.setAttribute('aria-valuenow', magnitude.toFixed(2));
269
+
270
+ let direction: string;
271
+ if (Math.abs(x) > Math.abs(y)) {
272
+ direction = x > 0 ? 'right' : 'left';
273
+ } else {
274
+ direction = y > 0 ? 'down' : 'up';
275
+ }
276
+
277
+ const intensity = magnitude > 0.7 ? 'fast' : magnitude > 0.3 ? 'medium' : 'slow';
278
+ this.dom.disc.setAttribute('aria-valuetext', `Moving ${intensity} ${direction}`);
279
+ }
280
+ }
@@ -0,0 +1,101 @@
1
+ export const ZOOOOM_CSS = `
2
+ [data-zoooom] {
3
+ --zoooom-bg: #000;
4
+ --zoooom-spinner-color: #2196f3;
5
+ --zoooom-spinner-track: rgba(255, 255, 255, 0.3);
6
+ --zoooom-spinner-size: 40px;
7
+ --zoooom-loading-bg: rgba(0, 0, 0, 0.85);
8
+ --zoooom-loading-radius: 10px;
9
+ --zoooom-cursor: grab;
10
+ --zoooom-cursor-active: grabbing;
11
+ --zoooom-transition-speed: 0.2s;
12
+ --zoooom-fade-speed: 0.3s;
13
+
14
+ position: relative;
15
+ width: 100%;
16
+ height: 100%;
17
+ background: var(--zoooom-bg);
18
+ overflow: hidden;
19
+ touch-action: none;
20
+ cursor: var(--zoooom-cursor);
21
+ user-select: none;
22
+ -webkit-user-select: none;
23
+ }
24
+
25
+ [data-zoooom]:focus-visible {
26
+ outline: 2px solid var(--zoooom-spinner-color);
27
+ outline-offset: -2px;
28
+ }
29
+
30
+ [data-zoooom] .zoooom-image {
31
+ position: absolute;
32
+ top: 50%;
33
+ left: 50%;
34
+ transform: translate(-50%, -50%) scale(1);
35
+ max-width: 100%;
36
+ max-height: 100%;
37
+ object-fit: contain;
38
+ user-select: none;
39
+ -webkit-user-select: none;
40
+ pointer-events: none;
41
+ }
42
+
43
+ [data-zoooom] .zoooom-loading {
44
+ position: absolute;
45
+ top: 50%;
46
+ left: 50%;
47
+ transform: translate(-50%, -50%);
48
+ display: flex;
49
+ flex-direction: column;
50
+ align-items: center;
51
+ justify-content: center;
52
+ background: var(--zoooom-loading-bg);
53
+ padding: 20px;
54
+ border-radius: var(--zoooom-loading-radius);
55
+ z-index: 10;
56
+ min-width: 120px;
57
+ }
58
+
59
+ [data-zoooom] .zoooom-spinner {
60
+ width: var(--zoooom-spinner-size);
61
+ height: var(--zoooom-spinner-size);
62
+ border: 4px solid var(--zoooom-spinner-track);
63
+ border-radius: 50%;
64
+ border-top-color: var(--zoooom-spinner-color);
65
+ animation: zoooom-spin 1s linear infinite;
66
+ }
67
+
68
+ [data-zoooom] .zoooom-loading-text {
69
+ margin-top: 12px;
70
+ font-size: 14px;
71
+ color: #fff;
72
+ font-family: system-ui, -apple-system, sans-serif;
73
+ }
74
+
75
+ @keyframes zoooom-spin {
76
+ to { transform: rotate(360deg); }
77
+ }
78
+
79
+ @media (prefers-reduced-motion: reduce) {
80
+ [data-zoooom] .zoooom-spinner {
81
+ animation: none;
82
+ border-top-color: var(--zoooom-spinner-track);
83
+ border-right-color: var(--zoooom-spinner-color);
84
+ }
85
+
86
+ [data-zoooom] .zoooom-image {
87
+ transition: none !important;
88
+ }
89
+ }
90
+ `;
91
+
92
+ let injected = false;
93
+
94
+ export function injectCoreStyles(): void {
95
+ if (injected || typeof document === 'undefined') return;
96
+ const style = document.createElement('style');
97
+ style.setAttribute('data-zoooom-core', '');
98
+ style.textContent = ZOOOOM_CSS;
99
+ document.head.appendChild(style);
100
+ injected = true;
101
+ }
@@ -0,0 +1,213 @@
1
+ export const JOYSTICK_CSS = `
2
+ .zoooom-joystick-wrap {
3
+ position: fixed;
4
+ bottom: 20px;
5
+ left: 50%;
6
+ transform: translateX(-50%);
7
+ z-index: 100;
8
+ touch-action: none;
9
+ opacity: 0;
10
+ pointer-events: none;
11
+ transition: opacity 0.3s ease-out, transform 0.3s ease-out;
12
+ }
13
+
14
+ .zoooom-joystick-wrap.visible {
15
+ opacity: 1;
16
+ pointer-events: auto;
17
+ }
18
+
19
+ .zoooom-joystick-toggle {
20
+ position: fixed;
21
+ bottom: 20px;
22
+ left: 50%;
23
+ transform: translateX(-50%);
24
+ width: 56px;
25
+ height: 56px;
26
+ border-radius: 50%;
27
+ background: rgba(0, 0, 0, 0.5);
28
+ border: 2px solid rgba(255, 255, 255, 0.6);
29
+ cursor: pointer;
30
+ z-index: 99;
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: center;
34
+ color: #fff;
35
+ font-size: 20px;
36
+ font-weight: bold;
37
+ backdrop-filter: blur(4px);
38
+ transition: background 0.2s, box-shadow 0.2s;
39
+ }
40
+
41
+ .zoooom-joystick-toggle:hover {
42
+ background: rgba(0, 0, 0, 0.7);
43
+ box-shadow: 0 0 12px rgba(0, 0, 0, 0.4);
44
+ }
45
+
46
+ .zoooom-joystick-toggle:focus-visible {
47
+ outline: 2px solid #2196f3;
48
+ outline-offset: 2px;
49
+ }
50
+
51
+ .zoooom-disc {
52
+ width: 200px;
53
+ height: 200px;
54
+ border-radius: 50%;
55
+ background: rgba(0, 0, 0, 0.4);
56
+ border: 2px solid rgba(255, 255, 255, 0.6);
57
+ position: relative;
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: center;
61
+ touch-action: none;
62
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
63
+ }
64
+
65
+ .zoooom-disc.active {
66
+ border-color: rgba(255, 255, 255, 0.9);
67
+ }
68
+
69
+ .zoooom-inner-circle {
70
+ width: 72px;
71
+ height: 72px;
72
+ border-radius: 50%;
73
+ background: rgba(255, 255, 255, 0.15);
74
+ border: 1px solid rgba(255, 255, 255, 0.5);
75
+ display: flex;
76
+ position: relative;
77
+ z-index: 3;
78
+ }
79
+
80
+ .zoooom-zoom-half {
81
+ width: 50%;
82
+ height: 100%;
83
+ display: flex;
84
+ align-items: center;
85
+ justify-content: center;
86
+ cursor: pointer;
87
+ font-weight: bold;
88
+ font-size: 22px;
89
+ color: #fff;
90
+ user-select: none;
91
+ transition: background 0.15s;
92
+ }
93
+
94
+ .zoooom-zoom-half:hover {
95
+ background: rgba(255, 255, 255, 0.25);
96
+ }
97
+
98
+ .zoooom-zoom-out {
99
+ border-radius: 36px 0 0 36px;
100
+ border-right: 1px solid rgba(255, 255, 255, 0.4);
101
+ }
102
+
103
+ .zoooom-zoom-in {
104
+ border-radius: 0 36px 36px 0;
105
+ }
106
+
107
+ .zoooom-arrows {
108
+ position: absolute;
109
+ width: 100%;
110
+ height: 100%;
111
+ border-radius: 50%;
112
+ pointer-events: none;
113
+ overflow: hidden;
114
+ }
115
+
116
+ .zoooom-arrow {
117
+ position: absolute;
118
+ width: 0;
119
+ height: 0;
120
+ opacity: 0;
121
+ transition: opacity 0.2s;
122
+ }
123
+
124
+ .zoooom-arrow-n {
125
+ top: 14px;
126
+ left: 50%;
127
+ transform: translateX(-50%);
128
+ border-left: 10px solid transparent;
129
+ border-right: 10px solid transparent;
130
+ border-bottom: 14px solid rgba(255, 255, 255, 0.7);
131
+ }
132
+
133
+ .zoooom-arrow-e {
134
+ top: 50%;
135
+ right: 14px;
136
+ transform: translateY(-50%);
137
+ border-top: 10px solid transparent;
138
+ border-bottom: 10px solid transparent;
139
+ border-left: 14px solid rgba(255, 255, 255, 0.7);
140
+ }
141
+
142
+ .zoooom-arrow-s {
143
+ bottom: 14px;
144
+ left: 50%;
145
+ transform: translateX(-50%);
146
+ border-left: 10px solid transparent;
147
+ border-right: 10px solid transparent;
148
+ border-top: 14px solid rgba(255, 255, 255, 0.7);
149
+ }
150
+
151
+ .zoooom-arrow-w {
152
+ top: 50%;
153
+ left: 14px;
154
+ transform: translateY(-50%);
155
+ border-top: 10px solid transparent;
156
+ border-bottom: 10px solid transparent;
157
+ border-right: 14px solid rgba(255, 255, 255, 0.7);
158
+ }
159
+
160
+ .zoooom-disc.north .zoooom-arrow-n,
161
+ .zoooom-disc.south .zoooom-arrow-s,
162
+ .zoooom-disc.east .zoooom-arrow-e,
163
+ .zoooom-disc.west .zoooom-arrow-w,
164
+ .zoooom-disc.north-east .zoooom-arrow-n,
165
+ .zoooom-disc.north-east .zoooom-arrow-e,
166
+ .zoooom-disc.south-east .zoooom-arrow-s,
167
+ .zoooom-disc.south-east .zoooom-arrow-e,
168
+ .zoooom-disc.south-west .zoooom-arrow-s,
169
+ .zoooom-disc.south-west .zoooom-arrow-w,
170
+ .zoooom-disc.north-west .zoooom-arrow-n,
171
+ .zoooom-disc.north-west .zoooom-arrow-w {
172
+ opacity: 1;
173
+ }
174
+
175
+ @media (max-width: 768px) {
176
+ .zoooom-disc {
177
+ width: 140px;
178
+ height: 140px;
179
+ }
180
+
181
+ .zoooom-inner-circle {
182
+ width: 56px;
183
+ height: 56px;
184
+ }
185
+
186
+ .zoooom-joystick-toggle {
187
+ width: 48px;
188
+ height: 48px;
189
+ font-size: 16px;
190
+ }
191
+ }
192
+
193
+ @media (prefers-reduced-motion: reduce) {
194
+ .zoooom-joystick-wrap {
195
+ transition: none;
196
+ }
197
+
198
+ .zoooom-arrow {
199
+ transition: none;
200
+ }
201
+ }
202
+ `;
203
+
204
+ let injected = false;
205
+
206
+ export function injectJoystickStyles(): void {
207
+ if (injected || typeof document === 'undefined') return;
208
+ const style = document.createElement('style');
209
+ style.setAttribute('data-zoooom-joystick', '');
210
+ style.textContent = JOYSTICK_CSS;
211
+ document.head.appendChild(style);
212
+ injected = true;
213
+ }
package/src/types.ts ADDED
@@ -0,0 +1,118 @@
1
+ /** Configuration options for the Zoooom viewer */
2
+ export interface ZoooomOptions {
3
+ /** Image URL to display (required) */
4
+ src: string;
5
+ /** Alt text for the image */
6
+ alt?: string;
7
+
8
+ // Zoom behavior
9
+ /** Minimum zoom scale (default: 0.8) */
10
+ minScale?: number;
11
+ /** Maximum zoom scale — 'auto' calculates from natural dimensions (default: 'auto') */
12
+ maxScale?: number | 'auto';
13
+ /** Multiplier beyond native resolution for max zoom (default: 2) */
14
+ overscaleFactor?: number;
15
+ /** Zoom multiplier per discrete step (default: 1.5) */
16
+ zoomFactor?: number;
17
+
18
+ // Pan behavior
19
+ /** Keyboard pan distance in pixels (default: 50) */
20
+ panStep?: number;
21
+ /** Momentum friction coefficient, 0-1 (default: 0.85) */
22
+ velocityDamping?: number;
23
+
24
+ // Trackpad
25
+ /** Sensitivity for continuous trackpad zoom (default: 0.002) */
26
+ trackpadSensitivity?: number;
27
+
28
+ // Input methods
29
+ /** Enable mouse drag/wheel (default: true) */
30
+ mouse?: boolean;
31
+ /** Enable touch pan/pinch (default: true) */
32
+ touch?: boolean;
33
+ /** Enable wheel/trackpad zoom (default: true) */
34
+ wheel?: boolean;
35
+ /** Enable keyboard navigation (default: true) */
36
+ keyboard?: boolean;
37
+
38
+ // Loading
39
+ /** Show loading spinner — true for default, string for custom HTML, false to disable */
40
+ loading?: boolean | string;
41
+
42
+ // CSS
43
+ /** Auto-inject bundled CSS (default: true) */
44
+ injectStyles?: boolean;
45
+
46
+ // Accessibility
47
+ /** Honor prefers-reduced-motion (default: true) */
48
+ respectReducedMotion?: boolean;
49
+
50
+ // Callbacks
51
+ onLoad?: () => void;
52
+ onError?: (error: Error) => void;
53
+ onZoom?: (scale: number) => void;
54
+ onPan?: (x: number, y: number) => void;
55
+ }
56
+
57
+ /** Internal mutable state for the viewer */
58
+ export interface ZoooomState {
59
+ scale: number;
60
+ translateX: number;
61
+ translateY: number;
62
+ velocityX: number;
63
+ velocityY: number;
64
+ maxScale: number;
65
+ isDragging: boolean;
66
+ isAnimating: boolean;
67
+ isLoaded: boolean;
68
+ startX: number;
69
+ startY: number;
70
+ initialDistance: number;
71
+ initialScale: number;
72
+ initialTranslateX: number;
73
+ initialTranslateY: number;
74
+ pinchCenter: { x: number; y: number };
75
+ wheelTimeout: ReturnType<typeof setTimeout> | null;
76
+ reducedMotion: boolean;
77
+ }
78
+
79
+ /** Event types emitted by Zoooom */
80
+ export type ZoooomEvent = 'load' | 'error' | 'zoom' | 'pan' | 'reset' | 'destroy';
81
+
82
+ /** Event handler function */
83
+ export type ZoooomEventHandler = (...args: unknown[]) => void;
84
+
85
+ /** Resolved options with defaults applied */
86
+ export type ZoooomResolvedOptions = Required<Omit<ZoooomOptions, 'onLoad' | 'onError' | 'onZoom' | 'onPan' | 'maxScale'>> & {
87
+ maxScale: number | 'auto';
88
+ onLoad?: () => void;
89
+ onError?: (error: Error) => void;
90
+ onZoom?: (scale: number) => void;
91
+ onPan?: (x: number, y: number) => void;
92
+ };
93
+
94
+ /** DOM elements managed by the viewer */
95
+ export interface ZoooomElements {
96
+ container: HTMLElement;
97
+ image: HTMLImageElement;
98
+ loadingOverlay: HTMLElement | null;
99
+ }
100
+
101
+ /** Cleanup function returned by input handlers */
102
+ export type InputCleanup = () => void;
103
+
104
+ /** Joystick plugin options */
105
+ export interface JoystickOptions {
106
+ /** Panning zone radius in pixels (default: 60) */
107
+ radius?: number;
108
+ /** Center deadzone as fraction 0-1 (default: 0.1) */
109
+ deadzone?: number;
110
+ /** Max panning speed in px/frame (default: 10) */
111
+ maxSpeed?: number;
112
+ /** Position of the joystick (default: 'bottom-center') */
113
+ position?: 'bottom-center' | 'bottom-left' | 'bottom-right';
114
+ /** Show compass toggle button (default: true) */
115
+ showToggle?: boolean;
116
+ /** Milliseconds before dwell activates panning (default: 100) */
117
+ dwellTimeout?: number;
118
+ }