kmcom-nuxt-layers 1.6.38 → 1.6.40
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/docs/UPAGE-GRID-INTEGRATION.md +186 -0
- package/layers/motion/app/components/Motion/Marquee.vue +130 -130
- package/layers/motion/app/components/Motion/MarqueeText.vue +147 -0
- package/layers/motion/app/composables/useMarqueeCopies.ts +50 -0
- package/layers/motion/app/composables/useMarqueeVelocity.ts +44 -0
- package/layers/motion/tsconfig.json +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# UPage + Swiss Grid Integration
|
|
2
|
+
|
|
3
|
+
## The Problem
|
|
4
|
+
|
|
5
|
+
`<LayoutMain>` renders `<main class="mastmain">` which sets:
|
|
6
|
+
|
|
7
|
+
```css
|
|
8
|
+
.mastmain {
|
|
9
|
+
grid-auto-rows: calc((100vh - 11 * gap) / 12); /* ≈ 8.5vh per row */
|
|
10
|
+
}
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Every direct child that doesn't explicitly span rows becomes ~8.5vh tall. `<UPage>` and Nuxt UI's page components (`UPageSection`, `UPageHero`, etc.) don't span rows — so they collapse.
|
|
14
|
+
|
|
15
|
+
`.basesection` fixes this for `<LayoutSection>` by spanning 12 rows:
|
|
16
|
+
|
|
17
|
+
```css
|
|
18
|
+
.basesection {
|
|
19
|
+
grid-row: span 12; /* 12 × 8.5vh ≈ 100vh */
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`<UPage>` has no equivalent. It's designed for document flow, not a fixed-row grid.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Approaches
|
|
28
|
+
|
|
29
|
+
### Option A — `minmax` on `grid-auto-rows` (Recommended)
|
|
30
|
+
|
|
31
|
+
**Change in `grids.css`:**
|
|
32
|
+
|
|
33
|
+
```css
|
|
34
|
+
.mastmain {
|
|
35
|
+
/* Before */
|
|
36
|
+
grid-auto-rows: var(--grid-row-height);
|
|
37
|
+
|
|
38
|
+
/* After — allow rows to grow beyond their minimum */
|
|
39
|
+
grid-auto-rows: minmax(var(--grid-row-height), auto);
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The mobile breakpoint already uses this. Apply it at all breakpoints.
|
|
44
|
+
|
|
45
|
+
**What changes:**
|
|
46
|
+
- `<LayoutSection>` spanning 12 rows = at least `12 × 8.5vh ≈ 100vh` (min preserved)
|
|
47
|
+
- `<UPage>` in a single row = auto-sized to its content height
|
|
48
|
+
- Column alignment is fully preserved
|
|
49
|
+
|
|
50
|
+
**Trade-off:** `rowStart` placement in `<LayoutGridItem>` becomes less predictable when rows above it grow beyond their minimum. `rowSpan` presets are unaffected.
|
|
51
|
+
|
|
52
|
+
**Implementation:** One-line CSS change in the layout layer source.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
### Option B — `display: contents` on UPage
|
|
57
|
+
|
|
58
|
+
Configure `<UPage>` to be transparent to the grid so its children participate directly:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// In frontend app.config.ts, override the layout layer's UPage config:
|
|
62
|
+
ui: {
|
|
63
|
+
page: {
|
|
64
|
+
root: 'col-span-full [display:contents]',
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
With `display: contents`, `<UPage>` generates no box — its children (`UPageHero`, `UPageSection`, etc.) become direct grid items in `mastmain`.
|
|
70
|
+
|
|
71
|
+
**What changes:**
|
|
72
|
+
- `<UPage>` styling is effectively gone (use with care)
|
|
73
|
+
- Each child component becomes a grid item and needs its own column/row spanning
|
|
74
|
+
- Requires configuring `UPageSection`, `UPageHero`, etc. similarly
|
|
75
|
+
|
|
76
|
+
**Trade-off:** You lose `<UPage>`'s layout structure. Works best when you're using it purely as a semantic grouping with no visual styling.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
### Option C — `<LayoutPage>` component (Hybrid mode)
|
|
81
|
+
|
|
82
|
+
A new component that gives pages an explicit choice between Swiss Grid and normal document flow:
|
|
83
|
+
|
|
84
|
+
```vue
|
|
85
|
+
<!-- layers/layout/app/components/Layout/Page/index.vue -->
|
|
86
|
+
<script setup lang="ts">
|
|
87
|
+
interface Props {
|
|
88
|
+
mode?: 'swiss' | 'flow'
|
|
89
|
+
}
|
|
90
|
+
const { mode = 'swiss' } = defineProps<Props>()
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<template>
|
|
94
|
+
<!-- Swiss: children participate directly in mastmain grid (use LayoutSection) -->
|
|
95
|
+
<template v-if="mode === 'swiss'">
|
|
96
|
+
<slot />
|
|
97
|
+
</template>
|
|
98
|
+
|
|
99
|
+
<!-- Flow: UPage mode — auto-height row that fills content -->
|
|
100
|
+
<div
|
|
101
|
+
v-else
|
|
102
|
+
class="col-span-full"
|
|
103
|
+
style="grid-row: auto / span 999; height: max-content; min-height: 0"
|
|
104
|
+
>
|
|
105
|
+
<UPage>
|
|
106
|
+
<slot />
|
|
107
|
+
</UPage>
|
|
108
|
+
</div>
|
|
109
|
+
</template>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Usage:
|
|
113
|
+
|
|
114
|
+
```vue
|
|
115
|
+
<!-- Swiss Grid page -->
|
|
116
|
+
<template>
|
|
117
|
+
<LayoutPage>
|
|
118
|
+
<LayoutSection full-height>
|
|
119
|
+
<LayoutGridItem preset="centered">
|
|
120
|
+
<h1>Title</h1>
|
|
121
|
+
</LayoutGridItem>
|
|
122
|
+
</LayoutSection>
|
|
123
|
+
</LayoutPage>
|
|
124
|
+
</template>
|
|
125
|
+
|
|
126
|
+
<!-- UPage page inside LayoutMain -->
|
|
127
|
+
<template>
|
|
128
|
+
<LayoutPage mode="flow">
|
|
129
|
+
<UPageHero title="About" />
|
|
130
|
+
<UPageSection>...</UPageSection>
|
|
131
|
+
</LayoutPage>
|
|
132
|
+
</template>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Trade-off:** `span 999` is an escape hatch — it works because CSS Grid clamps spans to the available track count. It's pragmatic but not semantically clean. Requires the component to live in the layout layer source.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### Option D — Per-page `<LayoutMain tag="div">` opt-in
|
|
140
|
+
|
|
141
|
+
Don't apply `<LayoutMain>` in the layouts at all. Let pages that want the Swiss Grid add it themselves inside their template:
|
|
142
|
+
|
|
143
|
+
```vue
|
|
144
|
+
<!-- A Swiss Grid page — no UPage -->
|
|
145
|
+
<template>
|
|
146
|
+
<LayoutMain tag="div">
|
|
147
|
+
<LayoutSection full-height>
|
|
148
|
+
<LayoutGridItem preset="hero">
|
|
149
|
+
<h1>Home</h1>
|
|
150
|
+
</LayoutGridItem>
|
|
151
|
+
</LayoutSection>
|
|
152
|
+
</LayoutMain>
|
|
153
|
+
</template>
|
|
154
|
+
|
|
155
|
+
<!-- A normal page — uses UMain from the layout, no grid involvement -->
|
|
156
|
+
<template>
|
|
157
|
+
<UPage>
|
|
158
|
+
<UPageHero title="About" />
|
|
159
|
+
</UPage>
|
|
160
|
+
</template>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
**This is the current approach after the revert.** The layout layer is available everywhere but not globally applied. The `mastmain` CSS class exists — any element can opt in via `class="mastmain"` or `<LayoutMain tag="div">`.
|
|
164
|
+
|
|
165
|
+
**Trade-off:** No automatic column alignment across pages. Pages must explicitly adopt the grid.
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## Recommendation
|
|
170
|
+
|
|
171
|
+
**Short-term (no code changes required):** Option D — current approach. Build new sections using `<LayoutSection>` / `<LayoutGridItem>` inside pages that want the Swiss Grid. Existing `<UPage>` pages are untouched.
|
|
172
|
+
|
|
173
|
+
**Medium-term (one CSS change):** Option A — change `grid-auto-rows` to `minmax(var(--grid-row-height), auto)` in `grids.css`. This makes `<LayoutMain>` compatible with both Swiss Grid sections and UPage content at the same time, unlocking global application in the layouts with zero page changes.
|
|
174
|
+
|
|
175
|
+
**Long-term:** Migrate pages to the Swiss Grid pattern (no `<UPage>`) to take full advantage of precise row placement and the full 18-column subgrid system.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## CSS Grid Behaviour Reference
|
|
180
|
+
|
|
181
|
+
| Scenario | `grid-auto-rows: 8.5vh` | `grid-auto-rows: minmax(8.5vh, auto)` |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `<LayoutSection>` (spans 12 rows) | ✅ Exactly 100vh | ✅ At least 100vh, grows with content |
|
|
184
|
+
| `<UPage>` (1 auto row) | ❌ Collapsed to 8.5vh | ✅ Grows to content height |
|
|
185
|
+
| `<LayoutGridItem rowStart="3">` | ✅ Predictable position | ⚠️ Position shifts if rows above grew |
|
|
186
|
+
| `<LayoutGridItem rowSpan="6">` | ✅ 6 × 8.5vh = 51vh | ✅ At least 6 × 8.5vh |
|
|
@@ -1,142 +1,142 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
const props = withDefaults(
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
const { gsap } = useGsap()
|
|
56
|
-
const { velocity: scrollVelocity, direction: scrollDirection } = useSmoothScroll()
|
|
57
|
-
|
|
58
|
-
const containerRef = ref<HTMLElement | null>(null)
|
|
59
|
-
const contentRef = ref<HTMLElement | null>(null)
|
|
60
|
-
const tweenRef = ref<gsap.core.Tween | null>(null)
|
|
61
|
-
|
|
62
|
-
const isPaused = ref(false)
|
|
63
|
-
const currentTimeScale = ref(1)
|
|
64
|
-
|
|
65
|
-
onMounted(() => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
2
|
+
const props = withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
/**
|
|
5
|
+
* Base animation speed in pixels per second
|
|
6
|
+
*/
|
|
7
|
+
speed?: number
|
|
8
|
+
/**
|
|
9
|
+
* Direction of scroll
|
|
10
|
+
*/
|
|
11
|
+
direction?: 'left' | 'right'
|
|
12
|
+
/**
|
|
13
|
+
* Pause animation on hover
|
|
14
|
+
*/
|
|
15
|
+
pauseOnHover?: boolean
|
|
16
|
+
/**
|
|
17
|
+
* Gap between repeated items
|
|
18
|
+
*/
|
|
19
|
+
gap?: string
|
|
20
|
+
/**
|
|
21
|
+
* Enable velocity-based speed (scroll velocity affects marquee speed)
|
|
22
|
+
*/
|
|
23
|
+
velocityBased?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* How much scroll velocity affects speed (0-1)
|
|
26
|
+
* 0 = no effect, 1 = velocity fully controls speed
|
|
27
|
+
*/
|
|
28
|
+
velocitySensitivity?: number
|
|
29
|
+
/**
|
|
30
|
+
* Reverse direction based on scroll direction
|
|
31
|
+
*/
|
|
32
|
+
velocityDirection?: boolean
|
|
33
|
+
/**
|
|
34
|
+
* Minimum speed multiplier when using velocity
|
|
35
|
+
*/
|
|
36
|
+
minSpeed?: number
|
|
37
|
+
/**
|
|
38
|
+
* Maximum speed multiplier when using velocity
|
|
39
|
+
*/
|
|
40
|
+
maxSpeed?: number
|
|
41
|
+
}>(),
|
|
42
|
+
{
|
|
43
|
+
speed: 50,
|
|
44
|
+
direction: 'left',
|
|
45
|
+
pauseOnHover: true,
|
|
46
|
+
gap: '2rem',
|
|
47
|
+
velocityBased: false,
|
|
48
|
+
velocitySensitivity: 0.5,
|
|
49
|
+
velocityDirection: false,
|
|
50
|
+
minSpeed: 0.2,
|
|
51
|
+
maxSpeed: 5,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const { gsap } = useGsap()
|
|
56
|
+
const { velocity: scrollVelocity, direction: scrollDirection } = useSmoothScroll()
|
|
57
|
+
|
|
58
|
+
const containerRef = ref<HTMLElement | null>(null)
|
|
59
|
+
const contentRef = ref<HTMLElement | null>(null)
|
|
60
|
+
const tweenRef = ref<gsap.core.Tween | null>(null)
|
|
61
|
+
|
|
62
|
+
const isPaused = ref(false)
|
|
63
|
+
const currentTimeScale = ref(1)
|
|
64
|
+
|
|
65
|
+
onMounted(() => {
|
|
66
|
+
if (!containerRef.value || !contentRef.value) return
|
|
67
|
+
|
|
68
|
+
const content = contentRef.value
|
|
69
|
+
const contentWidth = content.offsetWidth
|
|
70
|
+
|
|
71
|
+
// Set up the infinite scroll animation
|
|
72
|
+
tweenRef.value = gsap.to(content, {
|
|
73
|
+
x: props.direction === 'left' ? -contentWidth / 2 : contentWidth / 2,
|
|
74
|
+
duration: contentWidth / props.speed,
|
|
75
|
+
ease: 'none',
|
|
76
|
+
repeat: -1,
|
|
77
|
+
modifiers: {
|
|
78
|
+
x: gsap.utils.unitize((x) => {
|
|
79
|
+
const mod = contentWidth / 2
|
|
80
|
+
return props.direction === 'left' ? parseFloat(x) % mod : Math.abs(parseFloat(x) % mod)
|
|
81
|
+
}),
|
|
82
|
+
},
|
|
83
|
+
})
|
|
83
84
|
})
|
|
84
|
-
})
|
|
85
|
-
|
|
86
|
-
// Watch velocity changes for velocity-based animation
|
|
87
|
-
watch(
|
|
88
|
-
[scrollVelocity, scrollDirection],
|
|
89
|
-
([vel, dir]) => {
|
|
90
|
-
if (!props.velocityBased || !tweenRef.value || isPaused.value) return
|
|
91
|
-
|
|
92
|
-
// Calculate velocity multiplier
|
|
93
|
-
const absVelocity = Math.abs(vel)
|
|
94
|
-
const velocityEffect = absVelocity * props.velocitySensitivity * 0.02
|
|
95
85
|
|
|
96
|
-
|
|
97
|
-
|
|
86
|
+
// Watch velocity changes for velocity-based animation
|
|
87
|
+
watch(
|
|
88
|
+
[scrollVelocity, scrollDirection],
|
|
89
|
+
([vel, dir]) => {
|
|
90
|
+
if (!props.velocityBased || !tweenRef.value || isPaused.value) return
|
|
91
|
+
|
|
92
|
+
// Calculate velocity multiplier
|
|
93
|
+
const absVelocity = Math.abs(vel)
|
|
94
|
+
const velocityEffect = absVelocity * props.velocitySensitivity * 0.02
|
|
95
|
+
|
|
96
|
+
// Base multiplier from velocity magnitude
|
|
97
|
+
let multiplier = 1 + velocityEffect
|
|
98
|
+
|
|
99
|
+
// Clamp to min/max
|
|
100
|
+
multiplier = Math.max(props.minSpeed, Math.min(props.maxSpeed, multiplier))
|
|
101
|
+
|
|
102
|
+
// Optionally reverse direction based on scroll direction
|
|
103
|
+
if (props.velocityDirection && dir !== 0) {
|
|
104
|
+
const baseDirection = props.direction === 'left' ? 1 : -1
|
|
105
|
+
multiplier *= dir * baseDirection
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Smoothly interpolate to target
|
|
109
|
+
gsap.to(currentTimeScale, {
|
|
110
|
+
value: multiplier,
|
|
111
|
+
duration: 0.3,
|
|
112
|
+
ease: 'power2.out',
|
|
113
|
+
onUpdate: () => {
|
|
114
|
+
if (tweenRef.value) {
|
|
115
|
+
tweenRef.value.timeScale(currentTimeScale.value)
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
{ immediate: false }
|
|
121
|
+
)
|
|
98
122
|
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
onUnmounted(() => {
|
|
124
|
+
tweenRef.value?.kill()
|
|
125
|
+
})
|
|
101
126
|
|
|
102
|
-
|
|
103
|
-
if (props.
|
|
104
|
-
|
|
105
|
-
|
|
127
|
+
function handleMouseEnter() {
|
|
128
|
+
if (props.pauseOnHover && tweenRef.value) {
|
|
129
|
+
gsap.to(tweenRef.value, { timeScale: 0, duration: 0.5 })
|
|
130
|
+
isPaused.value = true
|
|
106
131
|
}
|
|
107
|
-
|
|
108
|
-
// Smoothly interpolate to target
|
|
109
|
-
gsap.to(currentTimeScale, {
|
|
110
|
-
value: multiplier,
|
|
111
|
-
duration: 0.3,
|
|
112
|
-
ease: 'power2.out',
|
|
113
|
-
onUpdate: () => {
|
|
114
|
-
if (tweenRef.value) {
|
|
115
|
-
tweenRef.value.timeScale(currentTimeScale.value)
|
|
116
|
-
}
|
|
117
|
-
},
|
|
118
|
-
})
|
|
119
|
-
},
|
|
120
|
-
{ immediate: false }
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
onUnmounted(() => {
|
|
124
|
-
tweenRef.value?.kill()
|
|
125
|
-
})
|
|
126
|
-
|
|
127
|
-
function handleMouseEnter() {
|
|
128
|
-
if (props.pauseOnHover && tweenRef.value) {
|
|
129
|
-
gsap.to(tweenRef.value, { timeScale: 0, duration: 0.5 })
|
|
130
|
-
isPaused.value = true
|
|
131
132
|
}
|
|
132
|
-
}
|
|
133
133
|
|
|
134
|
-
function handleMouseLeave() {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
function handleMouseLeave() {
|
|
135
|
+
if (props.pauseOnHover && tweenRef.value) {
|
|
136
|
+
gsap.to(tweenRef.value, { timeScale: currentTimeScale.value, duration: 0.5 })
|
|
137
|
+
isPaused.value = false
|
|
138
|
+
}
|
|
138
139
|
}
|
|
139
|
-
}
|
|
140
140
|
</script>
|
|
141
141
|
|
|
142
142
|
<template>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ScrollTrigger as GSAPScrollTrigger } from 'gsap/ScrollTrigger'
|
|
3
|
+
|
|
4
|
+
interface VelocityMapping {
|
|
5
|
+
input: [number, number]
|
|
6
|
+
output: [number, number]
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
texts = [],
|
|
11
|
+
velocity = 100,
|
|
12
|
+
className = '',
|
|
13
|
+
damping = 50,
|
|
14
|
+
stiffness = 400,
|
|
15
|
+
velocityMapping = { input: [0, 1000] as [number, number], output: [0, 5] as [number, number] },
|
|
16
|
+
pauseOnHover = false,
|
|
17
|
+
parallaxClassName = '',
|
|
18
|
+
scrollerClassName = '',
|
|
19
|
+
parallaxStyle = {},
|
|
20
|
+
scrollerStyle = {},
|
|
21
|
+
} = defineProps<{
|
|
22
|
+
texts?: string[]
|
|
23
|
+
velocity?: number
|
|
24
|
+
className?: string
|
|
25
|
+
damping?: number
|
|
26
|
+
stiffness?: number
|
|
27
|
+
velocityMapping?: VelocityMapping
|
|
28
|
+
pauseOnHover?: boolean
|
|
29
|
+
parallaxClassName?: string
|
|
30
|
+
scrollerClassName?: string
|
|
31
|
+
parallaxStyle?: Record<string, string | number>
|
|
32
|
+
scrollerStyle?: Record<string, string | number>
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const { gsap, ScrollTrigger } = useGsap()
|
|
36
|
+
const { velocityFactor } = useMarqueeVelocity({ damping, stiffness, velocityMapping })
|
|
37
|
+
|
|
38
|
+
const containerRef = ref<HTMLElement[]>([])
|
|
39
|
+
const copyRefs = ref<HTMLSpanElement[]>([])
|
|
40
|
+
|
|
41
|
+
const { copyWidths, calculatedCopies } = useMarqueeCopies(
|
|
42
|
+
containerRef,
|
|
43
|
+
copyRefs,
|
|
44
|
+
computed(() => texts.length)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const baseX = ref<number[]>([])
|
|
48
|
+
const directionFactors = ref<number[]>([])
|
|
49
|
+
const isPaused = ref(false)
|
|
50
|
+
|
|
51
|
+
let scrollTriggerInstance: GSAPScrollTrigger | null = null
|
|
52
|
+
let animLastTime = 0
|
|
53
|
+
|
|
54
|
+
const wrap = (min: number, max: number, v: number): number => {
|
|
55
|
+
const range = max - min
|
|
56
|
+
if (range === 0) return min
|
|
57
|
+
return ((((v - min) % range) + range) % range) + min
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const scrollTransforms = computed(() =>
|
|
61
|
+
texts.map((_, index) => {
|
|
62
|
+
const singleWidth = copyWidths.value[index]
|
|
63
|
+
if (!singleWidth) return '0px'
|
|
64
|
+
return `${wrap(-singleWidth, 0, baseX.value[index] ?? 0)}px`
|
|
65
|
+
})
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const setCopyRef = (el: Element | ComponentPublicInstance | null, index: number) => {
|
|
69
|
+
if (el instanceof HTMLSpanElement) copyRefs.value[index] = el
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function animate() {
|
|
73
|
+
if (isPaused.value) return
|
|
74
|
+
|
|
75
|
+
const now = performance.now()
|
|
76
|
+
const delta = animLastTime ? now - animLastTime : 16
|
|
77
|
+
animLastTime = now
|
|
78
|
+
|
|
79
|
+
texts.forEach((_, index) => {
|
|
80
|
+
const baseVelocity = index % 2 !== 0 ? -velocity : velocity
|
|
81
|
+
let moveBy = (directionFactors.value[index] ?? 1) * baseVelocity * (delta / 1000)
|
|
82
|
+
|
|
83
|
+
if (velocityFactor.value < 0) {
|
|
84
|
+
directionFactors.value[index] = -1
|
|
85
|
+
} else if (velocityFactor.value > 0) {
|
|
86
|
+
directionFactors.value[index] = 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
moveBy += (directionFactors.value[index] ?? 1) * moveBy * velocityFactor.value
|
|
90
|
+
baseX.value[index] = (baseX.value[index] ?? 0) + moveBy
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
onMounted(async () => {
|
|
95
|
+
await nextTick()
|
|
96
|
+
|
|
97
|
+
baseX.value = new Array(texts.length).fill(0)
|
|
98
|
+
directionFactors.value = new Array(texts.length).fill(1)
|
|
99
|
+
animLastTime = performance.now()
|
|
100
|
+
|
|
101
|
+
const firstContainer = containerRef.value[0]
|
|
102
|
+
if (firstContainer) {
|
|
103
|
+
scrollTriggerInstance = ScrollTrigger.create({
|
|
104
|
+
trigger: firstContainer,
|
|
105
|
+
start: 'top bottom',
|
|
106
|
+
end: 'bottom top',
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
gsap.ticker.add(animate)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
onUnmounted(() => {
|
|
114
|
+
gsap.ticker.remove(animate)
|
|
115
|
+
scrollTriggerInstance?.kill()
|
|
116
|
+
})
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<template>
|
|
120
|
+
<section
|
|
121
|
+
class="w-full overflow-x-hidden"
|
|
122
|
+
@mouseenter="isPaused = pauseOnHover"
|
|
123
|
+
@mouseleave="isPaused = false"
|
|
124
|
+
>
|
|
125
|
+
<div
|
|
126
|
+
v-for="(text, index) in texts"
|
|
127
|
+
:key="index"
|
|
128
|
+
ref="containerRef"
|
|
129
|
+
:class="`${parallaxClassName} relative w-full overflow-x-hidden overflow-y-visible`"
|
|
130
|
+
:style="parallaxStyle"
|
|
131
|
+
>
|
|
132
|
+
<div
|
|
133
|
+
:class="`${scrollerClassName} font-styles-logo flex py-2 text-center font-sans text-4xl font-medium tracking-[-0.02em] whitespace-nowrap text-neutral drop-shadow md:text-[5rem] md:leading-[5rem]`"
|
|
134
|
+
:style="{ transform: `translateX(${scrollTransforms[index] ?? '0px'})`, ...scrollerStyle }"
|
|
135
|
+
>
|
|
136
|
+
<span
|
|
137
|
+
v-for="spanIndex in calculatedCopies[index] ?? 15"
|
|
138
|
+
:key="spanIndex"
|
|
139
|
+
:ref="spanIndex === 1 ? (el) => setCopyRef(el, index) : undefined"
|
|
140
|
+
:class="`shrink-0 ${className} text-primary-500`"
|
|
141
|
+
>
|
|
142
|
+
{{ text }}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</section>
|
|
147
|
+
</template>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { MaybeRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export function useMarqueeCopies(
|
|
4
|
+
containerRefs: Ref<HTMLElement[]>,
|
|
5
|
+
copyRefs: Ref<HTMLSpanElement[]>,
|
|
6
|
+
rowCount: MaybeRef<number>,
|
|
7
|
+
) {
|
|
8
|
+
const copyWidths = ref<number[]>([])
|
|
9
|
+
const calculatedCopies = ref<number[]>([])
|
|
10
|
+
|
|
11
|
+
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
12
|
+
|
|
13
|
+
function calculate() {
|
|
14
|
+
const n = unref(rowCount)
|
|
15
|
+
for (let i = 0; i < n; i++) {
|
|
16
|
+
const copy = copyRefs.value[i]
|
|
17
|
+
const container = containerRefs.value[i]
|
|
18
|
+
if (!copy || !container) continue
|
|
19
|
+
const singleWidth = copy.offsetWidth
|
|
20
|
+
if (singleWidth === 0) continue
|
|
21
|
+
const effectiveWidth = Math.max(container.offsetWidth, window.innerWidth)
|
|
22
|
+
const minCopies = Math.ceil((effectiveWidth * 2.5) / singleWidth)
|
|
23
|
+
copyWidths.value[i] = singleWidth
|
|
24
|
+
calculatedCopies.value[i] = Math.max(minCopies, 8)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function debouncedCalculate() {
|
|
29
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
30
|
+
debounceTimer = setTimeout(() => {
|
|
31
|
+
calculate()
|
|
32
|
+
debounceTimer = null
|
|
33
|
+
}, 150)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onMounted(() => {
|
|
37
|
+
nextTick(() => {
|
|
38
|
+
calculate()
|
|
39
|
+
setTimeout(calculate, 100)
|
|
40
|
+
})
|
|
41
|
+
window.addEventListener('resize', debouncedCalculate, { passive: true })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
onUnmounted(() => {
|
|
45
|
+
if (debounceTimer) clearTimeout(debounceTimer)
|
|
46
|
+
window.removeEventListener('resize', debouncedCalculate)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return { copyWidths, calculatedCopies }
|
|
50
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { MaybeRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
interface VelocityMapping {
|
|
4
|
+
input: [number, number]
|
|
5
|
+
output: [number, number]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface UseMarqueeVelocityOptions {
|
|
9
|
+
damping?: MaybeRef<number>
|
|
10
|
+
stiffness?: MaybeRef<number>
|
|
11
|
+
velocityMapping?: MaybeRef<VelocityMapping>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useMarqueeVelocity(opts: UseMarqueeVelocityOptions = {}) {
|
|
15
|
+
const { velocity: rawVelocity } = useSmoothScroll()
|
|
16
|
+
const { gsap } = useGsap()
|
|
17
|
+
|
|
18
|
+
const smoothVelocity = ref(0)
|
|
19
|
+
const velocityFactor = ref(0)
|
|
20
|
+
|
|
21
|
+
function tick() {
|
|
22
|
+
const damping = (unref(opts.damping) ?? 50) / 1000
|
|
23
|
+
const stiffness = (unref(opts.stiffness) ?? 400) / 1000
|
|
24
|
+
const mapping: VelocityMapping = unref(opts.velocityMapping) ?? {
|
|
25
|
+
input: [0, 1000],
|
|
26
|
+
output: [0, 5],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
smoothVelocity.value += (rawVelocity.value - smoothVelocity.value) * stiffness
|
|
30
|
+
smoothVelocity.value *= 1 - damping
|
|
31
|
+
|
|
32
|
+
const inputRange = mapping.input[1] - mapping.input[0]
|
|
33
|
+
const outputRange = mapping.output[1] - mapping.output[0]
|
|
34
|
+
let t = (Math.abs(smoothVelocity.value) - mapping.input[0]) / inputRange
|
|
35
|
+
t = Math.max(0, Math.min(1, t))
|
|
36
|
+
velocityFactor.value = mapping.output[0] + t * outputRange
|
|
37
|
+
if (smoothVelocity.value < 0) velocityFactor.value *= -1
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
onMounted(() => gsap.ticker.add(tick))
|
|
41
|
+
onUnmounted(() => gsap.ticker.remove(tick))
|
|
42
|
+
|
|
43
|
+
return { velocityFactor }
|
|
44
|
+
}
|
package/package.json
CHANGED