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,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
|
+
}
|