line-and-surface/src/components/VideoScroll.vue
2025-07-28 17:42:21 +02:00

244 lines
6.3 KiB
Vue

<template>
<div class="video-scroll" ref="root" v-if="definition.directions.length > 0">
<template
v-for="(file, idx) in dynamicFiles"
:key="`image_${idx}_${file.src}`"
>
<img
v-if="file.blurhash"
:src="file.blurhash"
:data-index="idx"
class="placeholder loaded"
:style="{
top: `${Math.round(file.top)}px`,
left: `${Math.round(file.left)}px`,
width: `${Math.round(definition.width)}px`,
height: `${Math.round(definition.height)}px`,
transform: `rotate(${definition.angle}deg)`,
}"
/>
<img
:data-src="file.src"
:data-index="idx"
:style="{
top: `${Math.round(file.top)}px`,
left: `${Math.round(file.left)}px`,
width: `${Math.round(definition.width)}px`,
height: `${Math.round(definition.height)}px`,
transform: `rotate(${definition.angle}deg)`,
}"
/>
</template>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from "vue";
import { rotate } from "@/utils";
import { queueImageForLoading } from "@/services/AssetLoader";
export default defineComponent({
name: "VideoScroll",
props: {
definition: {
type: Object as PropType<VideoScrollDef>,
required: true,
},
},
computed: {
dynamicFiles(): {
top: number;
left: number;
src: string;
blurhash?: string;
}[] {
return this.definition.files.map((file: FileDef, idx: number) => {
const cy =
this.definition.top +
(this.isVertical
? this.definition.height * (idx + 1) * this.verticalDirection
: 0);
const cx =
this.definition.left +
(this.isHorizontal
? this.definition.width * (idx + 1) * this.horizontalDirection
: 0);
const [left, top] = rotate(
cx,
cy,
this.definition.left,
this.definition.top,
this.definition.angle
);
return {
top,
left,
src: file.src,
blurhash: file.blurhash,
};
});
},
isHorizontal(): boolean {
return this.definition.directions.some(
(dir: VideoScrollDirection) =>
dir === VideoScrollDirection.LEFT ||
dir === VideoScrollDirection.RIGHT
);
},
isVertical(): boolean {
return this.definition.directions.some(
(dir: VideoScrollDirection) =>
dir === VideoScrollDirection.UP || dir === VideoScrollDirection.DOWN
);
},
horizontalDirection(): number {
return this.definition.directions.includes(VideoScrollDirection.RIGHT)
? 1
: -1;
},
verticalDirection(): number {
return this.definition.directions.includes(VideoScrollDirection.DOWN)
? 1
: -1;
},
},
methods: {
handleImageLoad(element: HTMLImageElement) {
// Setup image display when loaded
element.classList.add("displayed");
element.classList.add("loaded");
// Adjust dimensions based on scroll direction
if (this.isHorizontal) {
element.style.height = "auto";
} else {
element.style.width = "auto";
}
},
},
mounted() {
// Create separate observers for images and canvases
const imageObserver = new IntersectionObserver((entries, _) => {
entries.forEach((entry) => {
const element = entry.target as HTMLImageElement;
if (entry.isIntersecting) {
element.classList.add("visible");
if (!element.src && element.dataset.src) {
// Queue the image for loading through the global service
// Calculate the image's position to prioritize closer images
queueImageForLoading(
element,
() => {
return Math.hypot(
element.getBoundingClientRect().left - window.innerWidth / 2,
element.getBoundingClientRect().top - window.innerHeight / 2
);
},
() => {
this.handleImageLoad(element);
}
);
}
} else {
element.classList.remove("visible");
}
});
});
// Observer for placeholder images
const placeholderObserver = new IntersectionObserver((entries, _) => {
entries.forEach((entry) => {
const placeholder = entry.target as HTMLImageElement;
if (entry.isIntersecting) {
// Make the placeholder visible when it enters viewport
placeholder.classList.add("visible");
placeholder.classList.add("displayed");
} else {
placeholder.classList.remove("visible");
}
});
});
if (this.$refs.root) {
const root = this.$refs.root as Element;
// Observe all images
Array.from(root.querySelectorAll("img")).forEach((el) => {
imageObserver.observe(el);
});
// Observe all placeholder images
Array.from(root.querySelectorAll("img.placeholder")).forEach((el) => {
placeholderObserver.observe(el);
});
}
},
});
export enum VideoScrollDirection {
RIGHT = "right",
LEFT = "left",
UP = "up",
DOWN = "down",
}
export interface FileDef {
src: string;
blurhash?: string;
}
export interface VideoScrollDef {
id: string;
top: number;
left: number;
angle: number;
width: number;
height: number;
directions: VideoScrollDirection[];
files: FileDef[];
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style>
/* Common styles for positioned elements */
.video-scroll img {
position: absolute;
image-rendering: optimizeSpeed;
visibility: hidden;
opacity: 0;
transition: opacity 0.5s;
}
.video-scroll img.placeholder {
image-rendering: optimizeQuality;
}
/* Image specific styles */
.video-scroll img {
z-index: 2;
}
/* Placeholder image styles */
.video-scroll img.placeholder {
z-index: 1; /* Position below the actual image */
}
.video-scroll img.visible {
visibility: visible !important;
}
.video-scroll img.displayed {
opacity: 1 !important;
}
.video-scroll img.loaded {
background: transparent !important;
}
.video-scroll img.placeholder.hidden {
opacity: 0 !important;
transition: opacity 0.3s;
}
</style>