Import source: js/main
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
+544
@@ -0,0 +1,544 @@
|
||||
/* ============================================================
|
||||
OTIS VANDERMEER — main.js
|
||||
Three.js ember-field hero + GSAP/Lenis choreography
|
||||
============================================================ */
|
||||
import * as THREE from "three";
|
||||
|
||||
document.documentElement.classList.add("js");
|
||||
|
||||
const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const isFinePointer = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Boot: wait for fonts + DOM so SplitLines measures correctly
|
||||
------------------------------------------------------------ */
|
||||
const ready = Promise.all([
|
||||
new Promise((res) => {
|
||||
if (document.readyState !== "loading") res();
|
||||
else document.addEventListener("DOMContentLoaded", res, { once: true });
|
||||
}),
|
||||
document.fonts ? document.fonts.ready : Promise.resolve(),
|
||||
]);
|
||||
|
||||
ready.then(init);
|
||||
|
||||
function init() {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const lenis = initSmoothScroll();
|
||||
initClock();
|
||||
initWebGL();
|
||||
splitAll();
|
||||
initPreloader();
|
||||
initHeroIntro();
|
||||
initScrollReveals();
|
||||
initWorkList();
|
||||
initMarquee();
|
||||
initCounters();
|
||||
initCursor();
|
||||
initMagnetic(lenis);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Smooth scroll (Lenis) driven by GSAP ticker
|
||||
------------------------------------------------------------ */
|
||||
function initSmoothScroll() {
|
||||
if (prefersReduced || typeof Lenis === "undefined") return null;
|
||||
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
|
||||
lenis.on("scroll", ScrollTrigger.update);
|
||||
gsap.ticker.add((t) => lenis.raf(t * 1000));
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
// Anchor links go through Lenis
|
||||
document.querySelectorAll('a[href^="#"]').forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
const target = document.querySelector(a.getAttribute("href"));
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
lenis.scrollTo(target, { offset: 0, duration: 1.4 });
|
||||
});
|
||||
});
|
||||
return lenis;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Rotterdam clock
|
||||
------------------------------------------------------------ */
|
||||
function initClock() {
|
||||
const el = document.querySelector("[data-clock]");
|
||||
if (!el) return;
|
||||
const fmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Europe/Amsterdam",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
const tick = () => (el.textContent = fmt.format(new Date()));
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Three.js — "ember field" shader plane
|
||||
------------------------------------------------------------ */
|
||||
function initWebGL() {
|
||||
const canvas = document.querySelector("[data-webgl]");
|
||||
if (!canvas) return;
|
||||
|
||||
let renderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false });
|
||||
} catch {
|
||||
canvas.remove(); // graceful: CSS veil still gives a dark hero
|
||||
return;
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia("(max-width: 760px)").matches;
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.25 : 1.75));
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const uniforms = {
|
||||
uTime: { value: 0 },
|
||||
uRes: { value: new THREE.Vector2(1, 1) },
|
||||
uMouse: { value: new THREE.Vector2(0.5, 0.5) },
|
||||
uScroll: { value: 0 },
|
||||
};
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader: /* glsl */ `
|
||||
void main() { gl_Position = vec4(position, 1.0); }
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
precision highp float;
|
||||
uniform float uTime;
|
||||
uniform vec2 uRes;
|
||||
uniform vec2 uMouse;
|
||||
uniform float uScroll;
|
||||
|
||||
// --- simplex-ish value noise + fbm ---
|
||||
vec2 hash(vec2 p) {
|
||||
p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
|
||||
return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
|
||||
}
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
vec2 u = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(dot(hash(i), f), dot(hash(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
|
||||
mix(dot(hash(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
|
||||
dot(hash(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x),
|
||||
u.y);
|
||||
}
|
||||
float fbm(vec2 p) {
|
||||
float v = 0.0, a = 0.55;
|
||||
for (int i = 0; i < 5; i++) {
|
||||
v += a * noise(p);
|
||||
p = p * 2.05 + vec2(11.3, 7.7);
|
||||
a *= 0.5;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / uRes;
|
||||
vec2 p = uv;
|
||||
p.x *= uRes.x / uRes.y;
|
||||
|
||||
float t = uTime * 0.05;
|
||||
|
||||
// mouse drift
|
||||
vec2 m = (uMouse - 0.5) * 0.35;
|
||||
|
||||
// domain-warped fbm: slow molten flow
|
||||
vec2 q = vec2(fbm(p * 1.4 + t + m), fbm(p * 1.4 - t * 0.7));
|
||||
vec2 r = vec2(fbm(p * 1.8 + q * 1.6 + vec2(1.7, 9.2) + t * 0.6),
|
||||
fbm(p * 1.8 + q * 1.6 + vec2(8.3, 2.8) - t * 0.4));
|
||||
float f = fbm(p * 2.0 + r * 1.8);
|
||||
|
||||
// ridges -> thin ember veins
|
||||
float vein = smoothstep(0.42, 0.5, f) * smoothstep(0.62, 0.5, f);
|
||||
|
||||
// palette: warm black -> deep rust -> ember -> pale gold
|
||||
vec3 ink = vec3(0.047, 0.043, 0.035);
|
||||
vec3 rust = vec3(0.30, 0.10, 0.04);
|
||||
vec3 ember = vec3(1.0, 0.30, 0.14);
|
||||
vec3 gold = vec3(1.0, 0.78, 0.5);
|
||||
|
||||
vec3 col = ink;
|
||||
col = mix(col, rust, smoothstep(0.15, 0.75, f) * 0.85);
|
||||
col = mix(col, ember, vein * 0.9);
|
||||
col += gold * pow(vein, 3.0) * 0.6;
|
||||
|
||||
// glow pool near the mouse
|
||||
float md = distance(uv, uMouse);
|
||||
col += ember * 0.10 * smoothstep(0.45, 0.0, md) * (0.5 + 0.5 * f);
|
||||
|
||||
// top fade + vignette so type stays readable
|
||||
col *= smoothstep(-0.15, 0.55, uv.y) * 0.9 + 0.1;
|
||||
float vig = smoothstep(1.25, 0.45, length(uv - vec2(0.5, 0.42)));
|
||||
col *= mix(0.55, 1.0, vig);
|
||||
|
||||
// scroll dims the field as the hero leaves
|
||||
col *= 1.0 - uScroll * 0.85;
|
||||
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
|
||||
|
||||
const hero = document.querySelector("[data-hero]");
|
||||
const mouseTarget = new THREE.Vector2(0.5, 0.5);
|
||||
|
||||
function resize() {
|
||||
const w = hero.clientWidth, h = hero.clientHeight;
|
||||
renderer.setSize(w, h, false);
|
||||
renderer.getDrawingBufferSize(uniforms.uRes.value);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
if (isFinePointer) {
|
||||
window.addEventListener("pointermove", (e) => {
|
||||
mouseTarget.set(e.clientX / innerWidth, 1.0 - e.clientY / innerHeight);
|
||||
});
|
||||
}
|
||||
|
||||
// dim shader as hero scrolls away + pause when offscreen
|
||||
let heroVisible = true;
|
||||
new IntersectionObserver(([entry]) => (heroVisible = entry.isIntersecting), { threshold: 0 }).observe(hero);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
gsap.ticker.add(() => {
|
||||
if (!heroVisible) return;
|
||||
uniforms.uTime.value = clock.getElapsedTime();
|
||||
uniforms.uMouse.value.lerp(mouseTarget, 0.05);
|
||||
uniforms.uScroll.value = Math.min(window.scrollY / Math.max(hero.clientHeight, 1), 1);
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Text splitting (words -> measured lines), no club plugins
|
||||
------------------------------------------------------------ */
|
||||
function splitToLines(el) {
|
||||
const text = el.textContent.trim().replace(/\s+/g, " ");
|
||||
const hasEm = el.querySelector("em");
|
||||
// preserve <em> styling by re-walking original nodes
|
||||
const frag = [];
|
||||
el.childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
node.textContent.split(/\s+/).filter(Boolean).forEach((w) => frag.push({ w, em: false }));
|
||||
} else if (node.nodeName === "BR") {
|
||||
frag.push({ br: true });
|
||||
} else {
|
||||
node.textContent.split(/\s+/).filter(Boolean).forEach((w) =>
|
||||
frag.push({ w, em: node.nodeName === "EM" }));
|
||||
}
|
||||
});
|
||||
|
||||
el.innerHTML = "";
|
||||
const words = frag.map((item) => {
|
||||
if (item.br) {
|
||||
const br = document.createElement("br");
|
||||
el.appendChild(br);
|
||||
return null;
|
||||
}
|
||||
const span = document.createElement("span");
|
||||
span.style.display = "inline-block";
|
||||
if (item.em) {
|
||||
const em = document.createElement("em");
|
||||
em.textContent = item.w;
|
||||
span.appendChild(em);
|
||||
} else span.textContent = item.w;
|
||||
el.appendChild(span);
|
||||
el.appendChild(document.createTextNode(" "));
|
||||
return span;
|
||||
}).filter(Boolean);
|
||||
|
||||
// group by rendered line
|
||||
const lines = [];
|
||||
let lastTop = null;
|
||||
words.forEach((w) => {
|
||||
const top = w.offsetTop;
|
||||
if (top !== lastTop) { lines.push([]); lastTop = top; }
|
||||
lines[lines.length - 1].push(w);
|
||||
});
|
||||
|
||||
el.innerHTML = "";
|
||||
const lineSpans = lines.map((lineWords) => {
|
||||
const outer = document.createElement("span");
|
||||
outer.className = "split-line";
|
||||
const inner = document.createElement("span");
|
||||
inner.innerHTML = lineWords.map((w) => w.outerHTML).join(" ");
|
||||
outer.appendChild(inner);
|
||||
el.appendChild(outer);
|
||||
return inner;
|
||||
});
|
||||
el.classList.add("is-split");
|
||||
return lineSpans;
|
||||
}
|
||||
|
||||
function splitAll() {
|
||||
document.querySelectorAll("[data-reveal-lines]").forEach((el) => {
|
||||
el._lines = splitToLines(el);
|
||||
});
|
||||
document.querySelectorAll("[data-reveal-words]").forEach((el) => {
|
||||
el.classList.add("is-split"); // simple fade targets
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Preloader
|
||||
------------------------------------------------------------ */
|
||||
function initPreloader() {
|
||||
const pre = document.querySelector(".preloader");
|
||||
if (!pre) return;
|
||||
const countEl = pre.querySelector("[data-preloader-count]");
|
||||
const barEl = pre.querySelector("[data-preloader-bar]");
|
||||
const wordEl = pre.querySelector("[data-preloader-word]");
|
||||
const words = ["interfaces", "systems", "motion", "obsession"];
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
const state = { n: 0 };
|
||||
const tl = gsap.timeline({
|
||||
onComplete: () => {
|
||||
document.body.style.overflow = "";
|
||||
pre.remove();
|
||||
window._heroIntro && window._heroIntro.play();
|
||||
ScrollTrigger.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(state, {
|
||||
n: 100,
|
||||
duration: prefersReduced ? 0.1 : 1.9,
|
||||
ease: "power3.inOut",
|
||||
onUpdate: () => {
|
||||
const v = Math.round(state.n);
|
||||
countEl.textContent = v;
|
||||
barEl.style.width = v + "%";
|
||||
wordEl.textContent = words[Math.min(Math.floor(v / 26), words.length - 1)];
|
||||
},
|
||||
});
|
||||
tl.to(pre, {
|
||||
clipPath: "inset(0 0 100% 0)",
|
||||
duration: prefersReduced ? 0.1 : 0.9,
|
||||
ease: "expo.inOut",
|
||||
}, "+=0.15");
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Hero intro (played by preloader)
|
||||
------------------------------------------------------------ */
|
||||
function initHeroIntro() {
|
||||
const lines = gsap.utils.toArray("[data-hero-line]");
|
||||
const fades = gsap.utils.toArray("[data-hero-fade]");
|
||||
const kicker = document.querySelector(".hero__kicker");
|
||||
|
||||
gsap.set(lines, { yPercent: 110 });
|
||||
if (kicker) gsap.set(kicker, { opacity: 0, y: 14 });
|
||||
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to(lines, {
|
||||
yPercent: 0,
|
||||
duration: prefersReduced ? 0.1 : 1.3,
|
||||
ease: "expo.out",
|
||||
stagger: 0.12,
|
||||
});
|
||||
tl.to(kicker, { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, "-=0.8");
|
||||
tl.to(fades, { opacity: 1, duration: 0.9, ease: "power2.out", stagger: 0.07 }, "-=0.6");
|
||||
|
||||
window._heroIntro = tl;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Scroll reveals
|
||||
------------------------------------------------------------ */
|
||||
function initScrollReveals() {
|
||||
// line-by-line masked rise
|
||||
document.querySelectorAll("[data-reveal-lines]").forEach((el) => {
|
||||
if (!el._lines) return;
|
||||
gsap.set(el._lines, { yPercent: 115 });
|
||||
gsap.to(el._lines, {
|
||||
yPercent: 0,
|
||||
duration: 1.1,
|
||||
ease: "expo.out",
|
||||
stagger: 0.09,
|
||||
scrollTrigger: { trigger: el, start: "top 85%" },
|
||||
});
|
||||
});
|
||||
|
||||
// label fades
|
||||
document.querySelectorAll("[data-reveal-words]").forEach((el) => {
|
||||
if (el.closest(".hero")) return; // hero handles its own
|
||||
gsap.from(el, {
|
||||
opacity: 0, y: 16, duration: 0.8, ease: "power2.out",
|
||||
scrollTrigger: { trigger: el, start: "top 88%" },
|
||||
});
|
||||
});
|
||||
|
||||
// work rows slide in
|
||||
gsap.utils.toArray("[data-work-item]").forEach((item, i) => {
|
||||
gsap.from(item.querySelector(".work__row"), {
|
||||
opacity: 0, y: 40, duration: 0.9, ease: "power3.out",
|
||||
scrollTrigger: { trigger: item, start: "top 90%" },
|
||||
});
|
||||
});
|
||||
|
||||
// services
|
||||
gsap.utils.toArray("[data-service]").forEach((item) => {
|
||||
gsap.from(item, {
|
||||
opacity: 0, y: 36, duration: 0.8, ease: "power3.out",
|
||||
scrollTrigger: { trigger: item, start: "top 90%" },
|
||||
});
|
||||
});
|
||||
|
||||
// portrait parallax
|
||||
const portrait = document.querySelector("[data-parallax]");
|
||||
if (portrait && !prefersReduced) {
|
||||
gsap.fromTo(portrait, { y: 40 }, {
|
||||
y: -40, ease: "none",
|
||||
scrollTrigger: { trigger: portrait, start: "top bottom", end: "bottom top", scrub: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
// giant CTA lines
|
||||
gsap.utils.toArray("[data-cta-line]").forEach((line, i) => {
|
||||
gsap.from(line, {
|
||||
yPercent: 60, opacity: 0, duration: 1.2, ease: "expo.out", delay: i * 0.08,
|
||||
scrollTrigger: { trigger: ".contact__cta", start: "top 85%" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Work list: floating thumbnail follows cursor (desktop only)
|
||||
------------------------------------------------------------ */
|
||||
function initWorkList() {
|
||||
if (!isFinePointer) return;
|
||||
const items = document.querySelectorAll("[data-work-item]");
|
||||
|
||||
items.forEach((item) => {
|
||||
const thumb = item.querySelector("[data-work-thumb]");
|
||||
if (!thumb) return;
|
||||
|
||||
const xTo = gsap.quickTo(thumb, "x", { duration: 0.5, ease: "power3.out" });
|
||||
const yTo = gsap.quickTo(thumb, "y", { duration: 0.5, ease: "power3.out" });
|
||||
// thumbs are position:fixed only on wide viewports; skip when static
|
||||
const floating = () => getComputedStyle(thumb).position === "fixed";
|
||||
|
||||
item.addEventListener("pointerenter", (e) => {
|
||||
if (!floating()) return;
|
||||
gsap.set(thumb, { x: e.clientX, y: e.clientY });
|
||||
gsap.to(thumb, {
|
||||
opacity: 1, scale: 1, rotate: 0, duration: 0.45, ease: "power3.out",
|
||||
transformOrigin: "center",
|
||||
});
|
||||
});
|
||||
item.addEventListener("pointermove", (e) => {
|
||||
xTo(e.clientX);
|
||||
yTo(e.clientY);
|
||||
});
|
||||
item.addEventListener("pointerleave", () => {
|
||||
gsap.to(thumb, { opacity: 0, scale: 0.85, rotate: -4, duration: 0.35, ease: "power2.in" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Marquee: infinite loop + scroll-velocity skew
|
||||
------------------------------------------------------------ */
|
||||
function initMarquee() {
|
||||
const track = document.querySelector("[data-marquee-track]");
|
||||
if (!track) return;
|
||||
track.innerHTML += track.innerHTML; // duplicate for seamless wrap
|
||||
|
||||
if (prefersReduced) return;
|
||||
const half = track.scrollWidth / 2;
|
||||
const loop = gsap.to(track, {
|
||||
x: -half, duration: 28, ease: "none", repeat: -1,
|
||||
modifiers: { x: (x) => (parseFloat(x) % half) + "px" },
|
||||
});
|
||||
|
||||
// speed up with scroll velocity
|
||||
ScrollTrigger.create({
|
||||
trigger: document.body,
|
||||
start: 0, end: "max",
|
||||
onUpdate: (self) => {
|
||||
const v = gsap.utils.clamp(0.5, 4, Math.abs(self.getVelocity()) / 280);
|
||||
gsap.to(loop, { timeScale: v, duration: 0.4, overwrite: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Stat counters
|
||||
------------------------------------------------------------ */
|
||||
function initCounters() {
|
||||
document.querySelectorAll("[data-count]").forEach((el) => {
|
||||
const target = +el.dataset.count;
|
||||
const state = { n: 0 };
|
||||
ScrollTrigger.create({
|
||||
trigger: el, start: "top 88%", once: true,
|
||||
onEnter: () =>
|
||||
gsap.to(state, {
|
||||
n: target, duration: 1.6, ease: "power3.out",
|
||||
onUpdate: () => (el.textContent = Math.round(state.n)),
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Custom cursor
|
||||
------------------------------------------------------------ */
|
||||
function initCursor() {
|
||||
if (!isFinePointer) return;
|
||||
const root = document.querySelector(".cursor");
|
||||
const dot = root.querySelector("[data-cursor-dot]");
|
||||
const ring = root.querySelector("[data-cursor-ring]");
|
||||
const label = root.querySelector("[data-cursor-label]");
|
||||
document.body.classList.add("has-cursor");
|
||||
|
||||
const dotX = gsap.quickTo(dot, "x", { duration: 0.12, ease: "power2.out" });
|
||||
const dotY = gsap.quickTo(dot, "y", { duration: 0.12, ease: "power2.out" });
|
||||
const ringX = gsap.quickTo(ring, "x", { duration: 0.45, ease: "power3.out" });
|
||||
const ringY = gsap.quickTo(ring, "y", { duration: 0.45, ease: "power3.out" });
|
||||
|
||||
window.addEventListener("pointermove", (e) => {
|
||||
dotX(e.clientX); dotY(e.clientY);
|
||||
ringX(e.clientX); ringY(e.clientY);
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-cursor], a, button").forEach((el) => {
|
||||
el.addEventListener("pointerenter", () => {
|
||||
const text = el.dataset.cursor;
|
||||
if (text) {
|
||||
label.textContent = text;
|
||||
root.classList.add("is-hover");
|
||||
}
|
||||
});
|
||||
el.addEventListener("pointerleave", () => root.classList.remove("is-hover"));
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------
|
||||
Magnetic links
|
||||
------------------------------------------------------------ */
|
||||
function initMagnetic() {
|
||||
if (!isFinePointer || prefersReduced) return;
|
||||
document.querySelectorAll("[data-magnetic]").forEach((el) => {
|
||||
const xTo = gsap.quickTo(el, "x", { duration: 0.4, ease: "power3.out" });
|
||||
const yTo = gsap.quickTo(el, "y", { duration: 0.4, ease: "power3.out" });
|
||||
el.addEventListener("pointermove", (e) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
xTo((e.clientX - (r.left + r.width / 2)) * 0.35);
|
||||
yTo((e.clientY - (r.top + r.height / 2)) * 0.35);
|
||||
});
|
||||
el.addEventListener("pointerleave", () => { xTo(0); yTo(0); });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user