Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions ui/vuetifyx/vuetifyxjs/src/lib/TiltedImages/VXTiltedImages.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<template>
<div
class="vx-tilted-images"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
ref="container"
:style="containerStyle"
>
<div class="vx-tilted-images__content" :style="contentStyle">
<slot></slot>
</div>
</div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

const props = defineProps({
initialRotateX: {
type: Number,
default: 0
},
initialRotateY: {
type: Number,
default: 0
},
initialTranslateX: {
type: Number,
default: 0
},
initialTranslateY: {
type: Number,
default: 0
}
})

const container = ref<HTMLElement | null>(null)
const rotateX = ref(props.initialRotateX)
const rotateY = ref(props.initialRotateY)
const translateX = ref(props.initialTranslateX)
const translateY = ref(props.initialTranslateY)

const containerStyle = {
perspective: '1000px',
overflow: 'visible',
padding: '50px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}

const contentStyle = computed(() => {
return {
position: 'relative' as const,
display: 'grid',
justifyItems: 'center',
alignItems: 'center',
transformOrigin: 'center center',
transformStyle: 'preserve-3d' as const,
transform: `translateX(${translateX.value}px) translateY(${translateY.value}px) rotateX(${rotateX.value}deg) rotateY(${rotateY.value}deg)`,
transition: 'transform 0.1s ease-out'
}
})

const handleMouseMove = (e: MouseEvent) => {
if (!container.value) return

const rect = container.value.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top

const centerX = rect.width / 2
const centerY = rect.height / 2

const percentX = (x - centerX) / centerX
const percentY = (y - centerY) / centerY

// Adjust rotation based on mouse position relative to center
// Max rotation change +/- 10 degrees
rotateY.value = props.initialRotateY + percentX * 10
rotateX.value = props.initialRotateX - percentY * 10 // Invert Y axis for natural feel
}

const handleMouseLeave = () => {
// Reset to initial values
rotateX.value = props.initialRotateX
rotateY.value = props.initialRotateY
translateX.value = props.initialTranslateX
translateY.value = props.initialTranslateY
}
</script>

<style lang="scss" scoped>
.vx-tilted-images {
width: 100%;
}

.vx-tilted-images__content {
:deep(*) {
grid-area: 1 / 1;
}

@for $i from 1 through 10 {
:deep(*:nth-child(#{$i})) {
transform: translateZ(#{$i * 20}px);
}
}
}
</style>
231 changes: 231 additions & 0 deletions ui/vuetifyx/vuetifyxjs/src/lib/Timeline/VXTimeline.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<template>
<div
ref="root"
class="vx-timeline-wrap"
:class="{
'vx-timeline-sinuous': sinuous,
'vx-timeline-animate': animateOnScroll,
'vx-timeline-parallax': parallax
}"
v-bind="rootAttrs"
>
<v-timeline v-bind="combinedProps">
<template v-if="!isDefaultSlotReallyEmpty" #default>
<slot />
</template>
</v-timeline>
</div>
</template>

<script setup lang="ts">
import {
defineEmits,
computed,
useSlots,
defineOptions,
onMounted,
onUnmounted,
ref,
nextTick
} from 'vue'
import { useFilteredAttrs } from '@/lib/composables/useFilteredAttrs'

const { filteredAttrs, rootAttrs } = useFilteredAttrs()
const slots = useSlots()
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
sinuous: Boolean,
animateOnScroll: Boolean,
parallax: Boolean
})

const root = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
let parallaxRafId: number | null = null
let parallaxScheduled = false
const animationTimeouts = new Map<Element, number>()

// Previously handleParallax re-armed itself with requestAnimationFrame on
// every frame, which meant 60 fps worth of querySelectorAll +
// getBoundingClientRect + inline style writes for the life of the page.
// Combined with any hover-triggered layout (Vuetify adjusting a card
// elevation, the pagebuilder editor's overlay, etc.) the browser would
// stall or crash on mouse over. Drive it off scroll instead: one rAF per
// scroll event, coalesced so fast scrolls don't queue work.
const handleParallax = () => {
parallaxRafId = null
parallaxScheduled = false
if (!root.value) return

const opposites = root.value.querySelectorAll('.v-timeline-item__opposite')
const bodies = root.value.querySelectorAll('.v-timeline-item__body')
const windowHeight = window.innerHeight
const center = windowHeight / 2

const applyParallax = (el: Element, factor: number) => {
// If animateOnScroll is enabled, only apply parallax if the element is visible
if (props.animateOnScroll && !el.classList.contains('is-visible')) {
return
}

const rect = el.getBoundingClientRect()
const elCenter = rect.top + rect.height / 2
const dist = elCenter - center
const offset = dist * factor
;(el as HTMLElement).style.transform = `translateY(${offset}px)`
}

opposites.forEach((el) => applyParallax(el, 0.1))
bodies.forEach((el) => applyParallax(el, -0.02))
}

const scheduleParallax = () => {
if (parallaxScheduled) return
parallaxScheduled = true
parallaxRafId = requestAnimationFrame(handleParallax)
}

onMounted(() => {
if (props.animateOnScroll && root.value) {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible')

const existingId = animationTimeouts.get(entry.target)
if (existingId) {
clearTimeout(existingId)
animationTimeouts.delete(entry.target)
}

// Disable transform transition after animation completes to allow crisp parallax
const id = window.setTimeout(() => {
entry.target.classList.add('animation-done')
animationTimeouts.delete(entry.target)
}, 600)
animationTimeouts.set(entry.target, id)
} else {
entry.target.classList.remove('is-visible')
entry.target.classList.remove('animation-done')

const existingId = animationTimeouts.get(entry.target)
if (existingId) {
clearTimeout(existingId)
animationTimeouts.delete(entry.target)
}
}
})
},
{
threshold: 0
}
)

// Wait for layout to settle
setTimeout(() => {
if (root.value) {
// Observe the body and opposite elements instead of the item itself,
// because v-timeline-item might be display: contents
const items = root.value.querySelectorAll(
'.v-timeline-item__body, .v-timeline-item__opposite'
)
items.forEach((item) => {
observer?.observe(item)
})
}
}, 1000)
}

if (props.parallax) {
// Initial paint + bind to scroll/resize. Passive listeners so we
// never block scrolling.
scheduleParallax()
window.addEventListener('scroll', scheduleParallax, { passive: true })
window.addEventListener('resize', scheduleParallax, { passive: true })
}
})

onUnmounted(() => {
observer?.disconnect()
if (parallaxRafId !== null) {
cancelAnimationFrame(parallaxRafId)
}
if (props.parallax) {
window.removeEventListener('scroll', scheduleParallax)
window.removeEventListener('resize', scheduleParallax)
}
animationTimeouts.forEach((id) => clearTimeout(id))
animationTimeouts.clear()
})

const defaultOptions = computed(() => {
return {
// Default options if any
}
})

const isDefaultSlotReallyEmpty = computed(() => {
/* @ts-ignore */
return !slots.default || !slots.default().length
})

// bugfix: bind event will auto bind to rootElement, and result in trigger twice
defineOptions({
inheritAttrs: false
})

const combinedProps = computed(() => ({
...defaultOptions.value,
...filteredAttrs.value // passthrough the props that defined by vuetify
}))
</script>

<style lang="scss" scoped>
.vx-timeline-wrap {
&.vx-timeline-animate {
:deep(.v-timeline-item__body),
:deep(.v-timeline-item__opposite) {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.6s ease-out,
transform 0.6s ease-out;

&.is-visible {
opacity: 1;
transform: translateY(0);
}

&.animation-done {
transition: opacity 0.6s ease-out !important;
}
}
}

&.vx-timeline-parallax {
:deep(.v-timeline-item__opposite) {
align-self: center;
}
}

&.vx-timeline-sinuous {
:deep(.v-timeline-divider__before),
:deep(.v-timeline-divider__after) {
background-color: transparent !important;
width: 20px;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='100' viewBox='0 0 12 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 0 Q 12 25 6 50 T 6 100' fill='none' stroke='%23ccc' stroke-width='2'/%3E%3C/svg%3E");
background-repeat: repeat-y;
background-size: 12px 100px;
}

:deep(.v-timeline-divider__before) {
background-position: bottom center;
}

:deep(.v-timeline-divider__after) {
background-position: top center;
}
}
}
</style>
43 changes: 43 additions & 0 deletions ui/vuetifyx/vuetifyxjs/src/lib/VXImageGallery.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<template>
<v-sheet class="mx-auto" elevation="0" max-width="100%">
<v-slide-group v-model="model" class="pa-4" show-arrows>
<v-slide-group-item v-for="(item, i) in items" :key="i" v-slot="{ isSelected, toggle }">
<v-card
:color="isSelected ? 'primary' : 'grey-lighten-1'"
class="ma-4"
:height="height"
:width="width || 300"
@click="toggle"
>
<div class="d-flex fill-height align-center justify-center">
<v-img :src="item.src" :lazy-src="item.lazySrc" cover height="100%" width="100%">
<template v-slot:placeholder>
<div class="d-flex align-center justify-center fill-height">
<v-progress-circular color="grey-lighten-4" indeterminate></v-progress-circular>
</div>
</template>
</v-img>
</div>
</v-card>
</v-slide-group-item>
</v-slide-group>
</v-sheet>
</template>

<script setup lang="ts">
import { ref } from 'vue'

interface GalleryItem {
src: string
lazySrc?: string
title?: string
}

const props = defineProps<{
items: GalleryItem[]
height?: number | string
width?: number | string
}>()

const model = ref(null)
</script>
Loading