@linear_non/stellar-libs 1.3.1 → 1.4.3
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/README.md +155 -36
- package/package.json +7 -7
- package/src/Slider/index.js +391 -0
- package/src/Smooth/index.js +9 -8
- package/src/SpritePlayer/index.js +9 -4
- package/src/index.js +2 -1
package/README.md
CHANGED
|
@@ -1,71 +1,190 @@
|
|
|
1
1
|
# stellar-libs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> `@linear_non/stellar-libs` — Reusable UI behavior modules for Non-Linear Studio projects.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
A collection of lightweight, self-contained frontend UI behavior modules built to work seamlessly with [`@linear_non/stellar-kit`](https://www.npmjs.com/package/@linear_non/stellar-kit). Each library provides specific functionality and can be dropped into modular websites with minimal configuration.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Sticky**: Basic sticky logic for DOM elements
|
|
9
|
-
- **Smooth**: Smooth scroll instance using Virtual Scroll
|
|
10
|
-
- **SplitOnScroll**: Text splitting + reveal synced to scroll
|
|
11
|
-
- **Noise**: Configurable canvas noise
|
|
12
|
-
- **SpritePlayer**: Canvas image sequence player
|
|
7
|
+
## Available Libraries
|
|
13
8
|
|
|
14
|
-
|
|
9
|
+
| Library | Description |
|
|
10
|
+
|---|---|
|
|
11
|
+
| **Sticky** | Sticky scroll behavior with GSAP ScrollTrigger |
|
|
12
|
+
| **Smooth** | Smooth scroll system using VirtualScroll |
|
|
13
|
+
| **SplitOnScroll** | Text splitting with scroll-triggered reveals |
|
|
14
|
+
| **Noise** | Canvas-based animated noise effect |
|
|
15
|
+
| **SpritePlayer** | Image sequence player on canvas |
|
|
16
|
+
| **CursorTracker** | Custom cursor that follows mouse |
|
|
17
|
+
| **Marquee** | Infinite horizontal marquee scroller |
|
|
18
|
+
| **Slider** | Drag slider with optional infinite loop and progress |
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
15
21
|
|
|
16
22
|
```bash
|
|
17
23
|
npm install @linear_non/stellar-libs
|
|
24
|
+
npm install @linear_non/stellar-kit
|
|
18
25
|
```
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
## Local Development
|
|
21
28
|
|
|
22
29
|
```bash
|
|
23
|
-
npm install
|
|
30
|
+
npm install
|
|
31
|
+
npm run dev
|
|
24
32
|
```
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
Launches a Vite dev server with a demo index linking to individual playgrounds for each library.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Sticky
|
|
27
41
|
|
|
42
|
+
```js
|
|
43
|
+
import { Sticky } from "@linear_non/stellar-libs"
|
|
44
|
+
|
|
45
|
+
const sticky = new Sticky({
|
|
46
|
+
el: document.querySelector(".sticky-container"),
|
|
47
|
+
sticky: document.querySelector(".sticky-element"),
|
|
48
|
+
isSmooth: false,
|
|
49
|
+
onUpdateCallback: ({ progress }) => console.log(progress),
|
|
50
|
+
})
|
|
28
51
|
```
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
│ └── SpritePlayer/ # Image sprite player
|
|
37
|
-
├── dev/ # Dev playgrounds for each lib
|
|
38
|
-
├── index.js # Entry point for exports
|
|
39
|
-
└── vite.config.js # Vite config to run individual demos
|
|
52
|
+
|
|
53
|
+
### Smooth
|
|
54
|
+
|
|
55
|
+
```js
|
|
56
|
+
import { Smooth } from "@linear_non/stellar-libs"
|
|
57
|
+
|
|
58
|
+
const smooth = new Smooth({ scroll: kitStore.scroll })
|
|
40
59
|
```
|
|
41
60
|
|
|
42
|
-
|
|
61
|
+
```html
|
|
62
|
+
<section data-smooth data-speed="0.8">Parallax content</section>
|
|
63
|
+
```
|
|
43
64
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
65
|
+
### SplitOnScroll
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
import { SplitOnScroll } from "@linear_non/stellar-libs"
|
|
69
|
+
|
|
70
|
+
const split = new SplitOnScroll({
|
|
71
|
+
el: document.querySelector(".trigger"),
|
|
72
|
+
splitText: document.querySelectorAll("h1"),
|
|
73
|
+
isReady: ({ splits, groups }) => {
|
|
74
|
+
gsap.from(splits[0].chars, { y: 40, opacity: 0, stagger: 0.03 })
|
|
75
|
+
},
|
|
76
|
+
once: true,
|
|
77
|
+
})
|
|
47
78
|
```
|
|
48
79
|
|
|
49
|
-
>
|
|
80
|
+
> The constructor key is `splitText` (not `splitTargets`). The `isReady` callback receives `{ splits, groups }`.
|
|
50
81
|
|
|
51
|
-
|
|
82
|
+
### Noise
|
|
52
83
|
|
|
53
84
|
```js
|
|
54
|
-
import {
|
|
85
|
+
import { Noise } from "@linear_non/stellar-libs"
|
|
86
|
+
|
|
87
|
+
const noise = new Noise({
|
|
88
|
+
target: ".noise-canvas",
|
|
89
|
+
density: 0.75,
|
|
90
|
+
color: 0xf3f2feff,
|
|
91
|
+
opacity: 0.25,
|
|
92
|
+
fps: 24,
|
|
93
|
+
})
|
|
94
|
+
```
|
|
55
95
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
96
|
+
### SpritePlayer
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { SpritePlayer } from "@linear_non/stellar-libs"
|
|
100
|
+
|
|
101
|
+
const player = new SpritePlayer({
|
|
102
|
+
canvas: document.querySelector("canvas"),
|
|
103
|
+
container: document.querySelector(".player-container"),
|
|
104
|
+
desktop_path: "/sprites/desktop/",
|
|
105
|
+
mobile_path: "/sprites/mobile/",
|
|
106
|
+
fileName: "frame_{index}",
|
|
107
|
+
total: 60,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
await player.init()
|
|
111
|
+
player.setProgress(0.5)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### CursorTracker
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
import { CursorTracker } from "@linear_non/stellar-libs"
|
|
118
|
+
|
|
119
|
+
const tracker = new CursorTracker({
|
|
120
|
+
container: document.querySelector(".hover-area"),
|
|
121
|
+
el: document.querySelector(".cursor"),
|
|
122
|
+
ease: 0.12,
|
|
123
|
+
isCentered: true,
|
|
59
124
|
})
|
|
60
125
|
```
|
|
61
126
|
|
|
62
|
-
>
|
|
127
|
+
> `CursorTracker` subscribes to `APP_TICK` internally — do not add an external tick subscription.
|
|
128
|
+
|
|
129
|
+
### Marquee
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
import { Marquee } from "@linear_non/stellar-libs"
|
|
133
|
+
|
|
134
|
+
const marquee = new Marquee({
|
|
135
|
+
container: document.querySelector(".marquee"),
|
|
136
|
+
el: document.querySelector(".marquee__track"),
|
|
137
|
+
speed: 50,
|
|
138
|
+
gap: "1.6rem",
|
|
139
|
+
})
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Slider
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
import { Slider } from "@linear_non/stellar-libs"
|
|
146
|
+
|
|
147
|
+
const slider = new Slider({
|
|
148
|
+
container: document.querySelector(".slider"),
|
|
149
|
+
el: document.querySelector(".slider__track"),
|
|
150
|
+
speed: 0.1,
|
|
151
|
+
snap: true,
|
|
152
|
+
infinite: false,
|
|
153
|
+
activeBelow: 'mediumUp', // only active below the mediumUp breakpoint; mobileOnly:true is an alias
|
|
154
|
+
prevEl: document.querySelector(".prev"),
|
|
155
|
+
nextEl: document.querySelector(".next"),
|
|
156
|
+
progressEl: document.querySelector(".progress-fill"),
|
|
157
|
+
onSlideChange: ({ index, total }) => console.log(index, total),
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```html
|
|
162
|
+
<div class="slider">
|
|
163
|
+
<div class="slider__track">
|
|
164
|
+
<div class="slide">01</div>
|
|
165
|
+
<div class="slide">02</div>
|
|
166
|
+
<div class="slide">03</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
```
|
|
63
170
|
|
|
64
171
|
---
|
|
65
172
|
|
|
66
|
-
|
|
173
|
+
## Roadmap
|
|
174
|
+
|
|
175
|
+
- [ ] TypeScript definitions (`.d.ts` for all 8 libraries)
|
|
176
|
+
- [ ] Unit test suite (per-library Jest tests)
|
|
177
|
+
- [ ] Accessibility — ARIA attributes + keyboard support (Slider, Marquee, CursorTracker)
|
|
178
|
+
- [ ] Animation presets — exportable GSAP timeline builders per library
|
|
179
|
+
- [ ] Performance monitoring — FPS tracking integrated with stellar-kit `?debug`
|
|
180
|
+
- [ ] Storybook interactive docs
|
|
181
|
+
- [ ] Plugin system — `.use(plugin)` extension API
|
|
182
|
+
|
|
183
|
+
## Lifecycle
|
|
184
|
+
|
|
185
|
+
All libraries follow a consistent class-based lifecycle: `init() → on() → tick() → resize() → off() → destroy()`
|
|
67
186
|
|
|
68
|
-
|
|
187
|
+
Always call `destroy()` on page navigation to clean up event listeners, animations, and timers.
|
|
69
188
|
|
|
70
189
|
---
|
|
71
190
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linear_non/stellar-libs",
|
|
3
|
-
"version": "1.3
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "Reusable JavaScript libraries for Non-Linear Studio projects.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -10,10 +10,6 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"src"
|
|
12
12
|
],
|
|
13
|
-
"scripts": {
|
|
14
|
-
"dev": "vite",
|
|
15
|
-
"build": "vite build"
|
|
16
|
-
},
|
|
17
13
|
"keywords": [
|
|
18
14
|
"non-linear",
|
|
19
15
|
"frontend",
|
|
@@ -25,10 +21,14 @@
|
|
|
25
21
|
"author": "Non-Linear Studio",
|
|
26
22
|
"license": "MIT",
|
|
27
23
|
"dependencies": {
|
|
28
|
-
"@linear_non/stellar-kit": "
|
|
24
|
+
"@linear_non/stellar-kit": "3.0.12"
|
|
29
25
|
},
|
|
30
26
|
"devDependencies": {
|
|
31
27
|
"@linear_non/prettier-config": "^1.0.6",
|
|
32
28
|
"vite": "^6.3.5"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"dev": "vite",
|
|
32
|
+
"build": "vite build"
|
|
33
33
|
}
|
|
34
|
-
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { lerp, bounds } from "@linear_non/stellar-kit/utils"
|
|
2
|
+
import { gsap } from "@linear_non/stellar-kit/gsap"
|
|
3
|
+
import { emitter, EVENTS } from "@linear_non/stellar-kit/events"
|
|
4
|
+
import { kitStore } from "@linear_non/stellar-kit"
|
|
5
|
+
|
|
6
|
+
export default class Slider {
|
|
7
|
+
constructor({
|
|
8
|
+
container,
|
|
9
|
+
el,
|
|
10
|
+
speed = 0.1,
|
|
11
|
+
snap = true,
|
|
12
|
+
infinite = false,
|
|
13
|
+
// activeBelow: a kitStore.breakpoints key (e.g. 'mediumUp', 'smallUp').
|
|
14
|
+
// Slider is active when kitStore.breakpoints[activeBelow] is falsy (i.e. we are below that breakpoint).
|
|
15
|
+
// mobileOnly: true is kept as a backward-compat alias for activeBelow: 'mediumUp'.
|
|
16
|
+
activeBelow = null,
|
|
17
|
+
mobileOnly = false,
|
|
18
|
+
prevEl = null,
|
|
19
|
+
nextEl = null,
|
|
20
|
+
progressEl = null,
|
|
21
|
+
onSlideChange = null,
|
|
22
|
+
}) {
|
|
23
|
+
this.container = container
|
|
24
|
+
this.el = el
|
|
25
|
+
this.speed = speed
|
|
26
|
+
this.snap = infinite ? true : snap // infinite always snaps
|
|
27
|
+
this.infinite = infinite
|
|
28
|
+
// Resolve activeBelow: explicit prop wins; mobileOnly:true falls back to 'mediumUp'
|
|
29
|
+
this.activeBelow = activeBelow || (mobileOnly ? 'mediumUp' : null)
|
|
30
|
+
this.prevEl = prevEl
|
|
31
|
+
this.nextEl = nextEl
|
|
32
|
+
this.progressEl = progressEl
|
|
33
|
+
this.onSlideChange = typeof onSlideChange === "function" ? onSlideChange : null
|
|
34
|
+
|
|
35
|
+
this.isActive = false
|
|
36
|
+
this.isDown = false
|
|
37
|
+
this.isDragging = false
|
|
38
|
+
|
|
39
|
+
this.x = { start: 0, state: 0 }
|
|
40
|
+
this.current = 0
|
|
41
|
+
this.target = 0
|
|
42
|
+
this.index = 0 // 0-based real index (infinite: derived from position)
|
|
43
|
+
this.total = 0
|
|
44
|
+
this.slideWidth = 0
|
|
45
|
+
this.setW = 0 // total width of one full set of real slides (infinite mode)
|
|
46
|
+
this.limit = 0
|
|
47
|
+
|
|
48
|
+
this.dpr = Math.max(1, Math.round(window.devicePixelRatio || 1))
|
|
49
|
+
this._snap2px = v => Math.round(v * this.dpr) / this.dpr
|
|
50
|
+
|
|
51
|
+
this.init()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Setup ────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
_shouldActivate() {
|
|
57
|
+
if (!this.activeBelow) return true
|
|
58
|
+
// Active when the named breakpoint flag is falsy (i.e. we are below that breakpoint)
|
|
59
|
+
return !kitStore.breakpoints[this.activeBelow]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setup() {
|
|
63
|
+
this.isActive = this._shouldActivate()
|
|
64
|
+
if (!this.isActive) return
|
|
65
|
+
|
|
66
|
+
if (this.infinite) {
|
|
67
|
+
this._setupInfinite()
|
|
68
|
+
} else {
|
|
69
|
+
this._measure()
|
|
70
|
+
this.index = gsap.utils.clamp(0, this.total - 1, this.index)
|
|
71
|
+
this.target = this.index * this.slideWidth
|
|
72
|
+
this.current = this.target
|
|
73
|
+
this._draw(this.current)
|
|
74
|
+
this._updateProgress()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_measure() {
|
|
79
|
+
// Only measure non-clone slides for width/total
|
|
80
|
+
const rawSlides = Array.from(this.el.children).filter(s => !s.dataset.clone)
|
|
81
|
+
this.total = rawSlides.length
|
|
82
|
+
if (!this.total) return
|
|
83
|
+
|
|
84
|
+
const gapPx = parseFloat(getComputedStyle(this.el).gap) || 0
|
|
85
|
+
this.slideWidth = rawSlides[0].getBoundingClientRect().width + gapPx
|
|
86
|
+
this.setW = this.slideWidth * this.total
|
|
87
|
+
const viewWidth = bounds(this.container).width
|
|
88
|
+
this.limit = Math.max(0, this.setW - viewWidth)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_setupInfinite() {
|
|
92
|
+
// Remove any stale clones from previous setup
|
|
93
|
+
Array.from(this.el.querySelectorAll("[data-clone]")).forEach(c => c.remove())
|
|
94
|
+
|
|
95
|
+
const rawSlides = Array.from(this.el.children)
|
|
96
|
+
if (rawSlides.length < 2) return
|
|
97
|
+
|
|
98
|
+
// Clone the ENTIRE set before and after (tripling) so the user can never
|
|
99
|
+
// reach a real edge regardless of drag speed or prev/next calls.
|
|
100
|
+
const cloneBefore = rawSlides.map(s => {
|
|
101
|
+
const c = s.cloneNode(true)
|
|
102
|
+
c.setAttribute("aria-hidden", "true")
|
|
103
|
+
c.dataset.clone = "before"
|
|
104
|
+
return c
|
|
105
|
+
})
|
|
106
|
+
const cloneAfter = rawSlides.map(s => {
|
|
107
|
+
const c = s.cloneNode(true)
|
|
108
|
+
c.setAttribute("aria-hidden", "true")
|
|
109
|
+
c.dataset.clone = "after"
|
|
110
|
+
return c
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Prepend "before" in correct visual order, append "after"
|
|
114
|
+
;[...cloneBefore].reverse().forEach(c => this.el.prepend(c))
|
|
115
|
+
cloneAfter.forEach(c => this.el.appendChild(c))
|
|
116
|
+
|
|
117
|
+
this._measure()
|
|
118
|
+
|
|
119
|
+
// x convention: translateX(-x). Start at setW so real slide 0 is visible.
|
|
120
|
+
// Track layout: [before clones 0..setW) [real slides setW..2setW) [after clones 2setW..3setW)
|
|
121
|
+
this.index = 0
|
|
122
|
+
this.current = this.setW
|
|
123
|
+
this.target = this.setW
|
|
124
|
+
this._draw(this.current)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Infinite wrap (runs every tick) ──────────────────────────────────────
|
|
128
|
+
// Silently teleport when position drifts outside the real-set zone.
|
|
129
|
+
// Because we cloned full sets on both sides this jump is always invisible.
|
|
130
|
+
|
|
131
|
+
_wrapInfinite() {
|
|
132
|
+
if (!this.setW) return
|
|
133
|
+
let shift = 0
|
|
134
|
+
if (this.current < 0) {
|
|
135
|
+
shift = Math.ceil(-this.current / this.setW) * this.setW
|
|
136
|
+
} else if (this.current >= 2 * this.setW) {
|
|
137
|
+
shift = -Math.floor((this.current - this.setW) / this.setW) * this.setW
|
|
138
|
+
}
|
|
139
|
+
if (shift !== 0) {
|
|
140
|
+
this.current += shift
|
|
141
|
+
this.target += shift
|
|
142
|
+
// Keep the drag reference in sync so the next onPointerMove doesn't
|
|
143
|
+
// compute a target relative to the pre-wrap baseline, which would cause
|
|
144
|
+
// the slider to snap back to the wrong position.
|
|
145
|
+
if (this.isDown) this.x.state += shift
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ─── Render ───────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
_draw(x) {
|
|
152
|
+
this.el.style.transform = `translate3d(${-this._snap2px(x)}px, 0, 0)`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_updateProgress() {
|
|
156
|
+
if (!this.progressEl || this.infinite || !this.total) return
|
|
157
|
+
// Position-based so the bar reaches 100% exactly when current hits limit.
|
|
158
|
+
// Floor at 1/total so the bar is never empty on the first slide.
|
|
159
|
+
const min = 1 / this.total
|
|
160
|
+
const pos = this.limit > 0 ? this.current / this.limit : 1
|
|
161
|
+
const progress = Math.max(min, Math.min(1, pos))
|
|
162
|
+
this.progressEl.style.transformOrigin = "left center"
|
|
163
|
+
this.progressEl.style.transform = `scaleX(${progress})`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Tick ─────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
tick = () => {
|
|
169
|
+
if (!this.isActive) return
|
|
170
|
+
|
|
171
|
+
const prev = this.current
|
|
172
|
+
this.current = lerp(this.current, this.target, this.speed)
|
|
173
|
+
|
|
174
|
+
// Snap to exact value when close enough to avoid sub-pixel drift
|
|
175
|
+
if (Math.abs(this.current - this.target) < 0.01) {
|
|
176
|
+
this.current = this.target
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Infinite: wrap on every tick (not just when settled) so a fast drag
|
|
180
|
+
// that overshoots the clone zone is corrected immediately.
|
|
181
|
+
if (this.infinite) this._wrapInfinite()
|
|
182
|
+
|
|
183
|
+
if (this.current !== prev) {
|
|
184
|
+
this._draw(this.current)
|
|
185
|
+
this._updateProgress()
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Snap ─────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
_snapToNearest() {
|
|
192
|
+
if (!this.slideWidth) return
|
|
193
|
+
|
|
194
|
+
if (this.infinite) {
|
|
195
|
+
// Round to nearest slide in ABSOLUTE (un-normalised) space.
|
|
196
|
+
// Do NOT clamp target back into [0, 2·setW) here — that would invert the
|
|
197
|
+
// lerp direction if the user released before current crossed the wrap
|
|
198
|
+
// boundary, causing a visible "all-slides-jump-backward" reset.
|
|
199
|
+
// Instead, keep target in whatever cycle it is; _wrapInfinite will shift
|
|
200
|
+
// both current and target together tick-by-tick as current naturally
|
|
201
|
+
// crosses each setW boundary — always seamless, always forward.
|
|
202
|
+
this.target = Math.round(this.target / this.slideWidth) * this.slideWidth
|
|
203
|
+
// Derive real 0-based index via modulo on normalised position
|
|
204
|
+
const normPos = ((this.target % this.setW) + this.setW) % this.setW
|
|
205
|
+
this.index = Math.round(normPos / this.slideWidth) % this.total
|
|
206
|
+
} else {
|
|
207
|
+
this.index = gsap.utils.clamp(0, this.total - 1, Math.round(this.target / this.slideWidth))
|
|
208
|
+
this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
this._fireChange()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
_fireChange() {
|
|
215
|
+
this._updateProgress()
|
|
216
|
+
if (!this.onSlideChange) return
|
|
217
|
+
this.onSlideChange({ index: this.index, total: this.total })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ─── Public navigation ────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
next = () => {
|
|
223
|
+
if (!this.isActive) return
|
|
224
|
+
if (this.infinite) {
|
|
225
|
+
this.target += this.slideWidth
|
|
226
|
+
this.index = (this.index + 1) % this.total
|
|
227
|
+
} else {
|
|
228
|
+
this.index = gsap.utils.clamp(0, this.total - 1, this.index + 1)
|
|
229
|
+
this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
|
|
230
|
+
}
|
|
231
|
+
this._fireChange()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
prev = () => {
|
|
235
|
+
if (!this.isActive) return
|
|
236
|
+
if (this.infinite) {
|
|
237
|
+
this.target -= this.slideWidth
|
|
238
|
+
this.index = (this.index - 1 + this.total) % this.total
|
|
239
|
+
} else {
|
|
240
|
+
this.index = gsap.utils.clamp(0, this.total - 1, this.index - 1)
|
|
241
|
+
this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
|
|
242
|
+
}
|
|
243
|
+
this._fireChange()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
goTo(i) {
|
|
247
|
+
if (!this.isActive) return
|
|
248
|
+
const idx = gsap.utils.clamp(0, this.total - 1, i)
|
|
249
|
+
if (this.infinite) {
|
|
250
|
+
// Find the shortest delta from current position to target idx
|
|
251
|
+
const currentIdx = this.index
|
|
252
|
+
let delta = idx - currentIdx
|
|
253
|
+
if (Math.abs(delta) > this.total / 2) {
|
|
254
|
+
delta = delta > 0 ? delta - this.total : delta + this.total
|
|
255
|
+
}
|
|
256
|
+
this.target += delta * this.slideWidth
|
|
257
|
+
} else {
|
|
258
|
+
this.target = gsap.utils.clamp(0, this.limit, idx * this.slideWidth)
|
|
259
|
+
}
|
|
260
|
+
this.index = idx
|
|
261
|
+
this._fireChange()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Pointer events ───────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
onPointerDown = e => {
|
|
267
|
+
if (!this.isActive) return
|
|
268
|
+
const clientX = e.touches ? e.touches[0].clientX : e.clientX
|
|
269
|
+
this.isDown = true
|
|
270
|
+
this.x.start = clientX
|
|
271
|
+
this.x.state = this.target
|
|
272
|
+
this.container.classList.add("-dragging")
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
onPointerMove = e => {
|
|
276
|
+
if (!this.isActive || !this.isDown) return
|
|
277
|
+
this.isDragging = true
|
|
278
|
+
const clientX = e.touches ? e.touches[0].clientX : e.clientX
|
|
279
|
+
const delta = this.x.start - clientX
|
|
280
|
+
let newTarget = this.x.state + delta
|
|
281
|
+
|
|
282
|
+
if (!this.infinite) {
|
|
283
|
+
newTarget = gsap.utils.clamp(0, this.limit, newTarget)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
this.target = newTarget
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
onPointerUp = () => {
|
|
290
|
+
if (!this.isActive) return
|
|
291
|
+
this.isDown = false
|
|
292
|
+
this.container.classList.remove("-dragging")
|
|
293
|
+
|
|
294
|
+
if (this.isDragging && this.snap) {
|
|
295
|
+
this._snapToNearest()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
this.isDragging = false
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
on() {
|
|
304
|
+
if (!this.isActive) return
|
|
305
|
+
|
|
306
|
+
this.container.addEventListener("mousedown", this.onPointerDown)
|
|
307
|
+
this.container.addEventListener("touchstart", this.onPointerDown, { passive: true })
|
|
308
|
+
window.addEventListener("mousemove", this.onPointerMove)
|
|
309
|
+
window.addEventListener("touchmove", this.onPointerMove, { passive: true })
|
|
310
|
+
window.addEventListener("mouseup", this.onPointerUp)
|
|
311
|
+
window.addEventListener("touchend", this.onPointerUp)
|
|
312
|
+
|
|
313
|
+
if (this.prevEl) this.prevEl.addEventListener("click", this.prev)
|
|
314
|
+
if (this.nextEl) this.nextEl.addEventListener("click", this.next)
|
|
315
|
+
|
|
316
|
+
emitter.on(EVENTS.APP_TICK, this.tick)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
off() {
|
|
320
|
+
this.container?.removeEventListener("mousedown", this.onPointerDown)
|
|
321
|
+
this.container?.removeEventListener("touchstart", this.onPointerDown)
|
|
322
|
+
window.removeEventListener("mousemove", this.onPointerMove)
|
|
323
|
+
window.removeEventListener("touchmove", this.onPointerMove)
|
|
324
|
+
window.removeEventListener("mouseup", this.onPointerUp)
|
|
325
|
+
window.removeEventListener("touchend", this.onPointerUp)
|
|
326
|
+
|
|
327
|
+
if (this.prevEl) this.prevEl.removeEventListener("click", this.prev)
|
|
328
|
+
if (this.nextEl) this.nextEl.removeEventListener("click", this.next)
|
|
329
|
+
|
|
330
|
+
emitter.off(EVENTS.APP_TICK, this.tick)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
resize = () => {
|
|
334
|
+
const wasActive = this.isActive
|
|
335
|
+
this.isActive = this._shouldActivate()
|
|
336
|
+
|
|
337
|
+
// Was active, now inactive (e.g. mobileOnly + resized to desktop)
|
|
338
|
+
if (wasActive && !this.isActive) {
|
|
339
|
+
this._reset()
|
|
340
|
+
this.off()
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Was inactive, now active (e.g. mobileOnly + resized to mobile)
|
|
345
|
+
if (!wasActive && this.isActive) {
|
|
346
|
+
this.setup()
|
|
347
|
+
this.on()
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (!this.isActive) return
|
|
352
|
+
|
|
353
|
+
if (this.infinite) {
|
|
354
|
+
// Rebuild clones and remeasure, keeping current real index visible
|
|
355
|
+
const savedIndex = this.index
|
|
356
|
+
this._setupInfinite()
|
|
357
|
+
this.index = savedIndex
|
|
358
|
+
this.current = this.target = this.setW + savedIndex * this.slideWidth
|
|
359
|
+
this._draw(this.current)
|
|
360
|
+
} else {
|
|
361
|
+
this._measure()
|
|
362
|
+
this.target = gsap.utils.clamp(0, this.limit, this.index * this.slideWidth)
|
|
363
|
+
this.current = this.target
|
|
364
|
+
this._draw(this.current)
|
|
365
|
+
this._updateProgress()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
_reset() {
|
|
370
|
+
Array.from(this.el.querySelectorAll("[data-clone]")).forEach(c => c.remove())
|
|
371
|
+
this.el.style.transform = ""
|
|
372
|
+
this.current = this.target = 0
|
|
373
|
+
this.index = 0
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
destroy() {
|
|
377
|
+
this.off()
|
|
378
|
+
this._reset()
|
|
379
|
+
emitter.off(EVENTS.APP_RESIZE, this.resize)
|
|
380
|
+
this.container = null
|
|
381
|
+
this.el = null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
init() {
|
|
385
|
+
this.setup()
|
|
386
|
+
this.on()
|
|
387
|
+
// APP_RESIZE is always registered so the slider can transition
|
|
388
|
+
// between active/inactive states when the breakpoint changes.
|
|
389
|
+
emitter.on(EVENTS.APP_RESIZE, this.resize)
|
|
390
|
+
}
|
|
391
|
+
}
|
package/src/Smooth/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Scrollbar from "../ScrollBar"
|
|
2
|
+
import { gsap } from "@linear_non/stellar-kit/gsap"
|
|
2
3
|
import { kitStore } from "@linear_non/stellar-kit"
|
|
3
4
|
import { emitter, EVENTS } from "@linear_non/stellar-kit/events"
|
|
4
5
|
import { qs, qsa, bounds, Debug } from "@linear_non/stellar-kit/utils"
|
|
@@ -24,7 +25,7 @@ export default class Smooth {
|
|
|
24
25
|
let cursor = 0
|
|
25
26
|
|
|
26
27
|
this.elems.forEach(el => {
|
|
27
|
-
|
|
28
|
+
gsap.set(el, { y: 0 })
|
|
28
29
|
const raw = el.dataset.speed != null ? parseFloat(el.dataset.speed) : 1
|
|
29
30
|
const speed = Number.isFinite(raw) ? raw : 1
|
|
30
31
|
const { height, offset } = this.getVars(el, speed)
|
|
@@ -39,6 +40,7 @@ export default class Smooth {
|
|
|
39
40
|
|
|
40
41
|
this.sections.push({
|
|
41
42
|
el,
|
|
43
|
+
ySet: gsap.quickSetter(el, "y", "px"),
|
|
42
44
|
parent,
|
|
43
45
|
top,
|
|
44
46
|
bottom,
|
|
@@ -66,9 +68,9 @@ export default class Smooth {
|
|
|
66
68
|
this.sections.forEach(section => {
|
|
67
69
|
const { isVisible, transform } = this.isVisible(section)
|
|
68
70
|
if (isVisible || isResizing || !section.out) {
|
|
69
|
-
const
|
|
71
|
+
const y = this.getY(transform)
|
|
70
72
|
if (y !== section._roundedY) {
|
|
71
|
-
section.
|
|
73
|
+
section.ySet(y)
|
|
72
74
|
section._roundedY = y
|
|
73
75
|
}
|
|
74
76
|
section.out = !isVisible
|
|
@@ -90,10 +92,9 @@ export default class Smooth {
|
|
|
90
92
|
return { isVisible, transform }
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return { y, css: `translate3d(0, ${y}px, 0)` }
|
|
95
|
+
getY(transform) {
|
|
96
|
+
const y = -transform
|
|
97
|
+
return Math.round(y * this.dpr) / this.dpr
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
getVars(el, speed) {
|
|
@@ -137,7 +138,7 @@ export default class Smooth {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
clean() {
|
|
140
|
-
if (this.sections) this.sections.forEach(s => (s.el
|
|
141
|
+
if (this.sections) this.sections.forEach(s => gsap.set(s.el, { y: 0, clearProps: "transform" }))
|
|
141
142
|
this.elems = this.sections = null
|
|
142
143
|
}
|
|
143
144
|
|
|
@@ -124,7 +124,7 @@ export default class SpritePlayer {
|
|
|
124
124
|
this.dom.alphaImages = alphaImages
|
|
125
125
|
|
|
126
126
|
if (this.isAlpha && this.dom.alphaImages.length) {
|
|
127
|
-
this.dom.alphaImages = this.dom.alphaImages.map(img => this.toAlphaFromLuma(img))
|
|
127
|
+
this.dom.alphaImages = await Promise.all(this.dom.alphaImages.map(img => this.toAlphaFromLuma(img)))
|
|
128
128
|
}
|
|
129
129
|
if (this.isAlpha && this.dom.alphaImages.length !== this.dom.images.length) {
|
|
130
130
|
this.isAlpha = false
|
|
@@ -154,8 +154,8 @@ export default class SpritePlayer {
|
|
|
154
154
|
Object.assign(this.canvas, { width: rect.width, height: rect.height, rect, area, offset })
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
// grayscale → alpha (returns
|
|
158
|
-
toAlphaFromLuma(img) {
|
|
157
|
+
// grayscale → alpha (returns an ImageBitmap; temp canvas backing store freed immediately)
|
|
158
|
+
async toAlphaFromLuma(img) {
|
|
159
159
|
const w = img.naturalWidth || img.width
|
|
160
160
|
const h = img.naturalHeight || img.height
|
|
161
161
|
const c = document.createElement("canvas")
|
|
@@ -173,7 +173,11 @@ export default class SpritePlayer {
|
|
|
173
173
|
a[i + 3] = lum
|
|
174
174
|
}
|
|
175
175
|
x.putImageData(d, 0, 0)
|
|
176
|
-
|
|
176
|
+
const bitmap = await createImageBitmap(c)
|
|
177
|
+
// Release backing store — bitmap is now the only reference needed
|
|
178
|
+
c.width = 0
|
|
179
|
+
c.height = 0
|
|
180
|
+
return bitmap
|
|
177
181
|
}
|
|
178
182
|
|
|
179
183
|
drawImageCover(ctx, img, area, offset) {
|
|
@@ -254,6 +258,7 @@ export default class SpritePlayer {
|
|
|
254
258
|
if (!this.persistent) {
|
|
255
259
|
this._loaders.forEach(l => l.destroy?.())
|
|
256
260
|
}
|
|
261
|
+
this.dom.alphaImages.forEach(bmp => bmp?.close?.())
|
|
257
262
|
this.dom.images = []
|
|
258
263
|
this.dom.alphaImages = []
|
|
259
264
|
this._loaders = []
|
package/src/index.js
CHANGED
|
@@ -5,5 +5,6 @@ import Noise from "./Noise"
|
|
|
5
5
|
import SpritePlayer from "./SpritePlayer"
|
|
6
6
|
import CursorTracker from "./CursorTracker"
|
|
7
7
|
import Marquee from "./Marquee"
|
|
8
|
+
import Slider from "./Slider"
|
|
8
9
|
|
|
9
|
-
export { Sticky, Smooth, SplitOnScroll, Noise, SpritePlayer, CursorTracker, Marquee }
|
|
10
|
+
export { Sticky, Smooth, SplitOnScroll, Noise, SpritePlayer, CursorTracker, Marquee, Slider }
|