Skip to content

Commit 40e81c7

Browse files
authored
Merge pull request #2 from zephrynis/master
Add custom marquee component and update usage
2 parents a40fdda + 4dd83b3 commit 40e81c7

File tree

3 files changed

+307
-6
lines changed

3 files changed

+307
-6
lines changed
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
<template>
2+
<div
3+
ref="marqueeContainer"
4+
class="relative overflow-hidden w-full"
5+
@mouseenter="handleMouseEnter"
6+
@mouseleave="handleMouseLeave"
7+
@mousedown="handleMouseDown"
8+
@touchstart="handleTouchStart"
9+
@click.capture="handleClick"
10+
>
11+
<div
12+
ref="marqueeTrack"
13+
class="flex w-max"
14+
:style="{
15+
transform: `translateX(${translateX}px)`,
16+
cursor: isDragging ? 'grabbing' : 'grab',
17+
touchAction: 'pan-y' // Allow vertical scrolling but prevent horizontal on touch
18+
}"
19+
>
20+
<!-- First set of items -->
21+
<div class="flex">
22+
<slot />
23+
</div>
24+
<!-- Duplicate set for seamless looping -->
25+
<div class="flex">
26+
<slot />
27+
</div>
28+
</div>
29+
</div>
30+
</template>
31+
32+
<script setup lang="ts">
33+
import { ref, onMounted, onUnmounted, computed } from 'vue'
34+
35+
interface Props {
36+
direction?: 'left' | 'right'
37+
speed?: number // pixels per second
38+
pauseOnHover?: boolean
39+
}
40+
41+
const props = withDefaults(defineProps<Props>(), {
42+
direction: 'left',
43+
speed: 20, // default 20 pixels per second
44+
pauseOnHover: true
45+
})
46+
47+
const marqueeContainer = ref<HTMLElement | null>(null)
48+
const marqueeTrack = ref<HTMLElement | null>(null)
49+
50+
const translateX = ref(0)
51+
const isHovered = ref(false)
52+
const isDragging = ref(false)
53+
54+
// Convert pixels per second to pixels per frame (assuming 60fps)
55+
const speedPerFrame = computed(() => {
56+
const pixelsPerFrame = props.speed / 60
57+
return props.direction === 'left' ? pixelsPerFrame : -pixelsPerFrame
58+
})
59+
60+
let animationFrameId: number | null = null
61+
let contentWidth = 0
62+
63+
// Drag state
64+
const dragStartX = ref(0)
65+
const dragStartTranslateX = ref(0)
66+
67+
// Momentum/flick state
68+
const velocity = ref(0)
69+
const deceleration = 0.95 // friction factor (0.95 = 5% slowdown per frame)
70+
const minVelocity = 0.1 // stop when velocity is below this threshold
71+
72+
// Track mouse positions for velocity calculation
73+
let lastMouseX = 0
74+
let lastMouseTime = 0
75+
let hasDragged = false
76+
77+
const normalizePosition = () => {
78+
if (contentWidth === 0) return
79+
80+
// Keep position within bounds (0 to -contentWidth)
81+
// This allows seamless looping in both directions
82+
if (translateX.value > 0) {
83+
translateX.value = translateX.value - contentWidth
84+
} else if (translateX.value <= -contentWidth) {
85+
translateX.value = translateX.value + contentWidth
86+
}
87+
}
88+
89+
const handleMouseEnter = () => {
90+
isHovered.value = true
91+
}
92+
93+
const handleMouseLeave = () => {
94+
isHovered.value = false
95+
}
96+
97+
const handleClick = (e: MouseEvent) => {
98+
// Prevent clicks on links if user was dragging
99+
if (hasDragged) {
100+
e.preventDefault()
101+
e.stopPropagation()
102+
}
103+
}
104+
105+
const handleMouseDown = (e: MouseEvent) => {
106+
isDragging.value = true
107+
dragStartX.value = e.clientX
108+
dragStartTranslateX.value = translateX.value
109+
hasDragged = false
110+
111+
// Reset velocity when starting a new drag
112+
velocity.value = 0
113+
114+
// Initialize velocity tracking
115+
lastMouseX = e.clientX
116+
lastMouseTime = Date.now()
117+
118+
document.addEventListener('mousemove', handleMouseMove)
119+
document.addEventListener('mouseup', handleMouseUp)
120+
121+
// Prevent text selection and link behavior
122+
e.preventDefault()
123+
}
124+
125+
const handleMouseMove = (e: MouseEvent) => {
126+
if (!isDragging.value) return
127+
128+
const deltaX = e.clientX - dragStartX.value
129+
translateX.value = dragStartTranslateX.value + deltaX
130+
131+
// Track if user has actually dragged (moved more than 5px)
132+
if (Math.abs(deltaX) > 5) {
133+
hasDragged = true
134+
}
135+
136+
// Calculate velocity for flick/momentum
137+
const currentTime = Date.now()
138+
const timeDelta = currentTime - lastMouseTime
139+
140+
if (timeDelta > 0) {
141+
const moveDelta = e.clientX - lastMouseX
142+
velocity.value = moveDelta / timeDelta * 16 // normalize to ~60fps
143+
}
144+
145+
lastMouseX = e.clientX
146+
lastMouseTime = currentTime
147+
148+
// Normalize position to keep within bounds for seamless looping
149+
normalizePosition()
150+
}
151+
152+
const handleMouseUp = () => {
153+
isDragging.value = false
154+
normalizePosition()
155+
document.removeEventListener('mousemove', handleMouseMove)
156+
document.removeEventListener('mouseup', handleMouseUp)
157+
}
158+
159+
// Touch event handlers for mobile
160+
const handleTouchStart = (e: TouchEvent) => {
161+
if (e.touches.length !== 1) return
162+
163+
isDragging.value = true
164+
const touch = e.touches[0]
165+
dragStartX.value = touch.clientX
166+
dragStartTranslateX.value = translateX.value
167+
hasDragged = false
168+
169+
// Reset velocity when starting a new drag
170+
velocity.value = 0
171+
172+
// Initialize velocity tracking
173+
lastMouseX = touch.clientX
174+
lastMouseTime = Date.now()
175+
176+
document.addEventListener('touchmove', handleTouchMove, { passive: false })
177+
document.addEventListener('touchend', handleTouchEnd)
178+
document.addEventListener('touchcancel', handleTouchEnd)
179+
}
180+
181+
const handleTouchMove = (e: TouchEvent) => {
182+
if (!isDragging.value || e.touches.length !== 1) return
183+
184+
const touch = e.touches[0]
185+
const deltaX = touch.clientX - dragStartX.value
186+
translateX.value = dragStartTranslateX.value + deltaX
187+
188+
// Track if user has actually dragged (moved more than 5px)
189+
if (Math.abs(deltaX) > 5) {
190+
hasDragged = true
191+
// Prevent scrolling when dragging
192+
e.preventDefault()
193+
}
194+
195+
// Calculate velocity for flick/momentum
196+
const currentTime = Date.now()
197+
const timeDelta = currentTime - lastMouseTime
198+
199+
if (timeDelta > 0) {
200+
const moveDelta = touch.clientX - lastMouseX
201+
velocity.value = moveDelta / timeDelta * 16 // normalize to ~60fps
202+
}
203+
204+
lastMouseX = touch.clientX
205+
lastMouseTime = currentTime
206+
207+
// Normalize position to keep within bounds for seamless looping
208+
normalizePosition()
209+
}
210+
211+
const handleTouchEnd = () => {
212+
isDragging.value = false
213+
normalizePosition()
214+
document.removeEventListener('touchmove', handleTouchMove)
215+
document.removeEventListener('touchend', handleTouchEnd)
216+
document.removeEventListener('touchcancel', handleTouchEnd)
217+
}
218+
219+
const animate = () => {
220+
if (!isDragging.value) {
221+
// Apply momentum if there's any velocity from flicking (even when hovering)
222+
if (Math.abs(velocity.value) > minVelocity) {
223+
translateX.value += velocity.value
224+
velocity.value *= deceleration // apply friction
225+
normalizePosition()
226+
} else if (!isHovered.value || !props.pauseOnHover) {
227+
// Default auto-scroll when no momentum and either:
228+
// - not hovering, OR
229+
// - pauseOnHover is disabled
230+
velocity.value = 0
231+
translateX.value -= speedPerFrame.value
232+
normalizePosition()
233+
}
234+
}
235+
236+
animationFrameId = requestAnimationFrame(animate)
237+
}
238+
239+
onMounted(() => {
240+
// Calculate the width of the content for seamless looping
241+
if (marqueeTrack.value) {
242+
const firstChild = marqueeTrack.value.firstElementChild as HTMLElement
243+
if (firstChild) {
244+
contentWidth = firstChild.offsetWidth
245+
}
246+
}
247+
248+
// Start animation
249+
animate()
250+
})
251+
252+
onUnmounted(() => {
253+
if (animationFrameId) {
254+
cancelAnimationFrame(animationFrameId)
255+
}
256+
// Clean up mouse event listeners
257+
document.removeEventListener('mousemove', handleMouseMove)
258+
document.removeEventListener('mouseup', handleMouseUp)
259+
// Clean up touch event listeners
260+
document.removeEventListener('touchmove', handleTouchMove)
261+
document.removeEventListener('touchend', handleTouchEnd)
262+
document.removeEventListener('touchcancel', handleTouchEnd)
263+
})
264+
</script>
265+
266+
<style scoped>
267+
/* Prevent text selection while dragging */
268+
.flex {
269+
user-select: none;
270+
-webkit-user-select: none;
271+
-moz-user-select: none;
272+
-ms-user-select: none;
273+
}
274+
</style>

apps/frontend/src/components/ui/marketing/Marquee.vue

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<div :class="props?.class" class="marquee h-32">
3-
<NuxtMarquee :autoFill="true" :pauseOnHover="true" :speed="35">
3+
<!-- <NuxtMarquee :autoFill="true" :pauseOnHover="true" :speed="35">
44
<div
55
v-for="(item, index) in items"
66
:key="`item-${index}`"
@@ -30,7 +30,34 @@
3030
</div>
3131
</NuxtLink>
3232
</div>
33-
</NuxtMarquee>
33+
</NuxtMarquee> -->
34+
<ElementsMarquee :direction="'left'" :speed="35" :pause-on-hover="true">
35+
<div
36+
v-for="(item, index) in items"
37+
:key="`item-${index}`"
38+
class="mx-4 inline h-[128px] w-[256px]"
39+
>
40+
<NuxtLink :to="`/browse/${item.identifier}`" tabindex="-1">
41+
<div class="overflow-hidden rounded-2xl transition-transform hover:scale-95">
42+
<div class="absolute h-[128px] w-[256px] bg-neutral-950/50 opacity-0 transition-all hover:opacity-100">
43+
<Icon
44+
name="memory:arrow-up-right-box"
45+
:size="48"
46+
mode="svg"
47+
class="top-6/12 left-6/12 -translate-3/6 absolute"
48+
/>
49+
</div>
50+
<NuxtImg
51+
:src="`https://s3.blueprint.zip/static/${item.identifier}.jpeg`"
52+
:height="128"
53+
:width="256"
54+
:alt="item.name"
55+
class="aspect-[2/1] bg-neutral-800 object-cover"
56+
/>
57+
</div>
58+
</NuxtLink>
59+
</div>
60+
</ElementsMarquee>
3461
</div>
3562
</template>
3663

apps/frontend/src/components/ui/marketing/Testimonials.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<template>
22
<div :class="props?.class" class="rounded-3xl border border-neutral-700 p-4">
33
<div class="marquee space-y-4">
4-
<NuxtMarquee
4+
<ElementsMarquee
55
v-for="(testimonials, index) in allTestimonials"
6-
:autoFill="true"
7-
:speed="25"
6+
:speed="12"
87
:direction="index % 2 == 0 ? 'left' : 'right'"
8+
:pauseOnHover="false"
99
tabindex="-1"
1010
>
1111
<div
@@ -50,7 +50,7 @@
5050
/>
5151
</div>
5252
</div>
53-
</NuxtMarquee>
53+
</ElementsMarquee>
5454
</div>
5555
</div>
5656
</template>

0 commit comments

Comments
 (0)