@tegos/spindle 0.1.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 +110 -0
- package/dist/Spindle.d.ts +54 -0
- package/dist/frames.d.ts +4 -0
- package/dist/index.d.ts +2 -0
- package/dist/loader.d.ts +27 -0
- package/dist/momentum.d.ts +7 -0
- package/dist/options.d.ts +3 -0
- package/dist/renderer.d.ts +28 -0
- package/dist/spindle.js +255 -0
- package/dist/spindle.js.map +1 -0
- package/dist/spindle.umd.cjs +2 -0
- package/dist/spindle.umd.cjs.map +1 -0
- package/dist/types.d.ts +42 -0
- package/package.json +53 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Mykhavko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# spindle
|
|
2
|
+
|
|
3
|
+
360° frame-sequence spinner for the web — object spins and aerial orbits.
|
|
4
|
+
Vanilla TypeScript, **zero runtime dependencies**, single `<canvas>` render.
|
|
5
|
+
|
|
6
|
+
A modern rewrite of an old jQuery + SpriteSpin viewer (e.g. lun.ua drone
|
|
7
|
+
flyovers of a building complex). Viewer only — bring your own frames.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Frame source: individual image URLs **or** a sprite sheet
|
|
12
|
+
- Progressive loading: paints frame 1 and becomes interactive early, streams the rest (`onProgress` / `onReady`)
|
|
13
|
+
- Drag + touch with momentum/inertia and wraparound looping
|
|
14
|
+
- Autoplay until the user grabs
|
|
15
|
+
- Zoom (pinch / scroll / double-tap) + pan within the current frame
|
|
16
|
+
- Fullscreen toggle
|
|
17
|
+
- ESM + UMD builds with type definitions
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @tegos/spindle
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Spindle } from '@tegos/spindle'
|
|
29
|
+
|
|
30
|
+
const s = new Spindle('#jk-avalon', {
|
|
31
|
+
source: ['lun/1.jpg', 'lun/2.jpg', /* … */], // or { sheet, frames, fw, fh }
|
|
32
|
+
autoplay: true,
|
|
33
|
+
loop: true,
|
|
34
|
+
momentum: true,
|
|
35
|
+
zoom: true,
|
|
36
|
+
fullscreen: true,
|
|
37
|
+
onProgress: (p) => console.log(`${Math.round(p * 100)}%`),
|
|
38
|
+
onReady: () => console.log('ready'),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
s.goto(12)
|
|
42
|
+
s.play()
|
|
43
|
+
s.stop()
|
|
44
|
+
s.fullscreen()
|
|
45
|
+
s.destroy()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The target element needs a size from CSS; spindle fills it.
|
|
49
|
+
|
|
50
|
+
### Sprite sheet source
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
new Spindle('#viewer', {
|
|
54
|
+
source: { sheet: 'orbit.jpg', frames: 36, fw: 800, fh: 450, cols: 6 },
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Options
|
|
59
|
+
|
|
60
|
+
| Option | Default | Meaning |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `source` | — | Frame URLs `string[]` or `{ sheet, frames, fw, fh, cols? }` (required) |
|
|
63
|
+
| `autoplay` | `false` | Spin on load until grabbed |
|
|
64
|
+
| `loop` | `true` | Wrap around at the ends |
|
|
65
|
+
| `momentum` | `true` | Fling with inertia on release |
|
|
66
|
+
| `zoom` | `true` | Pinch / scroll / double-tap zoom + pan |
|
|
67
|
+
| `fullscreen` | `true` | Show a fullscreen toggle |
|
|
68
|
+
| `pxPerFrame` | `8` | Drag pixels per frame step |
|
|
69
|
+
| `autoplayFps` | `12` | Frames/sec during autoplay |
|
|
70
|
+
| `maxZoom` | `4` | Max zoom factor |
|
|
71
|
+
| `onProgress(p)` | — | Load progress `0..1` |
|
|
72
|
+
| `onReady()` | — | First frame painted, interactive |
|
|
73
|
+
|
|
74
|
+
## API
|
|
75
|
+
|
|
76
|
+
`new Spindle(target, options)` — `target` is a selector or element.
|
|
77
|
+
|
|
78
|
+
- `goto(i)` — jump to a frame (wrapped or clamped per `loop`)
|
|
79
|
+
- `play()` / `stop()` — start / stop autoplay
|
|
80
|
+
- `fullscreen()` — toggle fullscreen
|
|
81
|
+
- `resetZoom()` — back to fit view
|
|
82
|
+
- `destroy()` — tear down listeners and DOM
|
|
83
|
+
- `frame` (get) — current frame index
|
|
84
|
+
- `length` (get) — total frames
|
|
85
|
+
|
|
86
|
+
## Develop
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npm install
|
|
90
|
+
npm run dev # vite dev server doubles as the examples (examples/)
|
|
91
|
+
npm test # vitest
|
|
92
|
+
npm run build # dist/: ESM + UMD + .d.ts
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Examples
|
|
96
|
+
|
|
97
|
+
`npm run dev` serves `examples/` (vite) with two viewers:
|
|
98
|
+
|
|
99
|
+
- **Aerial orbit** — drone flyover of ЖК Avalon Holiday
|
|
100
|
+
- **Product spin** — object turntable
|
|
101
|
+
|
|
102
|
+
Demo frames are **for demonstration only** — see the `CREDITS.md` next to each
|
|
103
|
+
set. Aerial frames are a drone flyover from
|
|
104
|
+
[lun.ua](https://lun.ua/uk/жк-avalon-holiday-сокільники/аерообліт); the product
|
|
105
|
+
turntable is a generic sample found online (source unknown, unaffiliated with
|
|
106
|
+
lun.ua). Supply your own frames for production use.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT (code only — see demo frame credits above).
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { SpindleOptions } from './types';
|
|
2
|
+
export declare class Spindle {
|
|
3
|
+
readonly el: HTMLElement;
|
|
4
|
+
private readonly opts;
|
|
5
|
+
private readonly store;
|
|
6
|
+
private readonly renderer;
|
|
7
|
+
private readonly root;
|
|
8
|
+
private fsBtn?;
|
|
9
|
+
/** Current frame as a float; the rendered frame is the rounded, wrapped value. */
|
|
10
|
+
private frameF;
|
|
11
|
+
private view;
|
|
12
|
+
private ready;
|
|
13
|
+
private readonly pointers;
|
|
14
|
+
private lastX;
|
|
15
|
+
private lastY;
|
|
16
|
+
private lastMoveTs;
|
|
17
|
+
private velocity;
|
|
18
|
+
private pinchDist;
|
|
19
|
+
private lastTapTs;
|
|
20
|
+
private raf;
|
|
21
|
+
private lastTick;
|
|
22
|
+
private autoplaying;
|
|
23
|
+
private momentumActive;
|
|
24
|
+
private destroyed;
|
|
25
|
+
constructor(target: string | HTMLElement, options: SpindleOptions);
|
|
26
|
+
/** Jump to a frame index (wrapped when loop is on, else clamped). */
|
|
27
|
+
goto(index: number): void;
|
|
28
|
+
get frame(): number;
|
|
29
|
+
get length(): number;
|
|
30
|
+
/** Start autoplay (cancels on the next pointer grab). */
|
|
31
|
+
play(): void;
|
|
32
|
+
/** Stop autoplay and momentum. */
|
|
33
|
+
stop(): void;
|
|
34
|
+
/** Toggle browser fullscreen on the viewer. */
|
|
35
|
+
fullscreen(): void;
|
|
36
|
+
/** Reset zoom and pan to the fit view. */
|
|
37
|
+
resetZoom(): void;
|
|
38
|
+
destroy(): void;
|
|
39
|
+
private onFirstFrame;
|
|
40
|
+
private bindEvents;
|
|
41
|
+
private onResize;
|
|
42
|
+
private onPointerDown;
|
|
43
|
+
private onPointerMove;
|
|
44
|
+
private onPointerUp;
|
|
45
|
+
private onWheel;
|
|
46
|
+
private pointerSpread;
|
|
47
|
+
private handlePinch;
|
|
48
|
+
private toggleZoom;
|
|
49
|
+
private applyZoom;
|
|
50
|
+
private mountFullscreenButton;
|
|
51
|
+
private ensureLoop;
|
|
52
|
+
private tick;
|
|
53
|
+
private render;
|
|
54
|
+
}
|
package/dist/frames.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Wrap a frame index into [0, n) looping both directions. */
|
|
2
|
+
export declare function wrapIndex(i: number, n: number): number;
|
|
3
|
+
/** Whole frames covered by a pixel drag distance, truncated toward zero. */
|
|
4
|
+
export declare function frameDelta(dx: number, pxPerFrame: number): number;
|
package/dist/index.d.ts
ADDED
package/dist/loader.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Source } from './types';
|
|
2
|
+
/** A drawable region: an image plus the source rect to blit from it. */
|
|
3
|
+
export interface FrameRegion {
|
|
4
|
+
img: CanvasImageSource;
|
|
5
|
+
sx: number;
|
|
6
|
+
sy: number;
|
|
7
|
+
sw: number;
|
|
8
|
+
sh: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Owns frame pixels for one source. Loads progressively: the first frame is
|
|
12
|
+
* decoded up front so the viewer can paint and become interactive, then the
|
|
13
|
+
* remainder stream in. `get` returns null for a frame not yet decoded.
|
|
14
|
+
*/
|
|
15
|
+
export declare class FrameStore {
|
|
16
|
+
readonly count: number;
|
|
17
|
+
private readonly source;
|
|
18
|
+
private readonly imgs;
|
|
19
|
+
private sheetImg;
|
|
20
|
+
constructor(source: Source);
|
|
21
|
+
get(i: number): FrameRegion | null;
|
|
22
|
+
/**
|
|
23
|
+
* Begin loading. Resolves `onFirst` once frame 0 is ready, then loads the
|
|
24
|
+
* rest, calling `onProgress(0..1)` after each and `onReady` when all decode.
|
|
25
|
+
*/
|
|
26
|
+
load(onFirst: () => void, onProgress: (p: number) => void, onReady: () => void): Promise<void>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exponentially decay a velocity by `friction` per ~16ms frame, scaled to the
|
|
3
|
+
* real elapsed time so the fling feels the same regardless of refresh rate.
|
|
4
|
+
*/
|
|
5
|
+
export declare function decayVelocity(v: number, friction: number, dtMs: number): number;
|
|
6
|
+
/** True once the absolute speed has dropped below the resting threshold. */
|
|
7
|
+
export declare function isResting(v: number, threshold: number): boolean;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FrameRegion } from './loader';
|
|
2
|
+
/** View transform applied on top of the base contain-fit. */
|
|
3
|
+
export interface ViewTransform {
|
|
4
|
+
/** Zoom factor, 1 = fit. */
|
|
5
|
+
zoom: number;
|
|
6
|
+
/** Pan offset in CSS pixels, applied after zoom. */
|
|
7
|
+
panX: number;
|
|
8
|
+
panY: number;
|
|
9
|
+
}
|
|
10
|
+
export declare const IDENTITY: ViewTransform;
|
|
11
|
+
/** Clamp a pan offset so the zoomed frame can't be dragged off the canvas. */
|
|
12
|
+
export declare function clampPan(t: ViewTransform, cw: number, ch: number): ViewTransform;
|
|
13
|
+
/**
|
|
14
|
+
* Draws frames into a canvas, keeping a backing-store sized to the element and
|
|
15
|
+
* device pixel ratio. Each frame is contained (whole frame visible, centered),
|
|
16
|
+
* then the view transform (zoom + pan) is applied.
|
|
17
|
+
*/
|
|
18
|
+
export declare class Renderer {
|
|
19
|
+
readonly canvas: HTMLCanvasElement;
|
|
20
|
+
private readonly ctx;
|
|
21
|
+
private cssW;
|
|
22
|
+
private cssH;
|
|
23
|
+
constructor(canvas: HTMLCanvasElement);
|
|
24
|
+
/** Resize the backing store to the element's box. Returns true if it changed. */
|
|
25
|
+
resize(): boolean;
|
|
26
|
+
clear(): void;
|
|
27
|
+
draw(region: FrameRegion, view: ViewTransform): void;
|
|
28
|
+
}
|
package/dist/spindle.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
const u = () => {
|
|
2
|
+
};
|
|
3
|
+
function g(o) {
|
|
4
|
+
return {
|
|
5
|
+
source: o.source,
|
|
6
|
+
autoplay: o.autoplay ?? !1,
|
|
7
|
+
loop: o.loop ?? !0,
|
|
8
|
+
momentum: o.momentum ?? !0,
|
|
9
|
+
zoom: o.zoom ?? !0,
|
|
10
|
+
fullscreen: o.fullscreen ?? !0,
|
|
11
|
+
pxPerFrame: o.pxPerFrame ?? 8,
|
|
12
|
+
autoplayFps: o.autoplayFps ?? 12,
|
|
13
|
+
maxZoom: o.maxZoom ?? 4,
|
|
14
|
+
onProgress: o.onProgress ?? u,
|
|
15
|
+
onReady: o.onReady ?? u
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function p(o, t) {
|
|
19
|
+
return (o % t + t) % t;
|
|
20
|
+
}
|
|
21
|
+
function l(o) {
|
|
22
|
+
return !Array.isArray(o);
|
|
23
|
+
}
|
|
24
|
+
function m(o) {
|
|
25
|
+
return new Promise((t, e) => {
|
|
26
|
+
const i = new Image();
|
|
27
|
+
i.crossOrigin = "anonymous", i.onload = () => {
|
|
28
|
+
i.decode ? i.decode().then(() => t(i), () => t(i)) : t(i);
|
|
29
|
+
}, i.onerror = () => e(new Error(`spindle: failed to load ${o}`)), i.src = o;
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
class x {
|
|
33
|
+
constructor(t) {
|
|
34
|
+
this.sheetImg = null, this.source = t, this.count = l(t) ? t.frames : t.length, this.imgs = new Array(this.count).fill(null);
|
|
35
|
+
}
|
|
36
|
+
get(t) {
|
|
37
|
+
const e = this.source;
|
|
38
|
+
if (l(e)) {
|
|
39
|
+
if (!this.sheetImg) return null;
|
|
40
|
+
const s = e.cols ?? e.frames, n = t % s, r = Math.floor(t / s);
|
|
41
|
+
return { img: this.sheetImg, sx: n * e.fw, sy: r * e.fh, sw: e.fw, sh: e.fh };
|
|
42
|
+
}
|
|
43
|
+
const i = this.imgs[t];
|
|
44
|
+
return i ? { img: i, sx: 0, sy: 0, sw: i.naturalWidth, sh: i.naturalHeight } : null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Begin loading. Resolves `onFirst` once frame 0 is ready, then loads the
|
|
48
|
+
* rest, calling `onProgress(0..1)` after each and `onReady` when all decode.
|
|
49
|
+
*/
|
|
50
|
+
async load(t, e, i) {
|
|
51
|
+
const s = this.source;
|
|
52
|
+
if (l(s)) {
|
|
53
|
+
this.sheetImg = await m(s.sheet), t(), e(1), i();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let n = 0;
|
|
57
|
+
const r = () => e(this.count === 0 ? 1 : n / this.count);
|
|
58
|
+
if (this.imgs[0] = await m(s[0]), n++, t(), r(), n === this.count) return i();
|
|
59
|
+
await Promise.all(
|
|
60
|
+
s.slice(1).map(
|
|
61
|
+
(a, h) => m(a).then((c) => {
|
|
62
|
+
this.imgs[h + 1] = c, n++, r();
|
|
63
|
+
})
|
|
64
|
+
)
|
|
65
|
+
), i();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const d = { zoom: 1, panX: 0, panY: 0 };
|
|
69
|
+
function f(o, t, e) {
|
|
70
|
+
const i = Math.max(0, (t * o.zoom - t) / 2), s = Math.max(0, (e * o.zoom - e) / 2);
|
|
71
|
+
return {
|
|
72
|
+
zoom: o.zoom,
|
|
73
|
+
panX: Math.min(i, Math.max(-i, o.panX)),
|
|
74
|
+
panY: Math.min(s, Math.max(-s, o.panY))
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
class z {
|
|
78
|
+
constructor(t) {
|
|
79
|
+
this.cssW = 0, this.cssH = 0, this.canvas = t;
|
|
80
|
+
const e = t.getContext("2d");
|
|
81
|
+
if (!e) throw new Error("spindle: 2d canvas context unavailable");
|
|
82
|
+
this.ctx = e;
|
|
83
|
+
}
|
|
84
|
+
/** Resize the backing store to the element's box. Returns true if it changed. */
|
|
85
|
+
resize() {
|
|
86
|
+
const t = window.devicePixelRatio || 1, e = this.canvas.clientWidth, i = this.canvas.clientHeight;
|
|
87
|
+
return e === this.cssW && i === this.cssH && this.canvas.width === Math.round(e * t) ? !1 : (this.cssW = e, this.cssH = i, this.canvas.width = Math.round(e * t), this.canvas.height = Math.round(i * t), this.ctx.setTransform(t, 0, 0, t, 0, 0), !0);
|
|
88
|
+
}
|
|
89
|
+
clear() {
|
|
90
|
+
this.ctx.clearRect(0, 0, this.cssW, this.cssH);
|
|
91
|
+
}
|
|
92
|
+
draw(t, e) {
|
|
93
|
+
const { ctx: i } = this, s = this.cssW, n = this.cssH;
|
|
94
|
+
if (i.clearRect(0, 0, s, n), t.sw === 0 || t.sh === 0) return;
|
|
95
|
+
const r = Math.min(s / t.sw, n / t.sh) * e.zoom, a = t.sw * r, h = t.sh * r, c = (s - a) / 2 + e.panX, y = (n - h) / 2 + e.panY;
|
|
96
|
+
i.drawImage(t.img, t.sx, t.sy, t.sw, t.sh, c, y, a, h);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const M = 16;
|
|
100
|
+
function F(o, t, e) {
|
|
101
|
+
return o * Math.pow(t, e / M);
|
|
102
|
+
}
|
|
103
|
+
function v(o, t) {
|
|
104
|
+
return Math.abs(o) < t;
|
|
105
|
+
}
|
|
106
|
+
const E = 0.94, w = 2e-4, T = 300;
|
|
107
|
+
function b(o) {
|
|
108
|
+
const t = typeof o == "string" ? document.querySelector(o) : o;
|
|
109
|
+
if (!t) throw new Error(`spindle: element not found for ${String(o)}`);
|
|
110
|
+
return t;
|
|
111
|
+
}
|
|
112
|
+
class P {
|
|
113
|
+
constructor(t, e) {
|
|
114
|
+
this.frameF = 0, this.view = { ...d }, this.ready = !1, this.pointers = /* @__PURE__ */ new Map(), this.lastX = 0, this.lastY = 0, this.lastMoveTs = 0, this.velocity = 0, this.pinchDist = 0, this.lastTapTs = 0, this.raf = 0, this.lastTick = 0, this.autoplaying = !1, this.momentumActive = !1, this.destroyed = !1, this.onResize = () => {
|
|
115
|
+
this.renderer.resize() && this.render();
|
|
116
|
+
}, this.onPointerDown = (s) => {
|
|
117
|
+
this.root.setPointerCapture(s.pointerId), this.pointers.set(s.pointerId, { x: s.clientX, y: s.clientY }), this.autoplaying = !1, this.momentumActive = !1, this.velocity = 0, this.lastX = s.clientX, this.lastY = s.clientY, this.lastMoveTs = s.timeStamp, this.root.style.cursor = "grabbing", this.pointers.size === 2 && (this.pinchDist = this.pointerSpread()), this.opts.zoom && s.timeStamp - this.lastTapTs < T ? (this.toggleZoom(s), this.lastTapTs = 0) : this.lastTapTs = s.timeStamp;
|
|
118
|
+
}, this.onPointerMove = (s) => {
|
|
119
|
+
const n = this.pointers.get(s.pointerId);
|
|
120
|
+
if (!n) return;
|
|
121
|
+
if (n.x = s.clientX, n.y = s.clientY, this.pointers.size >= 2) {
|
|
122
|
+
this.handlePinch();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const r = s.clientX - this.lastX, a = s.clientY - this.lastY;
|
|
126
|
+
if (this.view.zoom > 1)
|
|
127
|
+
this.view = f(
|
|
128
|
+
{ zoom: this.view.zoom, panX: this.view.panX + r, panY: this.view.panY + a },
|
|
129
|
+
this.root.clientWidth,
|
|
130
|
+
this.root.clientHeight
|
|
131
|
+
);
|
|
132
|
+
else {
|
|
133
|
+
const h = r / this.opts.pxPerFrame;
|
|
134
|
+
this.frameF += h;
|
|
135
|
+
const c = Math.max(1, s.timeStamp - this.lastMoveTs);
|
|
136
|
+
this.velocity = h / c;
|
|
137
|
+
}
|
|
138
|
+
this.lastX = s.clientX, this.lastY = s.clientY, this.lastMoveTs = s.timeStamp, this.render();
|
|
139
|
+
}, this.onPointerUp = (s) => {
|
|
140
|
+
this.pointers.delete(s.pointerId), this.pointers.size < 2 && (this.pinchDist = 0), !(this.pointers.size > 0) && (this.root.style.cursor = "grab", this.opts.momentum && this.view.zoom <= 1 && !v(this.velocity, w) && (this.momentumActive = !0, this.ensureLoop()));
|
|
141
|
+
}, this.onWheel = (s) => {
|
|
142
|
+
s.preventDefault();
|
|
143
|
+
const n = s.deltaY < 0 ? 1.15 : 1 / 1.15;
|
|
144
|
+
this.applyZoom(this.view.zoom * n, s);
|
|
145
|
+
}, this.tick = (s) => {
|
|
146
|
+
if (this.destroyed) return;
|
|
147
|
+
const n = this.lastTick ? s - this.lastTick : 16;
|
|
148
|
+
this.lastTick = s, this.autoplaying ? (this.frameF += this.opts.autoplayFps / 1e3 * n, this.render()) : this.momentumActive && (this.frameF += this.velocity * n, this.velocity = F(this.velocity, E, n), this.render(), v(this.velocity, w) && (this.momentumActive = !1)), this.autoplaying || this.momentumActive ? this.raf = requestAnimationFrame(this.tick) : this.raf = 0;
|
|
149
|
+
}, this.el = b(t), this.opts = g(e), this.store = new x(this.opts.source), this.root = document.createElement("div"), this.root.className = "spindle", Object.assign(this.root.style, {
|
|
150
|
+
position: "relative",
|
|
151
|
+
width: "100%",
|
|
152
|
+
height: "100%",
|
|
153
|
+
touchAction: "none",
|
|
154
|
+
overflow: "hidden",
|
|
155
|
+
cursor: "grab"
|
|
156
|
+
});
|
|
157
|
+
const i = document.createElement("canvas");
|
|
158
|
+
Object.assign(i.style, { display: "block", width: "100%", height: "100%" }), this.root.appendChild(i), this.el.appendChild(this.root), this.renderer = new z(i), this.opts.fullscreen && this.mountFullscreenButton(), this.bindEvents(), this.store.load(
|
|
159
|
+
() => this.onFirstFrame(),
|
|
160
|
+
(s) => this.opts.onProgress(s),
|
|
161
|
+
() => this.opts.onReady()
|
|
162
|
+
).catch((s) => console.error(s));
|
|
163
|
+
}
|
|
164
|
+
// ---- public API ---------------------------------------------------------
|
|
165
|
+
/** Jump to a frame index (wrapped when loop is on, else clamped). */
|
|
166
|
+
goto(t) {
|
|
167
|
+
this.frameF = this.opts.loop ? p(t, this.store.count) : Math.min(this.store.count - 1, Math.max(0, t)), this.render();
|
|
168
|
+
}
|
|
169
|
+
get frame() {
|
|
170
|
+
return p(Math.round(this.frameF), this.store.count);
|
|
171
|
+
}
|
|
172
|
+
get length() {
|
|
173
|
+
return this.store.count;
|
|
174
|
+
}
|
|
175
|
+
/** Start autoplay (cancels on the next pointer grab). */
|
|
176
|
+
play() {
|
|
177
|
+
this.autoplaying || (this.momentumActive = !1, this.autoplaying = !0, this.ensureLoop());
|
|
178
|
+
}
|
|
179
|
+
/** Stop autoplay and momentum. */
|
|
180
|
+
stop() {
|
|
181
|
+
this.autoplaying = !1, this.momentumActive = !1;
|
|
182
|
+
}
|
|
183
|
+
/** Toggle browser fullscreen on the viewer. */
|
|
184
|
+
fullscreen() {
|
|
185
|
+
var t, e;
|
|
186
|
+
document.fullscreenElement ? document.exitFullscreen() : (e = (t = this.root).requestFullscreen) == null || e.call(t);
|
|
187
|
+
}
|
|
188
|
+
/** Reset zoom and pan to the fit view. */
|
|
189
|
+
resetZoom() {
|
|
190
|
+
this.view = { ...d }, this.render();
|
|
191
|
+
}
|
|
192
|
+
destroy() {
|
|
193
|
+
this.destroyed = !0, cancelAnimationFrame(this.raf), window.removeEventListener("resize", this.onResize), this.root.remove(), this.pointers.clear();
|
|
194
|
+
}
|
|
195
|
+
// ---- loading ------------------------------------------------------------
|
|
196
|
+
onFirstFrame() {
|
|
197
|
+
this.ready = !0, this.renderer.resize(), this.render(), this.opts.autoplay && this.play();
|
|
198
|
+
}
|
|
199
|
+
// ---- events -------------------------------------------------------------
|
|
200
|
+
bindEvents() {
|
|
201
|
+
const t = this.root;
|
|
202
|
+
t.addEventListener("pointerdown", this.onPointerDown), t.addEventListener("pointermove", this.onPointerMove), t.addEventListener("pointerup", this.onPointerUp), t.addEventListener("pointercancel", this.onPointerUp), this.opts.zoom && t.addEventListener("wheel", this.onWheel, { passive: !1 }), window.addEventListener("resize", this.onResize);
|
|
203
|
+
}
|
|
204
|
+
// ---- zoom helpers -------------------------------------------------------
|
|
205
|
+
pointerSpread() {
|
|
206
|
+
const t = [...this.pointers.values()];
|
|
207
|
+
return t.length < 2 ? 0 : Math.hypot(t[0].x - t[1].x, t[0].y - t[1].y);
|
|
208
|
+
}
|
|
209
|
+
handlePinch() {
|
|
210
|
+
const t = this.pointerSpread();
|
|
211
|
+
this.pinchDist > 0 && t > 0 && this.applyZoom(this.view.zoom * (t / this.pinchDist)), this.pinchDist = t;
|
|
212
|
+
}
|
|
213
|
+
toggleZoom(t) {
|
|
214
|
+
this.view.zoom > 1 ? this.resetZoom() : this.applyZoom(2, t);
|
|
215
|
+
}
|
|
216
|
+
applyZoom(t, e) {
|
|
217
|
+
const i = Math.min(this.opts.maxZoom, Math.max(1, t));
|
|
218
|
+
let s = this.view.panX, n = this.view.panY;
|
|
219
|
+
if (e && i !== this.view.zoom) {
|
|
220
|
+
const r = this.root.getBoundingClientRect(), a = e.clientX - r.left - r.width / 2, h = e.clientY - r.top - r.height / 2, c = i / this.view.zoom;
|
|
221
|
+
s = (s - a) * c + a, n = (n - h) * c + h;
|
|
222
|
+
}
|
|
223
|
+
i === 1 && (s = 0, n = 0), this.view = f({ zoom: i, panX: s, panY: n }, this.root.clientWidth, this.root.clientHeight), this.render();
|
|
224
|
+
}
|
|
225
|
+
mountFullscreenButton() {
|
|
226
|
+
const t = document.createElement("button");
|
|
227
|
+
t.type = "button", t.className = "spindle-fs", t.setAttribute("aria-label", "Toggle fullscreen"), t.textContent = "⛶", Object.assign(t.style, {
|
|
228
|
+
position: "absolute",
|
|
229
|
+
right: "8px",
|
|
230
|
+
bottom: "8px",
|
|
231
|
+
width: "32px",
|
|
232
|
+
height: "32px",
|
|
233
|
+
border: "none",
|
|
234
|
+
borderRadius: "4px",
|
|
235
|
+
background: "rgba(0,0,0,0.5)",
|
|
236
|
+
color: "#fff",
|
|
237
|
+
font: "16px/1 sans-serif",
|
|
238
|
+
cursor: "pointer"
|
|
239
|
+
}), t.addEventListener("click", () => this.fullscreen()), this.root.appendChild(t), this.fsBtn = t;
|
|
240
|
+
}
|
|
241
|
+
// ---- animation loop -----------------------------------------------------
|
|
242
|
+
ensureLoop() {
|
|
243
|
+
this.raf || (this.lastTick = 0, this.raf = requestAnimationFrame(this.tick));
|
|
244
|
+
}
|
|
245
|
+
// ---- render -------------------------------------------------------------
|
|
246
|
+
render() {
|
|
247
|
+
if (!this.ready) return;
|
|
248
|
+
let t = this.frame, e = this.store.get(t);
|
|
249
|
+
e || (e = this.store.get(0)), e && this.renderer.draw(e, this.view), this.fsBtn && (this.fsBtn.textContent = document.fullscreenElement ? "⤢" : "⛶"), this.opts.loop || (this.frameF = Math.min(this.store.count - 1, Math.max(0, this.frameF)));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export {
|
|
253
|
+
P as Spindle
|
|
254
|
+
};
|
|
255
|
+
//# sourceMappingURL=spindle.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spindle.js","sources":["../src/options.ts","../src/frames.ts","../src/loader.ts","../src/renderer.ts","../src/momentum.ts","../src/Spindle.ts"],"sourcesContent":["import type { SpindleOptions, ResolvedOptions } from './types'\n\nconst noop = (): void => {}\n\n/** Merge caller options over defaults, leaving `source` by reference. */\nexport function resolveOptions(opts: SpindleOptions): ResolvedOptions {\n return {\n source: opts.source,\n autoplay: opts.autoplay ?? false,\n loop: opts.loop ?? true,\n momentum: opts.momentum ?? true,\n zoom: opts.zoom ?? true,\n fullscreen: opts.fullscreen ?? true,\n pxPerFrame: opts.pxPerFrame ?? 8,\n autoplayFps: opts.autoplayFps ?? 12,\n maxZoom: opts.maxZoom ?? 4,\n onProgress: opts.onProgress ?? noop,\n onReady: opts.onReady ?? noop,\n }\n}\n","/** Wrap a frame index into [0, n) looping both directions. */\nexport function wrapIndex(i: number, n: number): number {\n return ((i % n) + n) % n\n}\n\n/** Whole frames covered by a pixel drag distance, truncated toward zero. */\nexport function frameDelta(dx: number, pxPerFrame: number): number {\n return Math.trunc(dx / pxPerFrame)\n}\n","import type { Source, SheetSource } from './types'\n\n/** A drawable region: an image plus the source rect to blit from it. */\nexport interface FrameRegion {\n img: CanvasImageSource\n sx: number\n sy: number\n sw: number\n sh: number\n}\n\nfunction isSheet(s: Source): s is SheetSource {\n return !Array.isArray(s)\n}\n\nfunction loadImage(url: string): Promise<HTMLImageElement> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.crossOrigin = 'anonymous'\n img.onload = () => {\n // Decode off the main paint when supported; ignore failures.\n if (img.decode) img.decode().then(() => resolve(img), () => resolve(img))\n else resolve(img)\n }\n img.onerror = () => reject(new Error(`spindle: failed to load ${url}`))\n img.src = url\n })\n}\n\n/**\n * Owns frame pixels for one source. Loads progressively: the first frame is\n * decoded up front so the viewer can paint and become interactive, then the\n * remainder stream in. `get` returns null for a frame not yet decoded.\n */\nexport class FrameStore {\n readonly count: number\n private readonly source: Source\n private readonly imgs: (HTMLImageElement | null)[]\n private sheetImg: HTMLImageElement | null = null\n\n constructor(source: Source) {\n this.source = source\n this.count = isSheet(source) ? source.frames : source.length\n this.imgs = new Array(this.count).fill(null)\n }\n\n get(i: number): FrameRegion | null {\n const src = this.source\n if (isSheet(src)) {\n if (!this.sheetImg) return null\n const cols = src.cols ?? src.frames\n const col = i % cols\n const row = Math.floor(i / cols)\n return { img: this.sheetImg, sx: col * src.fw, sy: row * src.fh, sw: src.fw, sh: src.fh }\n }\n const img = this.imgs[i]\n if (!img) return null\n return { img, sx: 0, sy: 0, sw: img.naturalWidth, sh: img.naturalHeight }\n }\n\n /**\n * Begin loading. Resolves `onFirst` once frame 0 is ready, then loads the\n * rest, calling `onProgress(0..1)` after each and `onReady` when all decode.\n */\n async load(\n onFirst: () => void,\n onProgress: (p: number) => void,\n onReady: () => void,\n ): Promise<void> {\n const src = this.source\n if (isSheet(src)) {\n this.sheetImg = await loadImage(src.sheet)\n onFirst()\n onProgress(1)\n onReady()\n return\n }\n\n let done = 0\n const report = () => onProgress(this.count === 0 ? 1 : done / this.count)\n\n // Frame 0 first so we can paint and unlock interaction immediately.\n this.imgs[0] = await loadImage(src[0]!)\n done++\n onFirst()\n report()\n if (done === this.count) return onReady()\n\n await Promise.all(\n src.slice(1).map((url, k) =>\n loadImage(url).then((img) => {\n this.imgs[k + 1] = img\n done++\n report()\n }),\n ),\n )\n onReady()\n }\n}\n","import type { FrameRegion } from './loader'\n\n/** View transform applied on top of the base contain-fit. */\nexport interface ViewTransform {\n /** Zoom factor, 1 = fit. */\n zoom: number\n /** Pan offset in CSS pixels, applied after zoom. */\n panX: number\n panY: number\n}\n\nexport const IDENTITY: ViewTransform = { zoom: 1, panX: 0, panY: 0 }\n\n/** Clamp a pan offset so the zoomed frame can't be dragged off the canvas. */\nexport function clampPan(t: ViewTransform, cw: number, ch: number): ViewTransform {\n const maxX = Math.max(0, (cw * t.zoom - cw) / 2)\n const maxY = Math.max(0, (ch * t.zoom - ch) / 2)\n return {\n zoom: t.zoom,\n panX: Math.min(maxX, Math.max(-maxX, t.panX)),\n panY: Math.min(maxY, Math.max(-maxY, t.panY)),\n }\n}\n\n/**\n * Draws frames into a canvas, keeping a backing-store sized to the element and\n * device pixel ratio. Each frame is contained (whole frame visible, centered),\n * then the view transform (zoom + pan) is applied.\n */\nexport class Renderer {\n readonly canvas: HTMLCanvasElement\n private readonly ctx: CanvasRenderingContext2D\n private cssW = 0\n private cssH = 0\n\n constructor(canvas: HTMLCanvasElement) {\n this.canvas = canvas\n const ctx = canvas.getContext('2d')\n if (!ctx) throw new Error('spindle: 2d canvas context unavailable')\n this.ctx = ctx\n }\n\n /** Resize the backing store to the element's box. Returns true if it changed. */\n resize(): boolean {\n const dpr = window.devicePixelRatio || 1\n const w = this.canvas.clientWidth\n const h = this.canvas.clientHeight\n if (w === this.cssW && h === this.cssH && this.canvas.width === Math.round(w * dpr)) {\n return false\n }\n this.cssW = w\n this.cssH = h\n this.canvas.width = Math.round(w * dpr)\n this.canvas.height = Math.round(h * dpr)\n this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return true\n }\n\n clear(): void {\n this.ctx.clearRect(0, 0, this.cssW, this.cssH)\n }\n\n draw(region: FrameRegion, view: ViewTransform): void {\n const { ctx } = this\n const cw = this.cssW\n const ch = this.cssH\n ctx.clearRect(0, 0, cw, ch)\n if (region.sw === 0 || region.sh === 0) return\n\n // Contain fit.\n const scale = Math.min(cw / region.sw, ch / region.sh) * view.zoom\n const dw = region.sw * scale\n const dh = region.sh * scale\n const dx = (cw - dw) / 2 + view.panX\n const dy = (ch - dh) / 2 + view.panY\n ctx.drawImage(region.img, region.sx, region.sy, region.sw, region.sh, dx, dy, dw, dh)\n }\n}\n","const FRAME_MS = 16\n\n/**\n * Exponentially decay a velocity by `friction` per ~16ms frame, scaled to the\n * real elapsed time so the fling feels the same regardless of refresh rate.\n */\nexport function decayVelocity(v: number, friction: number, dtMs: number): number {\n return v * Math.pow(friction, dtMs / FRAME_MS)\n}\n\n/** True once the absolute speed has dropped below the resting threshold. */\nexport function isResting(v: number, threshold: number): boolean {\n return Math.abs(v) < threshold\n}\n","import { resolveOptions } from './options'\nimport { wrapIndex } from './frames'\nimport { FrameStore } from './loader'\nimport { Renderer, IDENTITY, clampPan, type ViewTransform } from './renderer'\nimport { decayVelocity, isResting } from './momentum'\nimport type { SpindleOptions, ResolvedOptions } from './types'\n\nconst FRICTION = 0.94\nconst REST_SPEED = 0.0002 // frames per ms\nconst DBLTAP_MS = 300\n\nfunction resolveElement(target: string | HTMLElement): HTMLElement {\n const el = typeof target === 'string' ? document.querySelector(target) : target\n if (!el) throw new Error(`spindle: element not found for ${String(target)}`)\n return el as HTMLElement\n}\n\ninterface Pointer {\n x: number\n y: number\n}\n\nexport class Spindle {\n readonly el: HTMLElement\n private readonly opts: ResolvedOptions\n private readonly store: FrameStore\n private readonly renderer: Renderer\n private readonly root: HTMLElement\n private fsBtn?: HTMLButtonElement\n\n /** Current frame as a float; the rendered frame is the rounded, wrapped value. */\n private frameF = 0\n private view: ViewTransform = { ...IDENTITY }\n private ready = false\n\n // Interaction state.\n private readonly pointers = new Map<number, Pointer>()\n private lastX = 0\n private lastY = 0\n private lastMoveTs = 0\n private velocity = 0 // frames per ms, signed\n private pinchDist = 0\n private lastTapTs = 0\n\n // Loop bookkeeping.\n private raf = 0\n private lastTick = 0\n private autoplaying = false\n private momentumActive = false\n private destroyed = false\n\n constructor(target: string | HTMLElement, options: SpindleOptions) {\n this.el = resolveElement(target)\n this.opts = resolveOptions(options)\n this.store = new FrameStore(this.opts.source)\n\n this.root = document.createElement('div')\n this.root.className = 'spindle'\n Object.assign(this.root.style, {\n position: 'relative',\n width: '100%',\n height: '100%',\n touchAction: 'none',\n overflow: 'hidden',\n cursor: 'grab',\n })\n\n const canvas = document.createElement('canvas')\n Object.assign(canvas.style, { display: 'block', width: '100%', height: '100%' })\n this.root.appendChild(canvas)\n this.el.appendChild(this.root)\n this.renderer = new Renderer(canvas)\n\n if (this.opts.fullscreen) this.mountFullscreenButton()\n this.bindEvents()\n\n this.store\n .load(\n () => this.onFirstFrame(),\n (p) => this.opts.onProgress(p),\n () => this.opts.onReady(),\n )\n .catch((err) => console.error(err))\n }\n\n // ---- public API ---------------------------------------------------------\n\n /** Jump to a frame index (wrapped when loop is on, else clamped). */\n goto(index: number): void {\n this.frameF = this.opts.loop\n ? wrapIndex(index, this.store.count)\n : Math.min(this.store.count - 1, Math.max(0, index))\n this.render()\n }\n\n get frame(): number {\n return wrapIndex(Math.round(this.frameF), this.store.count)\n }\n\n get length(): number {\n return this.store.count\n }\n\n /** Start autoplay (cancels on the next pointer grab). */\n play(): void {\n if (this.autoplaying) return\n this.momentumActive = false\n this.autoplaying = true\n this.ensureLoop()\n }\n\n /** Stop autoplay and momentum. */\n stop(): void {\n this.autoplaying = false\n this.momentumActive = false\n }\n\n /** Toggle browser fullscreen on the viewer. */\n fullscreen(): void {\n if (document.fullscreenElement) document.exitFullscreen()\n else this.root.requestFullscreen?.()\n }\n\n /** Reset zoom and pan to the fit view. */\n resetZoom(): void {\n this.view = { ...IDENTITY }\n this.render()\n }\n\n destroy(): void {\n this.destroyed = true\n cancelAnimationFrame(this.raf)\n window.removeEventListener('resize', this.onResize)\n this.root.remove()\n this.pointers.clear()\n }\n\n // ---- loading ------------------------------------------------------------\n\n private onFirstFrame(): void {\n this.ready = true\n this.renderer.resize()\n this.render()\n if (this.opts.autoplay) this.play()\n }\n\n // ---- events -------------------------------------------------------------\n\n private bindEvents(): void {\n const r = this.root\n r.addEventListener('pointerdown', this.onPointerDown)\n r.addEventListener('pointermove', this.onPointerMove)\n r.addEventListener('pointerup', this.onPointerUp)\n r.addEventListener('pointercancel', this.onPointerUp)\n if (this.opts.zoom) r.addEventListener('wheel', this.onWheel, { passive: false })\n window.addEventListener('resize', this.onResize)\n }\n\n private onResize = (): void => {\n if (this.renderer.resize()) this.render()\n }\n\n private onPointerDown = (e: PointerEvent): void => {\n this.root.setPointerCapture(e.pointerId)\n this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })\n // Any grab cancels autoplay and momentum.\n this.autoplaying = false\n this.momentumActive = false\n this.velocity = 0\n this.lastX = e.clientX\n this.lastY = e.clientY\n this.lastMoveTs = e.timeStamp\n this.root.style.cursor = 'grabbing'\n\n if (this.pointers.size === 2) this.pinchDist = this.pointerSpread()\n\n // Double-tap to toggle zoom.\n if (this.opts.zoom && e.timeStamp - this.lastTapTs < DBLTAP_MS) {\n this.toggleZoom(e)\n this.lastTapTs = 0\n } else {\n this.lastTapTs = e.timeStamp\n }\n }\n\n private onPointerMove = (e: PointerEvent): void => {\n const p = this.pointers.get(e.pointerId)\n if (!p) return\n p.x = e.clientX\n p.y = e.clientY\n\n if (this.pointers.size >= 2) {\n this.handlePinch()\n return\n }\n\n const dx = e.clientX - this.lastX\n const dy = e.clientY - this.lastY\n\n if (this.view.zoom > 1) {\n // Pan within the zoomed frame.\n this.view = clampPan(\n { zoom: this.view.zoom, panX: this.view.panX + dx, panY: this.view.panY + dy },\n this.root.clientWidth,\n this.root.clientHeight,\n )\n } else {\n // Spin. Dragging right advances the orbit forward.\n const dFrame = dx / this.opts.pxPerFrame\n this.frameF += dFrame\n const dt = Math.max(1, e.timeStamp - this.lastMoveTs)\n this.velocity = dFrame / dt\n }\n\n this.lastX = e.clientX\n this.lastY = e.clientY\n this.lastMoveTs = e.timeStamp\n this.render()\n }\n\n private onPointerUp = (e: PointerEvent): void => {\n this.pointers.delete(e.pointerId)\n if (this.pointers.size < 2) this.pinchDist = 0\n if (this.pointers.size > 0) return\n\n this.root.style.cursor = 'grab'\n if (this.opts.momentum && this.view.zoom <= 1 && !isResting(this.velocity, REST_SPEED)) {\n this.momentumActive = true\n this.ensureLoop()\n }\n }\n\n private onWheel = (e: WheelEvent): void => {\n e.preventDefault()\n const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15\n this.applyZoom(this.view.zoom * factor, e)\n }\n\n // ---- zoom helpers -------------------------------------------------------\n\n private pointerSpread(): number {\n const pts = [...this.pointers.values()]\n if (pts.length < 2) return 0\n return Math.hypot(pts[0]!.x - pts[1]!.x, pts[0]!.y - pts[1]!.y)\n }\n\n private handlePinch(): void {\n const dist = this.pointerSpread()\n if (this.pinchDist > 0 && dist > 0) {\n this.applyZoom(this.view.zoom * (dist / this.pinchDist))\n }\n this.pinchDist = dist\n }\n\n private toggleZoom(e: PointerEvent): void {\n if (this.view.zoom > 1) this.resetZoom()\n else this.applyZoom(2, e)\n }\n\n private applyZoom(target: number, at?: { clientX: number; clientY: number }): void {\n const zoom = Math.min(this.opts.maxZoom, Math.max(1, target))\n let panX = this.view.panX\n let panY = this.view.panY\n if (at && zoom !== this.view.zoom) {\n // Keep the cursor point stable as we scale.\n const rect = this.root.getBoundingClientRect()\n const cx = at.clientX - rect.left - rect.width / 2\n const cy = at.clientY - rect.top - rect.height / 2\n const ratio = zoom / this.view.zoom\n panX = (panX - cx) * ratio + cx\n panY = (panY - cy) * ratio + cy\n }\n if (zoom === 1) {\n panX = 0\n panY = 0\n }\n this.view = clampPan({ zoom, panX, panY }, this.root.clientWidth, this.root.clientHeight)\n this.render()\n }\n\n private mountFullscreenButton(): void {\n const btn = document.createElement('button')\n btn.type = 'button'\n btn.className = 'spindle-fs'\n btn.setAttribute('aria-label', 'Toggle fullscreen')\n btn.textContent = '⛶'\n Object.assign(btn.style, {\n position: 'absolute',\n right: '8px',\n bottom: '8px',\n width: '32px',\n height: '32px',\n border: 'none',\n borderRadius: '4px',\n background: 'rgba(0,0,0,0.5)',\n color: '#fff',\n font: '16px/1 sans-serif',\n cursor: 'pointer',\n })\n btn.addEventListener('click', () => this.fullscreen())\n this.root.appendChild(btn)\n this.fsBtn = btn\n }\n\n // ---- animation loop -----------------------------------------------------\n\n private ensureLoop(): void {\n if (this.raf) return\n this.lastTick = 0\n this.raf = requestAnimationFrame(this.tick)\n }\n\n private tick = (ts: number): void => {\n if (this.destroyed) return\n const dt = this.lastTick ? ts - this.lastTick : 16\n this.lastTick = ts\n\n if (this.autoplaying) {\n this.frameF += (this.opts.autoplayFps / 1000) * dt\n this.render()\n } else if (this.momentumActive) {\n this.frameF += this.velocity * dt\n this.velocity = decayVelocity(this.velocity, FRICTION, dt)\n this.render()\n if (isResting(this.velocity, REST_SPEED)) this.momentumActive = false\n }\n\n if (this.autoplaying || this.momentumActive) {\n this.raf = requestAnimationFrame(this.tick)\n } else {\n this.raf = 0\n }\n }\n\n // ---- render -------------------------------------------------------------\n\n private render(): void {\n if (!this.ready) return\n let idx = this.frame\n let region = this.store.get(idx)\n // During progressive load a frame may be undecoded; fall back to frame 0.\n if (!region) region = this.store.get(0)\n if (region) this.renderer.draw(region, this.view)\n\n if (this.fsBtn) this.fsBtn.textContent = document.fullscreenElement ? '⤢' : '⛶'\n\n if (!this.opts.loop) {\n // Clamp the float so it can't drift outside the range when looping is off.\n this.frameF = Math.min(this.store.count - 1, Math.max(0, this.frameF))\n }\n }\n}\n"],"names":["noop","resolveOptions","opts","wrapIndex","i","n","isSheet","s","loadImage","url","resolve","reject","img","FrameStore","source","src","cols","col","row","onFirst","onProgress","onReady","done","report","k","IDENTITY","clampPan","t","cw","ch","maxX","maxY","Renderer","canvas","ctx","dpr","w","h","region","view","scale","dw","dh","dx","dy","FRAME_MS","decayVelocity","v","friction","dtMs","isResting","threshold","FRICTION","REST_SPEED","DBLTAP_MS","resolveElement","target","el","Spindle","options","e","p","dFrame","dt","factor","ts","err","index","_a","_b","r","pts","dist","at","zoom","panX","panY","rect","cx","cy","ratio","btn","idx"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
(function(l,m){typeof exports=="object"&&typeof module<"u"?m(exports):typeof define=="function"&&define.amd?define(["exports"],m):(l=typeof globalThis<"u"?globalThis:l||self,m(l.Spindle={}))})(this,function(l){"use strict";const m=()=>{};function g(o){return{source:o.source,autoplay:o.autoplay??!1,loop:o.loop??!0,momentum:o.momentum??!0,zoom:o.zoom??!0,fullscreen:o.fullscreen??!0,pxPerFrame:o.pxPerFrame??8,autoplayFps:o.autoplayFps??12,maxZoom:o.maxZoom??4,onProgress:o.onProgress??m,onReady:o.onReady??m}}function d(o,t){return(o%t+t)%t}function u(o){return!Array.isArray(o)}function p(o){return new Promise((t,e)=>{const i=new Image;i.crossOrigin="anonymous",i.onload=()=>{i.decode?i.decode().then(()=>t(i),()=>t(i)):t(i)},i.onerror=()=>e(new Error(`spindle: failed to load ${o}`)),i.src=o})}class x{constructor(t){this.sheetImg=null,this.source=t,this.count=u(t)?t.frames:t.length,this.imgs=new Array(this.count).fill(null)}get(t){const e=this.source;if(u(e)){if(!this.sheetImg)return null;const s=e.cols??e.frames,n=t%s,r=Math.floor(t/s);return{img:this.sheetImg,sx:n*e.fw,sy:r*e.fh,sw:e.fw,sh:e.fh}}const i=this.imgs[t];return i?{img:i,sx:0,sy:0,sw:i.naturalWidth,sh:i.naturalHeight}:null}async load(t,e,i){const s=this.source;if(u(s)){this.sheetImg=await p(s.sheet),t(),e(1),i();return}let n=0;const r=()=>e(this.count===0?1:n/this.count);if(this.imgs[0]=await p(s[0]),n++,t(),r(),n===this.count)return i();await Promise.all(s.slice(1).map((a,h)=>p(a).then(c=>{this.imgs[h+1]=c,n++,r()}))),i()}}const f={zoom:1,panX:0,panY:0};function v(o,t,e){const i=Math.max(0,(t*o.zoom-t)/2),s=Math.max(0,(e*o.zoom-e)/2);return{zoom:o.zoom,panX:Math.min(i,Math.max(-i,o.panX)),panY:Math.min(s,Math.max(-s,o.panY))}}class M{constructor(t){this.cssW=0,this.cssH=0,this.canvas=t;const e=t.getContext("2d");if(!e)throw new Error("spindle: 2d canvas context unavailable");this.ctx=e}resize(){const t=window.devicePixelRatio||1,e=this.canvas.clientWidth,i=this.canvas.clientHeight;return e===this.cssW&&i===this.cssH&&this.canvas.width===Math.round(e*t)?!1:(this.cssW=e,this.cssH=i,this.canvas.width=Math.round(e*t),this.canvas.height=Math.round(i*t),this.ctx.setTransform(t,0,0,t,0,0),!0)}clear(){this.ctx.clearRect(0,0,this.cssW,this.cssH)}draw(t,e){const{ctx:i}=this,s=this.cssW,n=this.cssH;if(i.clearRect(0,0,s,n),t.sw===0||t.sh===0)return;const r=Math.min(s/t.sw,n/t.sh)*e.zoom,a=t.sw*r,h=t.sh*r,c=(s-a)/2+e.panX,S=(n-h)/2+e.panY;i.drawImage(t.img,t.sx,t.sy,t.sw,t.sh,c,S,a,h)}}const z=16;function F(o,t,e){return o*Math.pow(t,e/z)}function y(o,t){return Math.abs(o)<t}const T=.94,w=2e-4,b=300;function E(o){const t=typeof o=="string"?document.querySelector(o):o;if(!t)throw new Error(`spindle: element not found for ${String(o)}`);return t}class P{constructor(t,e){this.frameF=0,this.view={...f},this.ready=!1,this.pointers=new Map,this.lastX=0,this.lastY=0,this.lastMoveTs=0,this.velocity=0,this.pinchDist=0,this.lastTapTs=0,this.raf=0,this.lastTick=0,this.autoplaying=!1,this.momentumActive=!1,this.destroyed=!1,this.onResize=()=>{this.renderer.resize()&&this.render()},this.onPointerDown=s=>{this.root.setPointerCapture(s.pointerId),this.pointers.set(s.pointerId,{x:s.clientX,y:s.clientY}),this.autoplaying=!1,this.momentumActive=!1,this.velocity=0,this.lastX=s.clientX,this.lastY=s.clientY,this.lastMoveTs=s.timeStamp,this.root.style.cursor="grabbing",this.pointers.size===2&&(this.pinchDist=this.pointerSpread()),this.opts.zoom&&s.timeStamp-this.lastTapTs<b?(this.toggleZoom(s),this.lastTapTs=0):this.lastTapTs=s.timeStamp},this.onPointerMove=s=>{const n=this.pointers.get(s.pointerId);if(!n)return;if(n.x=s.clientX,n.y=s.clientY,this.pointers.size>=2){this.handlePinch();return}const r=s.clientX-this.lastX,a=s.clientY-this.lastY;if(this.view.zoom>1)this.view=v({zoom:this.view.zoom,panX:this.view.panX+r,panY:this.view.panY+a},this.root.clientWidth,this.root.clientHeight);else{const h=r/this.opts.pxPerFrame;this.frameF+=h;const c=Math.max(1,s.timeStamp-this.lastMoveTs);this.velocity=h/c}this.lastX=s.clientX,this.lastY=s.clientY,this.lastMoveTs=s.timeStamp,this.render()},this.onPointerUp=s=>{this.pointers.delete(s.pointerId),this.pointers.size<2&&(this.pinchDist=0),!(this.pointers.size>0)&&(this.root.style.cursor="grab",this.opts.momentum&&this.view.zoom<=1&&!y(this.velocity,w)&&(this.momentumActive=!0,this.ensureLoop()))},this.onWheel=s=>{s.preventDefault();const n=s.deltaY<0?1.15:1/1.15;this.applyZoom(this.view.zoom*n,s)},this.tick=s=>{if(this.destroyed)return;const n=this.lastTick?s-this.lastTick:16;this.lastTick=s,this.autoplaying?(this.frameF+=this.opts.autoplayFps/1e3*n,this.render()):this.momentumActive&&(this.frameF+=this.velocity*n,this.velocity=F(this.velocity,T,n),this.render(),y(this.velocity,w)&&(this.momentumActive=!1)),this.autoplaying||this.momentumActive?this.raf=requestAnimationFrame(this.tick):this.raf=0},this.el=E(t),this.opts=g(e),this.store=new x(this.opts.source),this.root=document.createElement("div"),this.root.className="spindle",Object.assign(this.root.style,{position:"relative",width:"100%",height:"100%",touchAction:"none",overflow:"hidden",cursor:"grab"});const i=document.createElement("canvas");Object.assign(i.style,{display:"block",width:"100%",height:"100%"}),this.root.appendChild(i),this.el.appendChild(this.root),this.renderer=new M(i),this.opts.fullscreen&&this.mountFullscreenButton(),this.bindEvents(),this.store.load(()=>this.onFirstFrame(),s=>this.opts.onProgress(s),()=>this.opts.onReady()).catch(s=>console.error(s))}goto(t){this.frameF=this.opts.loop?d(t,this.store.count):Math.min(this.store.count-1,Math.max(0,t)),this.render()}get frame(){return d(Math.round(this.frameF),this.store.count)}get length(){return this.store.count}play(){this.autoplaying||(this.momentumActive=!1,this.autoplaying=!0,this.ensureLoop())}stop(){this.autoplaying=!1,this.momentumActive=!1}fullscreen(){var t,e;document.fullscreenElement?document.exitFullscreen():(e=(t=this.root).requestFullscreen)==null||e.call(t)}resetZoom(){this.view={...f},this.render()}destroy(){this.destroyed=!0,cancelAnimationFrame(this.raf),window.removeEventListener("resize",this.onResize),this.root.remove(),this.pointers.clear()}onFirstFrame(){this.ready=!0,this.renderer.resize(),this.render(),this.opts.autoplay&&this.play()}bindEvents(){const t=this.root;t.addEventListener("pointerdown",this.onPointerDown),t.addEventListener("pointermove",this.onPointerMove),t.addEventListener("pointerup",this.onPointerUp),t.addEventListener("pointercancel",this.onPointerUp),this.opts.zoom&&t.addEventListener("wheel",this.onWheel,{passive:!1}),window.addEventListener("resize",this.onResize)}pointerSpread(){const t=[...this.pointers.values()];return t.length<2?0:Math.hypot(t[0].x-t[1].x,t[0].y-t[1].y)}handlePinch(){const t=this.pointerSpread();this.pinchDist>0&&t>0&&this.applyZoom(this.view.zoom*(t/this.pinchDist)),this.pinchDist=t}toggleZoom(t){this.view.zoom>1?this.resetZoom():this.applyZoom(2,t)}applyZoom(t,e){const i=Math.min(this.opts.maxZoom,Math.max(1,t));let s=this.view.panX,n=this.view.panY;if(e&&i!==this.view.zoom){const r=this.root.getBoundingClientRect(),a=e.clientX-r.left-r.width/2,h=e.clientY-r.top-r.height/2,c=i/this.view.zoom;s=(s-a)*c+a,n=(n-h)*c+h}i===1&&(s=0,n=0),this.view=v({zoom:i,panX:s,panY:n},this.root.clientWidth,this.root.clientHeight),this.render()}mountFullscreenButton(){const t=document.createElement("button");t.type="button",t.className="spindle-fs",t.setAttribute("aria-label","Toggle fullscreen"),t.textContent="⛶",Object.assign(t.style,{position:"absolute",right:"8px",bottom:"8px",width:"32px",height:"32px",border:"none",borderRadius:"4px",background:"rgba(0,0,0,0.5)",color:"#fff",font:"16px/1 sans-serif",cursor:"pointer"}),t.addEventListener("click",()=>this.fullscreen()),this.root.appendChild(t),this.fsBtn=t}ensureLoop(){this.raf||(this.lastTick=0,this.raf=requestAnimationFrame(this.tick))}render(){if(!this.ready)return;let t=this.frame,e=this.store.get(t);e||(e=this.store.get(0)),e&&this.renderer.draw(e,this.view),this.fsBtn&&(this.fsBtn.textContent=document.fullscreenElement?"⤢":"⛶"),this.opts.loop||(this.frameF=Math.min(this.store.count-1,Math.max(0,this.frameF)))}}l.Spindle=P,Object.defineProperty(l,Symbol.toStringTag,{value:"Module"})});
|
|
2
|
+
//# sourceMappingURL=spindle.umd.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"spindle.umd.cjs","sources":["../src/options.ts","../src/frames.ts","../src/loader.ts","../src/renderer.ts","../src/momentum.ts","../src/Spindle.ts"],"sourcesContent":["import type { SpindleOptions, ResolvedOptions } from './types'\n\nconst noop = (): void => {}\n\n/** Merge caller options over defaults, leaving `source` by reference. */\nexport function resolveOptions(opts: SpindleOptions): ResolvedOptions {\n return {\n source: opts.source,\n autoplay: opts.autoplay ?? false,\n loop: opts.loop ?? true,\n momentum: opts.momentum ?? true,\n zoom: opts.zoom ?? true,\n fullscreen: opts.fullscreen ?? true,\n pxPerFrame: opts.pxPerFrame ?? 8,\n autoplayFps: opts.autoplayFps ?? 12,\n maxZoom: opts.maxZoom ?? 4,\n onProgress: opts.onProgress ?? noop,\n onReady: opts.onReady ?? noop,\n }\n}\n","/** Wrap a frame index into [0, n) looping both directions. */\nexport function wrapIndex(i: number, n: number): number {\n return ((i % n) + n) % n\n}\n\n/** Whole frames covered by a pixel drag distance, truncated toward zero. */\nexport function frameDelta(dx: number, pxPerFrame: number): number {\n return Math.trunc(dx / pxPerFrame)\n}\n","import type { Source, SheetSource } from './types'\n\n/** A drawable region: an image plus the source rect to blit from it. */\nexport interface FrameRegion {\n img: CanvasImageSource\n sx: number\n sy: number\n sw: number\n sh: number\n}\n\nfunction isSheet(s: Source): s is SheetSource {\n return !Array.isArray(s)\n}\n\nfunction loadImage(url: string): Promise<HTMLImageElement> {\n return new Promise((resolve, reject) => {\n const img = new Image()\n img.crossOrigin = 'anonymous'\n img.onload = () => {\n // Decode off the main paint when supported; ignore failures.\n if (img.decode) img.decode().then(() => resolve(img), () => resolve(img))\n else resolve(img)\n }\n img.onerror = () => reject(new Error(`spindle: failed to load ${url}`))\n img.src = url\n })\n}\n\n/**\n * Owns frame pixels for one source. Loads progressively: the first frame is\n * decoded up front so the viewer can paint and become interactive, then the\n * remainder stream in. `get` returns null for a frame not yet decoded.\n */\nexport class FrameStore {\n readonly count: number\n private readonly source: Source\n private readonly imgs: (HTMLImageElement | null)[]\n private sheetImg: HTMLImageElement | null = null\n\n constructor(source: Source) {\n this.source = source\n this.count = isSheet(source) ? source.frames : source.length\n this.imgs = new Array(this.count).fill(null)\n }\n\n get(i: number): FrameRegion | null {\n const src = this.source\n if (isSheet(src)) {\n if (!this.sheetImg) return null\n const cols = src.cols ?? src.frames\n const col = i % cols\n const row = Math.floor(i / cols)\n return { img: this.sheetImg, sx: col * src.fw, sy: row * src.fh, sw: src.fw, sh: src.fh }\n }\n const img = this.imgs[i]\n if (!img) return null\n return { img, sx: 0, sy: 0, sw: img.naturalWidth, sh: img.naturalHeight }\n }\n\n /**\n * Begin loading. Resolves `onFirst` once frame 0 is ready, then loads the\n * rest, calling `onProgress(0..1)` after each and `onReady` when all decode.\n */\n async load(\n onFirst: () => void,\n onProgress: (p: number) => void,\n onReady: () => void,\n ): Promise<void> {\n const src = this.source\n if (isSheet(src)) {\n this.sheetImg = await loadImage(src.sheet)\n onFirst()\n onProgress(1)\n onReady()\n return\n }\n\n let done = 0\n const report = () => onProgress(this.count === 0 ? 1 : done / this.count)\n\n // Frame 0 first so we can paint and unlock interaction immediately.\n this.imgs[0] = await loadImage(src[0]!)\n done++\n onFirst()\n report()\n if (done === this.count) return onReady()\n\n await Promise.all(\n src.slice(1).map((url, k) =>\n loadImage(url).then((img) => {\n this.imgs[k + 1] = img\n done++\n report()\n }),\n ),\n )\n onReady()\n }\n}\n","import type { FrameRegion } from './loader'\n\n/** View transform applied on top of the base contain-fit. */\nexport interface ViewTransform {\n /** Zoom factor, 1 = fit. */\n zoom: number\n /** Pan offset in CSS pixels, applied after zoom. */\n panX: number\n panY: number\n}\n\nexport const IDENTITY: ViewTransform = { zoom: 1, panX: 0, panY: 0 }\n\n/** Clamp a pan offset so the zoomed frame can't be dragged off the canvas. */\nexport function clampPan(t: ViewTransform, cw: number, ch: number): ViewTransform {\n const maxX = Math.max(0, (cw * t.zoom - cw) / 2)\n const maxY = Math.max(0, (ch * t.zoom - ch) / 2)\n return {\n zoom: t.zoom,\n panX: Math.min(maxX, Math.max(-maxX, t.panX)),\n panY: Math.min(maxY, Math.max(-maxY, t.panY)),\n }\n}\n\n/**\n * Draws frames into a canvas, keeping a backing-store sized to the element and\n * device pixel ratio. Each frame is contained (whole frame visible, centered),\n * then the view transform (zoom + pan) is applied.\n */\nexport class Renderer {\n readonly canvas: HTMLCanvasElement\n private readonly ctx: CanvasRenderingContext2D\n private cssW = 0\n private cssH = 0\n\n constructor(canvas: HTMLCanvasElement) {\n this.canvas = canvas\n const ctx = canvas.getContext('2d')\n if (!ctx) throw new Error('spindle: 2d canvas context unavailable')\n this.ctx = ctx\n }\n\n /** Resize the backing store to the element's box. Returns true if it changed. */\n resize(): boolean {\n const dpr = window.devicePixelRatio || 1\n const w = this.canvas.clientWidth\n const h = this.canvas.clientHeight\n if (w === this.cssW && h === this.cssH && this.canvas.width === Math.round(w * dpr)) {\n return false\n }\n this.cssW = w\n this.cssH = h\n this.canvas.width = Math.round(w * dpr)\n this.canvas.height = Math.round(h * dpr)\n this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n return true\n }\n\n clear(): void {\n this.ctx.clearRect(0, 0, this.cssW, this.cssH)\n }\n\n draw(region: FrameRegion, view: ViewTransform): void {\n const { ctx } = this\n const cw = this.cssW\n const ch = this.cssH\n ctx.clearRect(0, 0, cw, ch)\n if (region.sw === 0 || region.sh === 0) return\n\n // Contain fit.\n const scale = Math.min(cw / region.sw, ch / region.sh) * view.zoom\n const dw = region.sw * scale\n const dh = region.sh * scale\n const dx = (cw - dw) / 2 + view.panX\n const dy = (ch - dh) / 2 + view.panY\n ctx.drawImage(region.img, region.sx, region.sy, region.sw, region.sh, dx, dy, dw, dh)\n }\n}\n","const FRAME_MS = 16\n\n/**\n * Exponentially decay a velocity by `friction` per ~16ms frame, scaled to the\n * real elapsed time so the fling feels the same regardless of refresh rate.\n */\nexport function decayVelocity(v: number, friction: number, dtMs: number): number {\n return v * Math.pow(friction, dtMs / FRAME_MS)\n}\n\n/** True once the absolute speed has dropped below the resting threshold. */\nexport function isResting(v: number, threshold: number): boolean {\n return Math.abs(v) < threshold\n}\n","import { resolveOptions } from './options'\nimport { wrapIndex } from './frames'\nimport { FrameStore } from './loader'\nimport { Renderer, IDENTITY, clampPan, type ViewTransform } from './renderer'\nimport { decayVelocity, isResting } from './momentum'\nimport type { SpindleOptions, ResolvedOptions } from './types'\n\nconst FRICTION = 0.94\nconst REST_SPEED = 0.0002 // frames per ms\nconst DBLTAP_MS = 300\n\nfunction resolveElement(target: string | HTMLElement): HTMLElement {\n const el = typeof target === 'string' ? document.querySelector(target) : target\n if (!el) throw new Error(`spindle: element not found for ${String(target)}`)\n return el as HTMLElement\n}\n\ninterface Pointer {\n x: number\n y: number\n}\n\nexport class Spindle {\n readonly el: HTMLElement\n private readonly opts: ResolvedOptions\n private readonly store: FrameStore\n private readonly renderer: Renderer\n private readonly root: HTMLElement\n private fsBtn?: HTMLButtonElement\n\n /** Current frame as a float; the rendered frame is the rounded, wrapped value. */\n private frameF = 0\n private view: ViewTransform = { ...IDENTITY }\n private ready = false\n\n // Interaction state.\n private readonly pointers = new Map<number, Pointer>()\n private lastX = 0\n private lastY = 0\n private lastMoveTs = 0\n private velocity = 0 // frames per ms, signed\n private pinchDist = 0\n private lastTapTs = 0\n\n // Loop bookkeeping.\n private raf = 0\n private lastTick = 0\n private autoplaying = false\n private momentumActive = false\n private destroyed = false\n\n constructor(target: string | HTMLElement, options: SpindleOptions) {\n this.el = resolveElement(target)\n this.opts = resolveOptions(options)\n this.store = new FrameStore(this.opts.source)\n\n this.root = document.createElement('div')\n this.root.className = 'spindle'\n Object.assign(this.root.style, {\n position: 'relative',\n width: '100%',\n height: '100%',\n touchAction: 'none',\n overflow: 'hidden',\n cursor: 'grab',\n })\n\n const canvas = document.createElement('canvas')\n Object.assign(canvas.style, { display: 'block', width: '100%', height: '100%' })\n this.root.appendChild(canvas)\n this.el.appendChild(this.root)\n this.renderer = new Renderer(canvas)\n\n if (this.opts.fullscreen) this.mountFullscreenButton()\n this.bindEvents()\n\n this.store\n .load(\n () => this.onFirstFrame(),\n (p) => this.opts.onProgress(p),\n () => this.opts.onReady(),\n )\n .catch((err) => console.error(err))\n }\n\n // ---- public API ---------------------------------------------------------\n\n /** Jump to a frame index (wrapped when loop is on, else clamped). */\n goto(index: number): void {\n this.frameF = this.opts.loop\n ? wrapIndex(index, this.store.count)\n : Math.min(this.store.count - 1, Math.max(0, index))\n this.render()\n }\n\n get frame(): number {\n return wrapIndex(Math.round(this.frameF), this.store.count)\n }\n\n get length(): number {\n return this.store.count\n }\n\n /** Start autoplay (cancels on the next pointer grab). */\n play(): void {\n if (this.autoplaying) return\n this.momentumActive = false\n this.autoplaying = true\n this.ensureLoop()\n }\n\n /** Stop autoplay and momentum. */\n stop(): void {\n this.autoplaying = false\n this.momentumActive = false\n }\n\n /** Toggle browser fullscreen on the viewer. */\n fullscreen(): void {\n if (document.fullscreenElement) document.exitFullscreen()\n else this.root.requestFullscreen?.()\n }\n\n /** Reset zoom and pan to the fit view. */\n resetZoom(): void {\n this.view = { ...IDENTITY }\n this.render()\n }\n\n destroy(): void {\n this.destroyed = true\n cancelAnimationFrame(this.raf)\n window.removeEventListener('resize', this.onResize)\n this.root.remove()\n this.pointers.clear()\n }\n\n // ---- loading ------------------------------------------------------------\n\n private onFirstFrame(): void {\n this.ready = true\n this.renderer.resize()\n this.render()\n if (this.opts.autoplay) this.play()\n }\n\n // ---- events -------------------------------------------------------------\n\n private bindEvents(): void {\n const r = this.root\n r.addEventListener('pointerdown', this.onPointerDown)\n r.addEventListener('pointermove', this.onPointerMove)\n r.addEventListener('pointerup', this.onPointerUp)\n r.addEventListener('pointercancel', this.onPointerUp)\n if (this.opts.zoom) r.addEventListener('wheel', this.onWheel, { passive: false })\n window.addEventListener('resize', this.onResize)\n }\n\n private onResize = (): void => {\n if (this.renderer.resize()) this.render()\n }\n\n private onPointerDown = (e: PointerEvent): void => {\n this.root.setPointerCapture(e.pointerId)\n this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })\n // Any grab cancels autoplay and momentum.\n this.autoplaying = false\n this.momentumActive = false\n this.velocity = 0\n this.lastX = e.clientX\n this.lastY = e.clientY\n this.lastMoveTs = e.timeStamp\n this.root.style.cursor = 'grabbing'\n\n if (this.pointers.size === 2) this.pinchDist = this.pointerSpread()\n\n // Double-tap to toggle zoom.\n if (this.opts.zoom && e.timeStamp - this.lastTapTs < DBLTAP_MS) {\n this.toggleZoom(e)\n this.lastTapTs = 0\n } else {\n this.lastTapTs = e.timeStamp\n }\n }\n\n private onPointerMove = (e: PointerEvent): void => {\n const p = this.pointers.get(e.pointerId)\n if (!p) return\n p.x = e.clientX\n p.y = e.clientY\n\n if (this.pointers.size >= 2) {\n this.handlePinch()\n return\n }\n\n const dx = e.clientX - this.lastX\n const dy = e.clientY - this.lastY\n\n if (this.view.zoom > 1) {\n // Pan within the zoomed frame.\n this.view = clampPan(\n { zoom: this.view.zoom, panX: this.view.panX + dx, panY: this.view.panY + dy },\n this.root.clientWidth,\n this.root.clientHeight,\n )\n } else {\n // Spin. Dragging right advances the orbit forward.\n const dFrame = dx / this.opts.pxPerFrame\n this.frameF += dFrame\n const dt = Math.max(1, e.timeStamp - this.lastMoveTs)\n this.velocity = dFrame / dt\n }\n\n this.lastX = e.clientX\n this.lastY = e.clientY\n this.lastMoveTs = e.timeStamp\n this.render()\n }\n\n private onPointerUp = (e: PointerEvent): void => {\n this.pointers.delete(e.pointerId)\n if (this.pointers.size < 2) this.pinchDist = 0\n if (this.pointers.size > 0) return\n\n this.root.style.cursor = 'grab'\n if (this.opts.momentum && this.view.zoom <= 1 && !isResting(this.velocity, REST_SPEED)) {\n this.momentumActive = true\n this.ensureLoop()\n }\n }\n\n private onWheel = (e: WheelEvent): void => {\n e.preventDefault()\n const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15\n this.applyZoom(this.view.zoom * factor, e)\n }\n\n // ---- zoom helpers -------------------------------------------------------\n\n private pointerSpread(): number {\n const pts = [...this.pointers.values()]\n if (pts.length < 2) return 0\n return Math.hypot(pts[0]!.x - pts[1]!.x, pts[0]!.y - pts[1]!.y)\n }\n\n private handlePinch(): void {\n const dist = this.pointerSpread()\n if (this.pinchDist > 0 && dist > 0) {\n this.applyZoom(this.view.zoom * (dist / this.pinchDist))\n }\n this.pinchDist = dist\n }\n\n private toggleZoom(e: PointerEvent): void {\n if (this.view.zoom > 1) this.resetZoom()\n else this.applyZoom(2, e)\n }\n\n private applyZoom(target: number, at?: { clientX: number; clientY: number }): void {\n const zoom = Math.min(this.opts.maxZoom, Math.max(1, target))\n let panX = this.view.panX\n let panY = this.view.panY\n if (at && zoom !== this.view.zoom) {\n // Keep the cursor point stable as we scale.\n const rect = this.root.getBoundingClientRect()\n const cx = at.clientX - rect.left - rect.width / 2\n const cy = at.clientY - rect.top - rect.height / 2\n const ratio = zoom / this.view.zoom\n panX = (panX - cx) * ratio + cx\n panY = (panY - cy) * ratio + cy\n }\n if (zoom === 1) {\n panX = 0\n panY = 0\n }\n this.view = clampPan({ zoom, panX, panY }, this.root.clientWidth, this.root.clientHeight)\n this.render()\n }\n\n private mountFullscreenButton(): void {\n const btn = document.createElement('button')\n btn.type = 'button'\n btn.className = 'spindle-fs'\n btn.setAttribute('aria-label', 'Toggle fullscreen')\n btn.textContent = '⛶'\n Object.assign(btn.style, {\n position: 'absolute',\n right: '8px',\n bottom: '8px',\n width: '32px',\n height: '32px',\n border: 'none',\n borderRadius: '4px',\n background: 'rgba(0,0,0,0.5)',\n color: '#fff',\n font: '16px/1 sans-serif',\n cursor: 'pointer',\n })\n btn.addEventListener('click', () => this.fullscreen())\n this.root.appendChild(btn)\n this.fsBtn = btn\n }\n\n // ---- animation loop -----------------------------------------------------\n\n private ensureLoop(): void {\n if (this.raf) return\n this.lastTick = 0\n this.raf = requestAnimationFrame(this.tick)\n }\n\n private tick = (ts: number): void => {\n if (this.destroyed) return\n const dt = this.lastTick ? ts - this.lastTick : 16\n this.lastTick = ts\n\n if (this.autoplaying) {\n this.frameF += (this.opts.autoplayFps / 1000) * dt\n this.render()\n } else if (this.momentumActive) {\n this.frameF += this.velocity * dt\n this.velocity = decayVelocity(this.velocity, FRICTION, dt)\n this.render()\n if (isResting(this.velocity, REST_SPEED)) this.momentumActive = false\n }\n\n if (this.autoplaying || this.momentumActive) {\n this.raf = requestAnimationFrame(this.tick)\n } else {\n this.raf = 0\n }\n }\n\n // ---- render -------------------------------------------------------------\n\n private render(): void {\n if (!this.ready) return\n let idx = this.frame\n let region = this.store.get(idx)\n // During progressive load a frame may be undecoded; fall back to frame 0.\n if (!region) region = this.store.get(0)\n if (region) this.renderer.draw(region, this.view)\n\n if (this.fsBtn) this.fsBtn.textContent = document.fullscreenElement ? '⤢' : '⛶'\n\n if (!this.opts.loop) {\n // Clamp the float so it can't drift outside the range when looping is off.\n this.frameF = Math.min(this.store.count - 1, Math.max(0, this.frameF))\n }\n }\n}\n"],"names":["noop","resolveOptions","opts","wrapIndex","i","n","isSheet","s","loadImage","url","resolve","reject","img","FrameStore","source","src","cols","col","row","onFirst","onProgress","onReady","done","report","k","IDENTITY","clampPan","t","cw","ch","maxX","maxY","Renderer","canvas","ctx","dpr","w","h","region","view","scale","dw","dh","dx","dy","FRAME_MS","decayVelocity","v","friction","dtMs","isResting","threshold","FRICTION","REST_SPEED","DBLTAP_MS","resolveElement","target","el","Spindle","options","e","p","dFrame","dt","factor","ts","err","index","_b","_a","r","pts","dist","at","zoom","panX","panY","rect","cx","cy","ratio","btn","idx"],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** A sprite-sheet frame source: one image holding `frames` cells of `fw`×`fh`. */
|
|
2
|
+
export interface SheetSource {
|
|
3
|
+
sheet: string;
|
|
4
|
+
frames: number;
|
|
5
|
+
/** Frame cell width in px. */
|
|
6
|
+
fw: number;
|
|
7
|
+
/** Frame cell height in px. */
|
|
8
|
+
fh: number;
|
|
9
|
+
/** Cells per row in the sheet. Defaults to all frames in a single row. */
|
|
10
|
+
cols?: number;
|
|
11
|
+
}
|
|
12
|
+
/** Either a list of per-frame image URLs or a single sprite sheet. */
|
|
13
|
+
export type Source = string[] | SheetSource;
|
|
14
|
+
export interface SpindleOptions {
|
|
15
|
+
/** Frame URLs or a sprite-sheet descriptor. Required. */
|
|
16
|
+
source: Source;
|
|
17
|
+
/** Spin on load until the user grabs. Default false. */
|
|
18
|
+
autoplay?: boolean;
|
|
19
|
+
/** Wrap around at the ends. Default true. */
|
|
20
|
+
loop?: boolean;
|
|
21
|
+
/** Fling with inertia after release. Default true. */
|
|
22
|
+
momentum?: boolean;
|
|
23
|
+
/** Allow pinch / double-tap / scroll zoom + pan. Default true. */
|
|
24
|
+
zoom?: boolean;
|
|
25
|
+
/** Show a fullscreen toggle and honour the Fullscreen API. Default true. */
|
|
26
|
+
fullscreen?: boolean;
|
|
27
|
+
/** Pixels of drag per single frame step. Default 8. */
|
|
28
|
+
pxPerFrame?: number;
|
|
29
|
+
/** Frames advanced per second during autoplay. Default 12. */
|
|
30
|
+
autoplayFps?: number;
|
|
31
|
+
/** Max zoom factor. Default 4. */
|
|
32
|
+
maxZoom?: number;
|
|
33
|
+
/** Fired as frames decode, 0..1. */
|
|
34
|
+
onProgress?: (p: number) => void;
|
|
35
|
+
/** Fired once the first frame is painted and the viewer is interactive. */
|
|
36
|
+
onReady?: () => void;
|
|
37
|
+
}
|
|
38
|
+
/** Options with all optional fields resolved to concrete values. */
|
|
39
|
+
export type ResolvedOptions = Required<Omit<SpindleOptions, 'onProgress' | 'onReady'>> & {
|
|
40
|
+
onProgress: (p: number) => void;
|
|
41
|
+
onReady: () => void;
|
|
42
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tegos/spindle",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "360° frame-sequence spinner (object + aerial orbit). Vanilla TypeScript, zero deps.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ivan Mykhavko",
|
|
8
|
+
"homepage": "https://tegos.github.io/spindle/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/tegos/spindle.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/tegos/spindle/issues",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"360",
|
|
19
|
+
"spinner",
|
|
20
|
+
"panorama",
|
|
21
|
+
"sprite",
|
|
22
|
+
"viewer",
|
|
23
|
+
"canvas",
|
|
24
|
+
"drag"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"main": "./dist/spindle.umd.cjs",
|
|
30
|
+
"module": "./dist/spindle.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"import": "./dist/spindle.js",
|
|
36
|
+
"require": "./dist/spindle.umd.cjs"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"dev": "vite examples",
|
|
41
|
+
"build": "vite build && tsc --emitDeclarationOnly",
|
|
42
|
+
"build:site": "vite build --config vite.site.config.ts && cp -r examples/frames site-dist/frames",
|
|
43
|
+
"preview": "vite preview",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"test:watch": "vitest",
|
|
46
|
+
"prepublishOnly": "npm run build"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"typescript": "^5.6.0",
|
|
50
|
+
"vite": "^5.4.0",
|
|
51
|
+
"vitest": "^2.1.0"
|
|
52
|
+
}
|
|
53
|
+
}
|