mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-22 01:29:31 -04:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7934c3e44 | ||
|
|
273c8e985c |
@@ -36,6 +36,7 @@ import {
|
||||
useSidebar,
|
||||
} from "../components/ui/sidebar";
|
||||
import { AppSidebar } from "../components/sidebar/app-sidebar";
|
||||
import { PageTransition } from "../components/motion/page-transition";
|
||||
import { MobileBottomNav } from "../components/sidebar/mobile-bottom-nav";
|
||||
import { TopBar } from "../components/topbar/topbar";
|
||||
import { SignupCtaModal } from "../components/signup-cta-modal";
|
||||
@@ -109,15 +110,10 @@ function ProtectedContent() {
|
||||
style={{ "--ac-sidebar-offset": sidebarOffset } as React.CSSProperties}
|
||||
>
|
||||
{!hideTopBar && <TopBar />}
|
||||
{/* The immersive editors (hideTopBar routes) paint their own #101014
|
||||
canvas backdrop; match the inset to it so no lighter strip shows at
|
||||
the edges. Other pages keep the standard #121212 surface. */}
|
||||
<SidebarInset
|
||||
className={`flex-1 min-h-0 overflow-y-auto ${
|
||||
hideTopBar ? "bg-[#101014]" : "bg-[#121212]"
|
||||
}`}
|
||||
>
|
||||
<Outlet />
|
||||
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-[#121212]">
|
||||
<PageTransition>
|
||||
<Outlet />
|
||||
</PageTransition>
|
||||
</SidebarInset>
|
||||
{isMobile && <MobileBottomNav />}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TruchetPattern } from "@storyteller/ui-vfx";
|
||||
import { AuthShowcase } from "./auth-showcase";
|
||||
import { useMediaQuery } from "../ui/use-media-query";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { Reveal, RevealGroup } from "../motion/reveal";
|
||||
|
||||
/**
|
||||
* Persistent shell for the auth pages. Rendered as a layout route so the
|
||||
@@ -108,16 +109,22 @@ interface AuthHeaderProps {
|
||||
}
|
||||
|
||||
export const AuthHeader = ({ title, subtitle }: AuthHeaderProps) => (
|
||||
<div className="mb-8 text-center">
|
||||
<img
|
||||
src="/images/artcraft-icon.png"
|
||||
alt="ArtCraft"
|
||||
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
|
||||
<p className="text-sm text-white/60">{subtitle}</p>
|
||||
</div>
|
||||
<RevealGroup inView={false} stagger={0.08} className="mb-8 text-center">
|
||||
<Reveal y={10}>
|
||||
<img
|
||||
src="/images/artcraft-icon.png"
|
||||
alt="ArtCraft"
|
||||
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
</Reveal>
|
||||
<Reveal as="h1" y={10} className="mb-2 text-2xl font-semibold">
|
||||
{title}
|
||||
</Reveal>
|
||||
<Reveal as="p" y={10} className="text-sm text-white/60">
|
||||
{subtitle}
|
||||
</Reveal>
|
||||
</RevealGroup>
|
||||
);
|
||||
|
||||
export const AuthFooter = ({ children }: { children: ReactNode }) => (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { pageFadeVariants } from "../../lib/motion";
|
||||
|
||||
// Routes whose dynamic sub-segments should NOT replay the page transition.
|
||||
// Switching scenes (`/edit-3d/:token`), library filters (`/library/:slug`) or
|
||||
// media items (`/media/:id`) stays *within* a section — re-fading the whole
|
||||
// shell on every param change would feel laggy and would needlessly remount
|
||||
// heavy editors. Section changes (Home → Create Image) still animate.
|
||||
const SECTION_PREFIXES = [
|
||||
"/edit-3d",
|
||||
"/video-editor",
|
||||
"/library",
|
||||
"/media",
|
||||
] as const;
|
||||
|
||||
/** Collapse a pathname to a stable key for its app section. */
|
||||
export function routeSectionKey(pathname: string): string {
|
||||
const section = SECTION_PREFIXES.find(
|
||||
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
||||
);
|
||||
return section ?? pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the routed `<Outlet />` so navigating between app sections fades the
|
||||
* new page in. Enter-only (no exit) keeps navigation feeling instant — the old
|
||||
* page is replaced immediately and the new one fades up over it.
|
||||
*
|
||||
* The fade is opacity-only by design; see `pageFadeVariants` for why a
|
||||
* transform here would break `position: fixed` page chrome.
|
||||
*
|
||||
* The wrapper mirrors `SidebarInset`'s flex slot (`flex-1 flex-col min-h-0`) so
|
||||
* pages that rely on `h-full` (the 3D/video editors) or `min-h-full` (scrolling
|
||||
* pages like Home) keep resolving their heights exactly as before.
|
||||
*/
|
||||
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
<motion.div
|
||||
key={routeSectionKey(pathname)}
|
||||
variants={pageFadeVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="flex w-full flex-1 flex-col min-h-0"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Reveal — the webapp's reusable entrance primitive.
|
||||
//
|
||||
// Wraps content in a fade-and-lift that plays once when the element scrolls
|
||||
// into view (or immediately on mount for above-the-fold content). It reuses the
|
||||
// shared motion tokens so every page enters with the same restrained, premium
|
||||
// cadence rather than each surface hand-rolling its own.
|
||||
//
|
||||
// Two pieces:
|
||||
// <RevealGroup> — a stagger container; its <Reveal> children cascade in.
|
||||
// <Reveal> — a single fade-up element (works standalone or as a child).
|
||||
//
|
||||
// Both respect `prefers-reduced-motion`: motion collapses to a plain, instant
|
||||
// presence so the layout never moves for users who opt out.
|
||||
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import { DUR_SLOW, EASE_OUT } from "../../lib/motion";
|
||||
|
||||
interface RevealProps {
|
||||
children: ReactNode;
|
||||
/** Render as a different element/component (e.g. "section", "li", Link). */
|
||||
as?: ElementType;
|
||||
/** Extra delay (seconds) before this element animates. */
|
||||
delay?: number;
|
||||
/** Travel distance for the lift, in px. Defaults to a gentle 16. */
|
||||
y?: number;
|
||||
/** Play once when scrolled into view (default) vs. immediately on mount. */
|
||||
inView?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stagger container. Place <Reveal> children inside and they fade up in
|
||||
* sequence. Use for grids, card rows, and stacked content blocks.
|
||||
*/
|
||||
export function RevealGroup({
|
||||
children,
|
||||
as = "div",
|
||||
stagger = 0.07,
|
||||
delayChildren = 0.04,
|
||||
inView = true,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
as?: ElementType;
|
||||
stagger?: number;
|
||||
delayChildren?: number;
|
||||
inView?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const MotionTag = motion(as);
|
||||
|
||||
const variants: Variants = {
|
||||
initial: {},
|
||||
animate: {
|
||||
transition: reduceMotion
|
||||
? {}
|
||||
: { staggerChildren: stagger, delayChildren },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<MotionTag
|
||||
className={className}
|
||||
variants={variants}
|
||||
initial="initial"
|
||||
{...(inView
|
||||
? { whileInView: "animate", viewport: { once: true, margin: "-10%" } }
|
||||
: { animate: "animate" })}
|
||||
>
|
||||
{children}
|
||||
</MotionTag>
|
||||
);
|
||||
}
|
||||
|
||||
export function Reveal({
|
||||
children,
|
||||
as = "div",
|
||||
delay = 0,
|
||||
y = 16,
|
||||
inView,
|
||||
className,
|
||||
}: RevealProps) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const MotionTag = motion(as);
|
||||
|
||||
const variants: Variants = {
|
||||
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: DUR_SLOW, ease: EASE_OUT, delay },
|
||||
},
|
||||
};
|
||||
|
||||
// When `inView` is left undefined the element behaves as a stagger child:
|
||||
// it inherits the parent RevealGroup's orchestration and must NOT drive its
|
||||
// own initial/animate props (that would override the cascade).
|
||||
const standalone = inView !== undefined;
|
||||
|
||||
return (
|
||||
<MotionTag
|
||||
className={className}
|
||||
variants={variants}
|
||||
{...(standalone
|
||||
? inView
|
||||
? {
|
||||
initial: "initial",
|
||||
whileInView: "animate",
|
||||
viewport: { once: true, margin: "-10%" },
|
||||
}
|
||||
: { initial: "initial", animate: "animate" }
|
||||
: {})}
|
||||
>
|
||||
{children}
|
||||
</MotionTag>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faHouse,
|
||||
@@ -137,9 +138,16 @@ function NavMenuItem({
|
||||
pathname: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const active = !item.external && isActive(pathname, item.href);
|
||||
const inner = (
|
||||
<>
|
||||
<FontAwesomeIcon icon={item.icon} />
|
||||
{/* Icon nudges up in scale on hover — a small tactile cue that the row is
|
||||
interactive, kept subtle to stay within the "restrained chrome" lane. */}
|
||||
<FontAwesomeIcon
|
||||
icon={item.icon}
|
||||
className="transition-transform duration-200 ease-out group-hover/menu-item:scale-110"
|
||||
/>
|
||||
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="ml-auto bg-amber-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full">
|
||||
@@ -150,9 +158,24 @@ function NavMenuItem({
|
||||
);
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
{/* Brand accent bar that glides between nav rows as the active route
|
||||
changes (shared-element layout animation via `layoutId`). One is
|
||||
mounted at a time, so framer-motion tweens it from the old row to the
|
||||
new one. */}
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId="sidebar-active-indicator"
|
||||
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-1 rounded-full bg-primary"
|
||||
transition={
|
||||
reduceMotion
|
||||
? { duration: 0 }
|
||||
: { type: "spring", stiffness: 520, damping: 40, mass: 0.7 }
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={!item.external && isActive(pathname, item.href)}
|
||||
isActive={active}
|
||||
tooltip={item.label}
|
||||
>
|
||||
{item.external ? (
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Shared motion tokens for the webapp.
|
||||
//
|
||||
// These mirror the CSS custom properties defined in `src/styles.css` (`--ease-*`,
|
||||
// `--dur-*`) so framer-motion animations and plain CSS transitions stay in sync.
|
||||
// The "balanced" motion direction: chrome and navigation are restrained and fast;
|
||||
// the creative canvas (3D drops, gallery, pagedraw) is where motion gets to be
|
||||
// expressive. Reach for these instead of hand-writing curves and durations.
|
||||
|
||||
import type { Transition, Variants } from "framer-motion";
|
||||
|
||||
// ── Easing curves (cubic-bezier control points) ───────────────────────────────
|
||||
// Tuples are typed as 4-number arrays so framer-motion accepts them as `ease`.
|
||||
|
||||
/** Decelerate-to-rest. The default for elements entering the screen. */
|
||||
export const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
|
||||
/** Symmetric ease for elements that move and settle in place. */
|
||||
export const EASE_IN_OUT: [number, number, number, number] = [0.65, 0, 0.35, 1];
|
||||
/** Gentle overshoot for playful, tactile moments (pills, badges, drops). */
|
||||
export const EASE_SPRING: [number, number, number, number] = [0.34, 1.56, 0.64, 1];
|
||||
/** Apple/Linear "expo" curve for confident, premium slides. */
|
||||
export const EASE_EMPHASIS: [number, number, number, number] = [0.32, 0.72, 0, 1];
|
||||
|
||||
// ── Durations (seconds, to match framer-motion's unit) ────────────────────────
|
||||
|
||||
export const DUR_FAST = 0.14;
|
||||
export const DUR_BASE = 0.22;
|
||||
export const DUR_SLOW = 0.34;
|
||||
export const DUR_PAGE = 0.26;
|
||||
|
||||
// ── Ready-made transition presets ─────────────────────────────────────────────
|
||||
|
||||
/** Quick, restrained transition for chrome (nav, hovers, toggles). */
|
||||
export const TRANSITION_CHROME: Transition = {
|
||||
duration: DUR_BASE,
|
||||
ease: EASE_OUT,
|
||||
};
|
||||
|
||||
/** The global page/route transition — fast and unobtrusive. */
|
||||
export const TRANSITION_PAGE: Transition = {
|
||||
duration: DUR_PAGE,
|
||||
ease: EASE_OUT,
|
||||
};
|
||||
|
||||
/** Tactile spring for expressive bits that should feel physical. */
|
||||
export const TRANSITION_SPRING: Transition = {
|
||||
type: "spring",
|
||||
stiffness: 420,
|
||||
damping: 30,
|
||||
mass: 0.9,
|
||||
};
|
||||
|
||||
// ── Reusable variants ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Opacity-only page enter. Deliberately avoids `transform`/`filter`: a lingering
|
||||
* transform on a route wrapper would establish a containing block and re-scope
|
||||
* `position: fixed` page chrome (promptboxes, editor toolbars) to the wrapper
|
||||
* instead of the viewport. Fading is the safe, premium choice here — the
|
||||
* expressive vertical motion lives inside individual pages, not the shell.
|
||||
*/
|
||||
export const pageFadeVariants: Variants = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, transition: TRANSITION_PAGE },
|
||||
exit: { opacity: 0, transition: { duration: DUR_FAST, ease: EASE_OUT } },
|
||||
};
|
||||
|
||||
/** Fade-and-lift for content that is safe to translate (no fixed children). */
|
||||
export const fadeUpVariants: Variants = {
|
||||
initial: { opacity: 0, y: 12 },
|
||||
animate: { opacity: 1, y: 0, transition: TRANSITION_CHROME },
|
||||
exit: { opacity: 0, y: 6, transition: { duration: DUR_FAST, ease: EASE_OUT } },
|
||||
};
|
||||
|
||||
/** Parent variant that staggers children using `fadeUpVariants` (or similar). */
|
||||
export const staggerContainerVariants: Variants = {
|
||||
initial: {},
|
||||
animate: {
|
||||
transition: { staggerChildren: 0.05, delayChildren: 0.02 },
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faImage,
|
||||
@@ -10,6 +11,10 @@ import {
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import Seo from "../../components/seo";
|
||||
import { Reveal, RevealGroup } from "../../components/motion/reveal";
|
||||
import { TRANSITION_SPRING } from "../../lib/motion";
|
||||
|
||||
const MotionLink = motion(Link);
|
||||
|
||||
type AppCard = {
|
||||
label: string;
|
||||
@@ -72,60 +77,71 @@ const APPS: AppCard[] = [
|
||||
];
|
||||
|
||||
export function Home() {
|
||||
const reduceMotion = useReducedMotion();
|
||||
return (
|
||||
<div className="min-h-full px-6 sm:px-10 py-10 sm:py-16 max-w-6xl mx-auto w-full">
|
||||
<Seo
|
||||
title="ArtCraft - Create AI Images and Video"
|
||||
description="Generate AI images and video with ArtCraft."
|
||||
/>
|
||||
<h1 className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight">
|
||||
<Reveal as="h1" inView={false} y={20} className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight">
|
||||
What will you <span className="text-primary">craft</span> today?
|
||||
</h1>
|
||||
</Reveal>
|
||||
|
||||
<section className="py-12">
|
||||
<h2 className="text-sm font-semibold text-white/85 mb-4">Create</h2>
|
||||
<div className="grid gap-3 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Reveal as="h2" inView={false} delay={0.08} className="text-sm font-semibold text-white/85 mb-4">
|
||||
Create
|
||||
</Reveal>
|
||||
<RevealGroup
|
||||
inView={false}
|
||||
delayChildren={0.12}
|
||||
stagger={0.06}
|
||||
className="grid gap-3 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{APPS.map((app) => (
|
||||
<Link
|
||||
key={app.href}
|
||||
to={app.href}
|
||||
className="bg-ui-controls/50 group relative h-full overflow-hidden rounded-2xl p-5 hover:border-white/20 hover:bg-ui-controls"
|
||||
>
|
||||
<div
|
||||
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex items-start gap-4">
|
||||
<Reveal key={app.href} y={20}>
|
||||
<MotionLink
|
||||
to={app.href}
|
||||
className="bg-ui-controls/50 group relative flex h-full overflow-hidden rounded-2xl p-5 ring-1 ring-white/[0.06] shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04)] hover:bg-ui-controls hover:ring-white/15"
|
||||
whileHover={reduceMotion ? undefined : { y: -4 }}
|
||||
whileTap={reduceMotion ? undefined : { scale: 0.985 }}
|
||||
transition={TRANSITION_SPRING}
|
||||
>
|
||||
<div
|
||||
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={app.icon} className="text-base" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{app.label}
|
||||
</h3>
|
||||
{app.badge && (
|
||||
<span className="shrink-0 rounded-full bg-amber-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white">
|
||||
{app.badge}
|
||||
</span>
|
||||
)}
|
||||
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex w-full items-start gap-4">
|
||||
<div
|
||||
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor} shadow-[inset_0_1px_1px_rgba(255,255,255,0.12)] transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-105`}
|
||||
>
|
||||
<FontAwesomeIcon icon={app.icon} className="text-base" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white truncate">
|
||||
{app.label}
|
||||
</h3>
|
||||
{app.badge && (
|
||||
<span className="shrink-0 rounded-full bg-amber-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white">
|
||||
{app.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/[0.04] text-white/40 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:bg-white/10 group-hover:text-white/80 group-hover:translate-x-0.5">
|
||||
<FontAwesomeIcon icon={faArrowRight} className="text-xs" />
|
||||
</span>
|
||||
</div>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="text-sm text-white/40 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-white/55 leading-snug">
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-white/55 leading-snug">
|
||||
{app.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</MotionLink>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</RevealGroup>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, useParams, useNavigate, useLocation } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { EASE_EMPHASIS } from "../../lib/motion";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import {
|
||||
@@ -859,25 +861,45 @@ export default function Library() {
|
||||
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
|
||||
<Link
|
||||
to="/library"
|
||||
className={`flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
tab === "unsorted"
|
||||
? "bg-ui-controls text-white"
|
||||
? "text-white"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBorderAll} className="text-xs" />
|
||||
<span>All Assets</span>
|
||||
{tab === "unsorted" && (
|
||||
<motion.span
|
||||
layoutId="library-tab-indicator"
|
||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||
/>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={faBorderAll}
|
||||
className="relative z-10 text-xs"
|
||||
/>
|
||||
<span className="relative z-10">All Assets</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/library/folders"
|
||||
className={`flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
tab === "folders"
|
||||
? "bg-ui-controls text-white"
|
||||
? "text-white"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFolder} className="text-xs" />
|
||||
<span>Folders</span>
|
||||
{tab === "folders" && (
|
||||
<motion.span
|
||||
layoutId="library-tab-indicator"
|
||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||
/>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={faFolder}
|
||||
className="relative z-10 text-xs"
|
||||
/>
|
||||
<span className="relative z-10">Folders</span>
|
||||
</Link>
|
||||
</div>
|
||||
{tab === "unsorted" && (
|
||||
@@ -901,17 +923,26 @@ export default function Library() {
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => navigate(filter.route)}
|
||||
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
|
||||
activeFilter === filter.id
|
||||
? "bg-ui-controls text-white"
|
||||
? "text-white"
|
||||
: "text-white/60 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{activeFilter === filter.id && (
|
||||
<motion.span
|
||||
layoutId="library-filter-indicator"
|
||||
className="absolute inset-0 rounded-md bg-ui-controls"
|
||||
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
|
||||
/>
|
||||
)}
|
||||
<FontAwesomeIcon
|
||||
icon={filter.icon}
|
||||
className="text-xs"
|
||||
className="relative z-10 text-xs"
|
||||
/>
|
||||
<span className="hidden sm:inline">{filter.label}</span>
|
||||
<span className="relative z-10 hidden sm:inline">
|
||||
{filter.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1063,7 +1094,15 @@ export default function Library() {
|
||||
)
|
||||
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
|
||||
<div>
|
||||
<div className="h-4 w-24 rounded bg-white/[0.06] mb-3" />
|
||||
<div
|
||||
className="h-4 w-24 rounded mb-3"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(100deg, rgba(255,255,255,0.05) 30%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 70%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: "lib-shimmer 1.6s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
<div className={GRID_CLASS}>
|
||||
{Array.from({ length: 15 }).map((_, i) => (
|
||||
<div
|
||||
@@ -1071,15 +1110,18 @@ export default function Library() {
|
||||
className="aspect-square rounded-lg overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="h-full w-full bg-white/[0.06]"
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
animation: `pulse 1.8s ease-in-out ${i * 0.07}s infinite`,
|
||||
background:
|
||||
"linear-gradient(100deg, rgba(255,255,255,0.04) 30%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 70%)",
|
||||
backgroundSize: "200% 100%",
|
||||
animation: `lib-shimmer 1.6s ease-in-out ${i * 0.08}s infinite`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<style>{`@keyframes pulse {0%,100%{opacity:.4}50%{opacity:.8}}`}</style>
|
||||
<style>{`@keyframes lib-shimmer {0%{background-position:200% 0}100%{background-position:-200% 0}}`}</style>
|
||||
</div>
|
||||
) : rootEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { UsersApi } from "@storyteller/api";
|
||||
import { AuthHeader, AuthFooter, GoogleLoginButton } from "../../components/auth";
|
||||
import Seo from "../../components/seo";
|
||||
import { Reveal, RevealGroup } from "../../components/motion/reveal";
|
||||
import { refreshSession } from "../../lib/session";
|
||||
import { hasActiveSubscription } from "../../lib/billing";
|
||||
|
||||
@@ -71,6 +72,8 @@ const Login = () => {
|
||||
/>
|
||||
<AuthHeader title="Welcome Back" subtitle="Log in to your account" />
|
||||
|
||||
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
|
||||
<Reveal>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(e) => {
|
||||
@@ -141,31 +144,37 @@ const Login = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Reveal>
|
||||
|
||||
<div className="relative my-6 flex items-center justify-center">
|
||||
<Reveal className="relative my-6 flex items-center justify-center">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
</div>
|
||||
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<GoogleLoginButton
|
||||
mode="login"
|
||||
onSuccess={handleGoogleSuccess}
|
||||
onError={handleGoogleError}
|
||||
/>
|
||||
<Reveal>
|
||||
<GoogleLoginButton
|
||||
mode="login"
|
||||
onSuccess={handleGoogleSuccess}
|
||||
onError={handleGoogleError}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<AuthFooter>
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/signup"
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</AuthFooter>
|
||||
<Reveal>
|
||||
<AuthFooter>
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/signup"
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</AuthFooter>
|
||||
</Reveal>
|
||||
</RevealGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,6 +37,21 @@ import { useSceneCacheStore } from "./scene-cache-store";
|
||||
const apiHost = () =>
|
||||
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
|
||||
|
||||
// In local dev, route absolute cross-origin asset URLs (CDN splats/GLBs/images)
|
||||
// through the Vite `/__cdn` proxy so they aren't blocked by CORS — the CDN only
|
||||
// sends CORS headers for the production origin. In production this is a no-op:
|
||||
// the asset is fetched directly from its real URL.
|
||||
function maybeProxyCdnUrl(url: string): string {
|
||||
if (!import.meta.env.DEV) return url;
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
if (parsed.origin === window.location.origin) return url;
|
||||
return `/__cdn/${parsed.host}${parsed.pathname}${parsed.search}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Scene file IO ─────────────────────────────────────────────────────────
|
||||
|
||||
const saveSceneViaApi = async (
|
||||
@@ -206,7 +221,7 @@ export const useWebAppPageSceneAdapter = (
|
||||
},
|
||||
|
||||
fetchAsset: (url: string, init?: { signal?: AbortSignal }) =>
|
||||
fetch(url, { mode: "cors", signal: init?.signal }),
|
||||
fetch(maybeProxyCdnUrl(url), { mode: "cors", signal: init?.signal }),
|
||||
|
||||
getCdnOrigin: () => GetCdnOrigin(),
|
||||
getApiSchemeAndHost: apiHost,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PricingPromoBanner,
|
||||
} from "@storyteller/ui-pricing-table";
|
||||
import { CreditsModal } from "../../components/credits-modal";
|
||||
import { Reveal } from "../../components/motion/reveal";
|
||||
|
||||
const SeedanceBanner = () => (
|
||||
<div className="flex flex-col gap-5">
|
||||
@@ -152,10 +153,7 @@ const Pricing = () => {
|
||||
) : (
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<PricingPromoBanner className="mb-10 sm:mb-12" />
|
||||
<div className="text-center mb-14" data-reveal>
|
||||
{/* <span className="inline-block text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">
|
||||
Plans
|
||||
</span> */}
|
||||
<Reveal as="div" inView={false} y={20} className="text-center mb-14">
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
|
||||
Invest in <span className="font-serif-italic">yourself</span>.
|
||||
</h1>
|
||||
@@ -164,7 +162,7 @@ const Pricing = () => {
|
||||
Your subscription helps keep ArtCraft free and open for
|
||||
everyone.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
{hasPlan && (
|
||||
<BuyMoreCreditsCta onClick={() => setCreditsModalOpen(true)} />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
|
||||
import Seo from "../../components/seo";
|
||||
import { Reveal, RevealGroup } from "../../components/motion/reveal";
|
||||
|
||||
const Signup = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -13,17 +14,26 @@ const Signup = () => {
|
||||
/>
|
||||
<AuthHeader title="Create an Account" subtitle="Join thousands of creators" />
|
||||
|
||||
<SignupForm onSuccess={() => navigate("/welcome")} signupSource="artcraft" />
|
||||
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
|
||||
<Reveal>
|
||||
<SignupForm
|
||||
onSuccess={() => navigate("/welcome")}
|
||||
signupSource="artcraft"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<AuthFooter>
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</AuthFooter>
|
||||
<Reveal>
|
||||
<AuthFooter>
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Log in
|
||||
</Link>
|
||||
</AuthFooter>
|
||||
</Reveal>
|
||||
</RevealGroup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -35,6 +35,39 @@
|
||||
--border: 0 0% 18%;
|
||||
--input: 0 0% 18%;
|
||||
--ring: 217 91% 60%;
|
||||
|
||||
/* ── Motion tokens ──────────────────────────────────────────────────
|
||||
Shared easing curves + durations so motion across the app feels like
|
||||
one system, not a pile of one-offs. Mirrored as JS constants in
|
||||
src/lib/motion.ts for framer-motion. "Balanced" direction: chrome and
|
||||
navigation stay restrained; the creative canvas gets to be expressive.
|
||||
|
||||
Curves:
|
||||
--ease-out decelerate-to-rest; the default for things entering.
|
||||
--ease-in-out symmetric; for things that move and settle in place.
|
||||
--ease-spring gentle overshoot; for playful, tactile moments.
|
||||
--ease-emphasis Apple/Linear "expo" curve; for confident, premium slides. */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-emphasis: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
|
||||
/* Durations — keep chrome snappy, reserve longer ones for canvas drama. */
|
||||
--dur-fast: 140ms;
|
||||
--dur-base: 220ms;
|
||||
--dur-slow: 340ms;
|
||||
--dur-page: 260ms;
|
||||
}
|
||||
|
||||
/* Respect users who ask for less motion: collapse durations to near-zero
|
||||
so transitions become instant cuts rather than being removed wholesale. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--dur-fast: 1ms;
|
||||
--dur-base: 1ms;
|
||||
--dur-slow: 1ms;
|
||||
--dur-page: 1ms;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,22 @@ export default defineConfig(() => ({
|
||||
Origin: 'https://api.storyteller.ai',
|
||||
},
|
||||
},
|
||||
// Dev-only CDN proxy. Media assets (splats, GLBs, images) are fetched
|
||||
// from the CDN with fetch(), but the CDN only sends CORS headers for the
|
||||
// production origin — not localhost. The web adapter's fetchAsset rewrites
|
||||
// absolute CDN URLs to `/__cdn/<host>/<path>` in DEV; this forwards them
|
||||
// server-side (no browser CORS). The target host is taken from the path
|
||||
// so any cdn-N.fakeyou.com / Cloudflare host works.
|
||||
'/__cdn': {
|
||||
target: 'https://cdn-2.fakeyou.com',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
router: (req) => {
|
||||
const match = req.url?.match(/^\/__cdn\/([^/]+)/);
|
||||
return match ? `https://${match[1]}` : 'https://cdn-2.fakeyou.com';
|
||||
},
|
||||
rewrite: (path) => path.replace(/^\/__cdn\/[^/]+/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
preview:{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./lib/gallery-modal";
|
||||
export * from "./lib/galleryModalSignals";
|
||||
export * from "./lib/DragGhost";
|
||||
export * from "./lib/GalleryDragComponent";
|
||||
export * from "./lib/GalleryDraggableItem";
|
||||
export * from "./lib/GalleryItemMenuItems";
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
// Shared "floating thumbnail" that follows the cursor while dragging an item out
|
||||
// of a library. Tuned to feel like physically carrying something rather than
|
||||
// teleporting a flat image: it lifts in with a scale/opacity pop on pickup,
|
||||
// trails the cursor with a touch of weight, and tilts into the direction of
|
||||
// motion based on drag velocity. All motion is folded into a single transform
|
||||
// driven by one rAF loop.
|
||||
//
|
||||
// Decoupled from any specific drag system via a `getState` callback the rAF polls
|
||||
// each frame, so both the gallery (galleryDnd) and the 3D asset library (its own
|
||||
// store-driven DnD) can reuse the exact same ghost.
|
||||
|
||||
const APPEAR_MS = 170; // pickup lift duration
|
||||
const FOLLOW = 0.4; // cursor-follow easing (1 = rigid, lower = more trail/weight)
|
||||
const TILT_PER_PX = 0.5; // velocity → tilt (deg per px/frame)
|
||||
const MAX_TILT = 14; // clamp so it never looks broken
|
||||
|
||||
export interface DragGhostState {
|
||||
/** Page-space cursor position (document coords). */
|
||||
x: number;
|
||||
y: number;
|
||||
thumbnail: string;
|
||||
/** Optional caption rendered under the thumbnail (asset library shows names). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface DragGhostProps {
|
||||
/** Polled every frame; return null when nothing is being dragged. */
|
||||
getState: () => DragGhostState | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Pass "anonymous" for CORS-tainted asset thumbnails; omit for the gallery. */
|
||||
crossOrigin?: "anonymous" | "use-credentials";
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
export function DragGhost({
|
||||
getState,
|
||||
width = 120,
|
||||
height = 120,
|
||||
crossOrigin,
|
||||
}: DragGhostProps) {
|
||||
const [, force] = useState(0);
|
||||
|
||||
// Keep the latest getState in a ref so the rAF loop (started once) always reads
|
||||
// fresh drag state without restarting on every render.
|
||||
const getStateRef = useRef(getState);
|
||||
getStateRef.current = getState;
|
||||
|
||||
// Animation state kept in refs so the rAF loop mutates without re-rendering.
|
||||
const appearStartRef = useRef<number | null>(null);
|
||||
const renderXRef = useRef(0);
|
||||
const renderYRef = useRef(0);
|
||||
const prevXRef = useRef(0);
|
||||
const tiltRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let frame: number;
|
||||
function update() {
|
||||
const state = getStateRef.current();
|
||||
|
||||
if (state) {
|
||||
// On the first frame of a drag, snap the rendered position to the cursor
|
||||
// and start the lift-in timer so it grows from the pickup point.
|
||||
if (appearStartRef.current === null) {
|
||||
appearStartRef.current = performance.now();
|
||||
renderXRef.current = state.x;
|
||||
renderYRef.current = state.y;
|
||||
prevXRef.current = state.x;
|
||||
}
|
||||
renderXRef.current += (state.x - renderXRef.current) * FOLLOW;
|
||||
renderYRef.current += (state.y - renderYRef.current) * FOLLOW;
|
||||
|
||||
const velocityX = state.x - prevXRef.current;
|
||||
prevXRef.current = state.x;
|
||||
const targetTilt = clamp(velocityX * TILT_PER_PX, -MAX_TILT, MAX_TILT);
|
||||
// Ease the tilt toward target so it leans and recovers smoothly.
|
||||
tiltRef.current += (targetTilt - tiltRef.current) * 0.2;
|
||||
} else {
|
||||
appearStartRef.current = null;
|
||||
tiltRef.current = 0;
|
||||
}
|
||||
|
||||
force((n) => (n + 1) & 0xffff);
|
||||
frame = requestAnimationFrame(update);
|
||||
}
|
||||
frame = requestAnimationFrame(update);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
const state = getState();
|
||||
if (
|
||||
!state ||
|
||||
appearStartRef.current === null ||
|
||||
typeof document === "undefined"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appear = easeOutCubic(
|
||||
clamp((performance.now() - appearStartRef.current) / APPEAR_MS, 0, 1),
|
||||
);
|
||||
const scale = 0.62 + 0.38 * appear;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: renderXRef.current + 1,
|
||||
top: renderYRef.current - height / 2,
|
||||
width,
|
||||
height,
|
||||
opacity: appear,
|
||||
transform: `rotate(${tiltRef.current}deg) scale(${scale})`,
|
||||
transformOrigin: "center center",
|
||||
willChange: "transform, opacity",
|
||||
zIndex: 10000,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
className="flex cursor-grabbing flex-col overflow-hidden rounded-xl shadow-[0_18px_40px_-12px_rgba(0,0,0,0.7)] ring-1 ring-white/20"
|
||||
>
|
||||
<img
|
||||
src={state.thumbnail}
|
||||
alt={state.label ?? ""}
|
||||
crossOrigin={crossOrigin}
|
||||
className="pointer-events-none min-h-0 w-full flex-1 select-none object-cover"
|
||||
/>
|
||||
{state.label && (
|
||||
<div className="w-full select-none truncate bg-ui-controls px-2 py-1 text-center text-[12px]">
|
||||
{state.label}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,22 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { DragGhost } from "./DragGhost";
|
||||
import galleryDnd from "./galleryDnd";
|
||||
|
||||
export const GalleryDragComponent: React.FC = () => {
|
||||
const [dragState, setDragState] = useState(galleryDnd.getDragState());
|
||||
|
||||
useEffect(() => {
|
||||
let frame: number;
|
||||
function update() {
|
||||
setDragState({ ...galleryDnd.getDragState() });
|
||||
frame = requestAnimationFrame(update);
|
||||
}
|
||||
frame = requestAnimationFrame(update);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, []);
|
||||
|
||||
if (!dragState.isDragging || !dragState.item) return null;
|
||||
|
||||
const { currX, currY, item } = dragState;
|
||||
const size = 120;
|
||||
const style: React.CSSProperties = {
|
||||
left: currX + 1,
|
||||
top: currY - size / 2,
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={style}
|
||||
className="fixed z-[1000] w-[120px] h-[120px] pointer-events-none aspect-square flex flex-col bg-black/70 rounded-xl overflow-hidden shadow-lg cursor-grabbing"
|
||||
>
|
||||
<img
|
||||
src={item.thumbnail || item.fullImage || ""}
|
||||
alt={item.label}
|
||||
className="pointer-events-none select-none w-full h-full object-cover rounded-xl"
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
/**
|
||||
* The floating thumbnail that follows the cursor while dragging a gallery item.
|
||||
* Thin wrapper that feeds the shared {@link DragGhost} from galleryDnd's state.
|
||||
*/
|
||||
export const GalleryDragComponent: React.FC = () => (
|
||||
<DragGhost
|
||||
width={120}
|
||||
height={120}
|
||||
getState={() => {
|
||||
const state = galleryDnd.getDragState();
|
||||
if (!state.isDragging || !state.item) return null;
|
||||
return {
|
||||
x: state.currX,
|
||||
y: state.currY,
|
||||
thumbnail: state.item.thumbnail || state.item.fullImage || "",
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -68,8 +68,10 @@ export const GalleryFolderChip: React.FC<GalleryFolderChipProps> = ({
|
||||
}}
|
||||
style={colorCode ? { borderColor: colorCode } : undefined}
|
||||
className={twMerge(
|
||||
"group/chip relative w-full aspect-square overflow-hidden rounded-md border-2 border-ui-controls/40 bg-ui-controls/20 transition-colors cursor-pointer hover:border-primary/60",
|
||||
"[&.folder-drag-over]:border-primary/60 [&.folder-drag-over]:bg-primary/20",
|
||||
"group/chip relative w-full aspect-square overflow-hidden rounded-md border-2 border-ui-controls/40 bg-ui-controls/20 transition-[transform,border-color,background-color,box-shadow] duration-200 ease-out cursor-pointer hover:border-primary/60",
|
||||
// A dragged item hovering the folder makes it lift and glow — clear,
|
||||
// tactile "drop here" feedback rather than a flat color swap.
|
||||
"[&.folder-drag-over]:scale-[1.04] [&.folder-drag-over]:border-primary [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:ring-2 [&.folder-drag-over]:ring-primary/40",
|
||||
)}
|
||||
aria-label={name}
|
||||
>
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
PLACEHOLDER_IMAGES,
|
||||
} from "@storyteller/common";
|
||||
import {
|
||||
galleryModalVisibleDuringDrag,
|
||||
galleryModalDraggingUnder,
|
||||
galleryReopenAfterDragSignal,
|
||||
galleryModalVisibleViewMode,
|
||||
galleryModalLightboxMediaId,
|
||||
@@ -2125,10 +2125,23 @@ export const GalleryModal = React.memo(
|
||||
<>
|
||||
<Modal
|
||||
resizable={mode === "view"}
|
||||
// While dragging an item out, the panel becomes pointer-transparent so
|
||||
// the drag passes under it onto the canvas. With "Reopen after adding"
|
||||
// ON it steps back to translucent (stays to keep adding); with it OFF
|
||||
// it eases all the way out (it's closing after the drop anyway). Both
|
||||
// go through the same spring, so neither is abrupt.
|
||||
contentInteractive={!galleryModalDraggingUnder.value}
|
||||
contentDimmed={
|
||||
galleryModalDraggingUnder.value &&
|
||||
galleryReopenAfterDragSignal.value
|
||||
}
|
||||
contentHidden={
|
||||
galleryModalDraggingUnder.value &&
|
||||
!galleryReopenAfterDragSignal.value
|
||||
}
|
||||
isOpen={
|
||||
mode === "view"
|
||||
? galleryModalVisibleViewMode.value &&
|
||||
galleryModalVisibleDuringDrag.value
|
||||
? galleryModalVisibleViewMode.value
|
||||
: typeof isOpen === "boolean"
|
||||
? isOpen
|
||||
: true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { GalleryItem } from "./gallery-modal";
|
||||
import {
|
||||
galleryModalVisibleDuringDrag,
|
||||
galleryModalDraggingUnder,
|
||||
galleryModalVisibleViewMode,
|
||||
galleryReopenAfterDragSignal,
|
||||
} from "./galleryModalSignals";
|
||||
|
||||
@@ -12,7 +13,6 @@ interface DragState {
|
||||
startY: number;
|
||||
currX: number;
|
||||
currY: number;
|
||||
modalHidden: boolean;
|
||||
}
|
||||
|
||||
const dragState: DragState = {
|
||||
@@ -23,7 +23,6 @@ const dragState: DragState = {
|
||||
startY: 0,
|
||||
currX: 0,
|
||||
currY: 0,
|
||||
modalHidden: false,
|
||||
};
|
||||
|
||||
const dragThreshold = 5;
|
||||
@@ -73,6 +72,40 @@ function removeDragPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drop-success ripple ──────────────────────────────────────────────────────
|
||||
// A quick brand-colored ring that expands and fades at the drop point, so a
|
||||
// successful drop reads as a deliberate landing instead of the item silently
|
||||
// vanishing. Self-removing; skipped under reduced-motion.
|
||||
function spawnDropRipple(x: number, y: number) {
|
||||
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
const ring = document.createElement("div");
|
||||
ring.style.cssText = `
|
||||
position: fixed;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
z-index: 99998;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-left: -15px;
|
||||
margin-top: -15px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid var(--primary, #2d81ff);
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(ring);
|
||||
|
||||
const anim = ring.animate(
|
||||
[
|
||||
{ transform: "scale(0.4)", opacity: 0.9 },
|
||||
{ transform: "scale(2.6)", opacity: 0 },
|
||||
],
|
||||
{ duration: 460, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
|
||||
);
|
||||
anim.onfinish = () => ring.remove();
|
||||
anim.oncancel = () => ring.remove();
|
||||
}
|
||||
|
||||
// ── Drag lifecycle ───────────────────────────────────────────────────────────
|
||||
|
||||
function onPointerDown(
|
||||
@@ -88,12 +121,35 @@ function onPointerDown(
|
||||
dragState.currX = event.pageX;
|
||||
dragState.currY = event.pageY;
|
||||
dragState.isDragging = false;
|
||||
dragState.modalHidden = false;
|
||||
document.body.style.cursor = "grabbing";
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
window.addEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* The folder chip under a screen point, found geometrically. We can't use
|
||||
* `elementFromPoint` here: once a drag begins the modal goes pointer-transparent
|
||||
* (so the drag can reach the canvas), which would make hit-testing skip the
|
||||
* folder chips entirely.
|
||||
*/
|
||||
function folderIdAt(clientX: number, clientY: number): string | null {
|
||||
const chips = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-folder-id]"),
|
||||
);
|
||||
for (const chip of chips) {
|
||||
const rect = chip.getBoundingClientRect();
|
||||
if (
|
||||
clientX >= rect.left &&
|
||||
clientX <= rect.right &&
|
||||
clientY >= rect.top &&
|
||||
clientY <= rect.bottom
|
||||
) {
|
||||
return chip.getAttribute("data-folder-id");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!dragState.item) return;
|
||||
const deltaX = event.pageX - dragState.startX;
|
||||
@@ -104,39 +160,27 @@ function onPointerMove(event: PointerEvent) {
|
||||
) {
|
||||
dragState.isDragging = true;
|
||||
createDragPreview(dragState.items.length);
|
||||
// Let pointer events pass through the gallery the moment the drag begins, so
|
||||
// the item can be dropped anywhere — including under the modal. The panel
|
||||
// eases to translucent (reopen on) or all the way out (reopen off) via the
|
||||
// Modal's contentDimmed/contentHidden, so the transition is never abrupt.
|
||||
galleryModalDraggingUnder.value = true;
|
||||
}
|
||||
dragState.currX = event.pageX;
|
||||
dragState.currY = event.pageY;
|
||||
|
||||
if (dragState.isDragging) {
|
||||
updateDragPreviewPosition(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
if (dragState.isDragging && !dragState.modalHidden) {
|
||||
// Check if cursor left the modal area - if so, hide modal for scene drop
|
||||
const modalEl = document.querySelector("[data-gallery-modal]");
|
||||
if (modalEl) {
|
||||
const rect = modalEl.getBoundingClientRect();
|
||||
const isOutside =
|
||||
event.clientX < rect.left ||
|
||||
event.clientX > rect.right ||
|
||||
event.clientY < rect.top ||
|
||||
event.clientY > rect.bottom;
|
||||
if (isOutside) {
|
||||
dragState.modalHidden = true;
|
||||
galleryModalVisibleDuringDrag.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update folder hover indicators while inside the modal
|
||||
if (!dragState.modalHidden) {
|
||||
const folderEl = document
|
||||
.elementFromPoint(event.clientX, event.clientY)
|
||||
?.closest("[data-folder-id]");
|
||||
document.querySelectorAll("[data-folder-id]").forEach((el) => {
|
||||
el.classList.toggle("folder-drag-over", el === folderEl);
|
||||
});
|
||||
}
|
||||
// Highlight the folder under the cursor (if any) — folders still accept
|
||||
// drops even while the gallery is dimmed.
|
||||
const overFolder = folderIdAt(event.clientX, event.clientY);
|
||||
document.querySelectorAll("[data-folder-id]").forEach((el) => {
|
||||
el.classList.toggle(
|
||||
"folder-drag-over",
|
||||
el.getAttribute("data-folder-id") === overFolder,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,25 +231,28 @@ export function removeFolderDropListener(handler: (e: any) => void) {
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
const wasModalHidden = dragState.modalHidden;
|
||||
// When closing after an add (reopen off), keep `draggingUnder` true so the
|
||||
// panel stays faded-out through the close — clearing it would race the close
|
||||
// animation and flash the hidden panel back up.
|
||||
let closedHidden = false;
|
||||
|
||||
if (dragState.item && dragState.isDragging) {
|
||||
if (!dragState.modalHidden) {
|
||||
// Still inside modal - check for folder drop
|
||||
const folderEl = document
|
||||
.elementFromPoint(event.clientX, event.clientY)
|
||||
?.closest("[data-folder-id]");
|
||||
if (folderEl) {
|
||||
const folderId = folderEl.getAttribute("data-folder-id")!;
|
||||
emitFolderDrop(dragState.items, folderId);
|
||||
}
|
||||
} else {
|
||||
// Modal was hidden - scene drop (existing behavior)
|
||||
if (
|
||||
dragState.item.mediaClass === "image" ||
|
||||
dragState.item.mediaClass === "dimensional"
|
||||
) {
|
||||
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
|
||||
const folderId = folderIdAt(event.clientX, event.clientY);
|
||||
if (folderId) {
|
||||
// Dropped onto a folder — organize, and keep the gallery open.
|
||||
emitFolderDrop(dragState.items, folderId);
|
||||
spawnDropRipple(event.clientX, event.clientY);
|
||||
} else if (
|
||||
dragState.item.mediaClass === "image" ||
|
||||
dragState.item.mediaClass === "dimensional"
|
||||
) {
|
||||
// Dropped onto the canvas (anywhere not on a folder) — add to the scene.
|
||||
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
|
||||
spawnDropRipple(event.clientX, event.clientY);
|
||||
// Close the gallery after adding unless the user asked it to stay open.
|
||||
if (!galleryReopenAfterDragSignal.value) {
|
||||
galleryModalVisibleViewMode.value = false;
|
||||
closedHidden = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,10 +267,11 @@ function onPointerUp(event: PointerEvent) {
|
||||
dragState.item = null;
|
||||
dragState.items = [];
|
||||
dragState.isDragging = false;
|
||||
dragState.modalHidden = false;
|
||||
|
||||
if (wasModalHidden) {
|
||||
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
|
||||
// Refocus the gallery (un-dim, restore pointer events) — unless it's closing,
|
||||
// in which case it stays faded until reopened (reset on open).
|
||||
if (!closedHidden) {
|
||||
galleryModalDraggingUnder.value = false;
|
||||
}
|
||||
|
||||
document.body.style.cursor = "";
|
||||
|
||||
@@ -4,6 +4,11 @@ import { signal } from "@preact/signals-react";
|
||||
export const galleryModalVisibleDuringDrag = signal(true);
|
||||
export const galleryReopenAfterDragSignal = signal(false);
|
||||
export const galleryModalVisibleViewMode = signal(false);
|
||||
// True while an item is being dragged out of the gallery. The modal stays open
|
||||
// but goes translucent and pointer-transparent so the drag can pass "under" it
|
||||
// onto the canvas; it refocuses (or closes, per the reopen-after-adding setting)
|
||||
// on drop.
|
||||
export const galleryModalDraggingUnder = signal(false);
|
||||
|
||||
// Lightbox Modal Signals
|
||||
export const galleryModalLightboxMediaId = signal<string | null>(null);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IconDefinition } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { CloseButton } from "@storyteller/ui-close-button";
|
||||
import { useRef, useState, useEffect, useContext, createContext } from "react";
|
||||
import { cloneElement, isValidElement } from "react";
|
||||
import { useTransition, animated } from "@react-spring/web";
|
||||
import { useTransition, useSpring, to, animated } from "@react-spring/web";
|
||||
import {
|
||||
faUpRightAndDownLeftFromCenter,
|
||||
faDownLeftAndUpRightToCenter,
|
||||
@@ -183,6 +183,9 @@ export const Modal = ({
|
||||
closeOnOutsideClick = true,
|
||||
closeOnEsc = true,
|
||||
allowBackgroundInteraction = false,
|
||||
contentInteractive = true,
|
||||
contentDimmed = false,
|
||||
contentHidden = false,
|
||||
expandable = false,
|
||||
accessibleTitle,
|
||||
accessibleDescription,
|
||||
@@ -207,6 +210,28 @@ export const Modal = ({
|
||||
closeOnOutsideClick?: boolean;
|
||||
closeOnEsc?: boolean;
|
||||
allowBackgroundInteraction?: boolean;
|
||||
/**
|
||||
* When false, the modal's content panel ignores pointer events (events fall
|
||||
* through to whatever is behind it). Lets a caller make the panel a passive,
|
||||
* see-through overlay — e.g. the gallery going translucent so a drag can pass
|
||||
* under it onto the canvas. Defaults to true.
|
||||
*/
|
||||
contentInteractive?: boolean;
|
||||
/**
|
||||
* When true, fades the content panel down (transition-eased). Applied to an
|
||||
* inner wrapper so it composes with — rather than fights — the spring opacity
|
||||
* the modal animates on open/close. Pair with `contentInteractive={false}` for
|
||||
* a passive, see-through overlay. Defaults to false.
|
||||
*/
|
||||
contentDimmed?: boolean;
|
||||
/**
|
||||
* When true, eases the whole panel all the way out (opacity → 0, slightly more
|
||||
* scale-down) through the same spring as `contentDimmed`, so a "hide it" reads
|
||||
* as a smooth step-away rather than an abrupt close. The panel stays mounted;
|
||||
* the caller flips `isOpen` once it's faded if it wants to fully unmount.
|
||||
* Takes precedence over `contentDimmed`. Defaults to false.
|
||||
*/
|
||||
contentHidden?: boolean;
|
||||
expandable?: boolean;
|
||||
accessibleTitle?: string;
|
||||
accessibleDescription?: string;
|
||||
@@ -250,6 +275,17 @@ export const Modal = ({
|
||||
},
|
||||
});
|
||||
|
||||
// Smoothly drives the `contentDimmed`/`contentHidden` "step back": the panel
|
||||
// eases to a target opacity factor (1 = shown, 0.4 = translucent, 0 = hidden)
|
||||
// with a paired scale-down, so going translucent or hiding reads as a
|
||||
// deliberate recede rather than a flicker or an abrupt cut. easeOutCubic over
|
||||
// 280ms: responsive on the way down, calm as it settles.
|
||||
const dimFactor = contentHidden ? 0 : contentDimmed ? 0.4 : 1;
|
||||
const dimSpring = useSpring({
|
||||
factor: dimFactor,
|
||||
config: { duration: 280, easing: (t: number) => 1 - Math.pow(1 - t, 3) },
|
||||
});
|
||||
|
||||
const backdropTransitions = useTransition(isOpen, {
|
||||
from: { opacity: 0 },
|
||||
enter: { opacity: 1 },
|
||||
@@ -872,6 +908,16 @@ export const Modal = ({
|
||||
}, [isOpen, zIndex, onClose, closeOnEsc]);
|
||||
|
||||
const getModalStyle = (): React.CSSProperties => {
|
||||
// Pointer behavior of the content panel: explicitly off when the caller
|
||||
// wants it passive (drag-through); otherwise re-enabled when the rest of the
|
||||
// screen is click-through (allowBackgroundInteraction makes the wrappers
|
||||
// pointer-none, so the panel needs pointer-auto to stay interactive).
|
||||
const pointerStyle: React.CSSProperties = !contentInteractive
|
||||
? { pointerEvents: "none" }
|
||||
: allowBackgroundInteraction
|
||||
? { pointerEvents: "auto" }
|
||||
: {};
|
||||
|
||||
// fill the entire viewport even if theres a stored size or position
|
||||
if (expanded) {
|
||||
return {
|
||||
@@ -882,7 +928,7 @@ export const Modal = ({
|
||||
zIndex,
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
|
||||
...pointerStyle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -892,7 +938,7 @@ export const Modal = ({
|
||||
...(resizable && size
|
||||
? { width: size.width, height: size.height }
|
||||
: {}),
|
||||
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
|
||||
...pointerStyle,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -902,7 +948,7 @@ export const Modal = ({
|
||||
margin: 0,
|
||||
zIndex,
|
||||
...(resizable && size ? { width: size.width, height: size.height } : {}),
|
||||
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
|
||||
...pointerStyle,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -985,14 +1031,35 @@ export const Modal = ({
|
||||
ref={modalRef}
|
||||
style={{
|
||||
...getModalStyle(),
|
||||
opacity: styles.opacity,
|
||||
transform: styles.transform,
|
||||
// The dim/hide factor eases the whole panel (background
|
||||
// included): translucent (0.4) or hidden (0), with a
|
||||
// paired scale-down. Composed with the open/close spring
|
||||
// so both animations coexist (factor multiplies opacity;
|
||||
// the scale-down is appended to the spring transform).
|
||||
opacity: to(
|
||||
[styles.opacity, dimSpring.factor],
|
||||
(o, f) => (o as number) * (f as number),
|
||||
),
|
||||
transform: to(
|
||||
[styles.transform, dimSpring.factor],
|
||||
(tr, f) => `${tr} scale(${0.93 + 0.07 * (f as number)})`,
|
||||
),
|
||||
transformOrigin: "center center",
|
||||
willChange: "transform, opacity", // Optimize for animations
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
<div className="w-full h-full">
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-full h-full",
|
||||
// When dimmed/hiding for a drag-under, fade out media so
|
||||
// the colored thumbnails don't block the view of
|
||||
// whatever's behind the translucent panel — the layout
|
||||
// (faint card outlines) stays as a placeholder.
|
||||
(contentDimmed || contentHidden) &&
|
||||
"[&_img]:opacity-0 [&_video]:opacity-0 [&_img]:transition-opacity [&_video]:transition-opacity [&_img]:duration-200 [&_video]:duration-200",
|
||||
)}
|
||||
>
|
||||
{accessibleDescription && (
|
||||
<VisuallyHidden asChild>
|
||||
<Dialog.Description>
|
||||
|
||||
@@ -125,6 +125,9 @@ export const PaintSurface = ({
|
||||
const mouseMoveThrottle = React.useRef(-1);
|
||||
const imageRef = React.useRef<Konva.Image>(null);
|
||||
const leftPanelRef = React.useRef<Konva.Layer>(null);
|
||||
// Tracks which draw-node ids we've already shown, so only freshly added
|
||||
// images/shapes get the entrance fade — not the whole canvas on scene restore.
|
||||
const seenNodeIdsRef = React.useRef<Set<string> | null>(null);
|
||||
const rightContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const cursorLayerRef = React.useRef<Konva.Layer>(null);
|
||||
const cursorShapeRef = React.useRef<Konva.Circle>(null);
|
||||
@@ -1446,6 +1449,46 @@ export const PaintSurface = ({
|
||||
[activeTool, drawNodes, inpaintLineNodes, selectedNodeIds, transformerRefs],
|
||||
);
|
||||
|
||||
// Entrance fade for newly added images/shapes. Opacity is the only safe
|
||||
// channel here: x/y/scale are controlled props that react-konva would reset
|
||||
// on the next render, but opacity is left untouched, so an imperative tween
|
||||
// survives re-renders. Strokes ("line") are skipped — a drawn line shouldn't
|
||||
// fade in. The first pass just records existing ids so a restored scene
|
||||
// doesn't replay the entrance for every object at once.
|
||||
useLayoutEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
const currentIds = new Set(drawNodes.map((n) => n.id));
|
||||
|
||||
if (seenNodeIdsRef.current === null) {
|
||||
seenNodeIdsRef.current = currentIds;
|
||||
return;
|
||||
}
|
||||
|
||||
const reduceMotion = window.matchMedia?.(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
if (!reduceMotion) {
|
||||
const seen = seenNodeIdsRef.current;
|
||||
for (const node of drawNodes) {
|
||||
if (node.type === "line" || seen.has(node.id)) continue;
|
||||
const konvaNode = stage.findOne<Konva.Node>(`#${node.id}`);
|
||||
if (!konvaNode) continue;
|
||||
konvaNode.opacity(0);
|
||||
new Konva.Tween({
|
||||
node: konvaNode,
|
||||
opacity: 1,
|
||||
duration: 0.26,
|
||||
easing: Konva.Easings.EaseOut,
|
||||
}).play();
|
||||
}
|
||||
}
|
||||
|
||||
seenNodeIdsRef.current = currentIds;
|
||||
}, [drawNodes, stageRef]);
|
||||
|
||||
// Load the checkboard image for transparency background
|
||||
useEffect(() => {
|
||||
loadImageFromUrl(checkerboard).then(setCheckerImage);
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
.pegboard-bg {
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
/* Themeable canvas backdrop; hosts may set --st-canvas, otherwise the
|
||||
original dark grey is used. */
|
||||
background-color: var(--st-canvas, #101014);
|
||||
background-size: 50px 50px;
|
||||
background-position:
|
||||
0 0,
|
||||
15px 15px;
|
||||
/* Soft center lift + edge vignette so the workspace reads as a lit stage
|
||||
with depth instead of a flat slab. Static and GPU-cheap (no animation). */
|
||||
background-color: #1b1b1b;
|
||||
background-image:
|
||||
radial-gradient(
|
||||
130% 100% at 50% 28%,
|
||||
rgba(255, 255, 255, 0.045) 0%,
|
||||
rgba(255, 255, 255, 0) 50%
|
||||
),
|
||||
radial-gradient(
|
||||
120% 120% at 50% 125%,
|
||||
rgba(0, 0, 0, 0.5) 0%,
|
||||
rgba(0, 0, 0, 0) 55%
|
||||
);
|
||||
}
|
||||
|
||||
/* react-colorful overrides for the brush color picker in SideToolbar */
|
||||
|
||||
@@ -47,7 +47,6 @@ class DndAsset {
|
||||
this.isDragging = false;
|
||||
store.setCanDrop(false);
|
||||
this.notDropText = "";
|
||||
store.setAssetModalVisibleDuringDrag(false);
|
||||
window.addEventListener("pointerup", this.onPointerUp);
|
||||
window.addEventListener("pointermove", this.onPointerMove);
|
||||
}
|
||||
@@ -60,8 +59,17 @@ class DndAsset {
|
||||
store.setCanDrop(false);
|
||||
this.overElement = null;
|
||||
this.notDropText = "";
|
||||
store.setAssetModalVisibleDuringDrag(store.reopenAfterDrag);
|
||||
}
|
||||
if (store.reopenAfterDrag) {
|
||||
// Refocus the library (un-dim, restore pointer events).
|
||||
store.setAssetDraggingUnder(false);
|
||||
} else {
|
||||
// Close it after adding. Keep `assetDraggingUnder` true so the panel stays
|
||||
// faded-out through the close instead of flashing back up; it's reset when
|
||||
// the library is reopened.
|
||||
store.setAssetModalVisible(false);
|
||||
}
|
||||
this.isDragging = false;
|
||||
this.editor = null;
|
||||
}
|
||||
|
||||
@@ -88,7 +96,7 @@ class DndAsset {
|
||||
|
||||
const store = usePageSceneStore.getState();
|
||||
if (!this.isDragging) {
|
||||
store.setAssetModalVisibleDuringDrag(true);
|
||||
// A click, not a drag — leave the library as-is, just clear drag state.
|
||||
store.setDragItem(null);
|
||||
store.setDragPosition({ currX: 0, currY: 0 });
|
||||
this.editor = null;
|
||||
@@ -137,10 +145,15 @@ class DndAsset {
|
||||
const deltaX = event.pageX - this.initX;
|
||||
const deltaY = event.pageY - this.initY;
|
||||
if (
|
||||
Math.abs(deltaX) > this.dragThreshold ||
|
||||
Math.abs(deltaY) > this.dragThreshold
|
||||
!this.isDragging &&
|
||||
(Math.abs(deltaX) > this.dragThreshold ||
|
||||
Math.abs(deltaY) > this.dragThreshold)
|
||||
) {
|
||||
this.isDragging = true;
|
||||
// Let pointer events pass through the library the moment the drag begins.
|
||||
// The panel eases to translucent (reopen on) or all the way out (reopen
|
||||
// off) via AssetModal's contentDimmed/contentHidden — never abrupt.
|
||||
store.setAssetDraggingUnder(true);
|
||||
}
|
||||
store.setDragPosition({
|
||||
currX: this.initX + deltaX,
|
||||
|
||||
@@ -145,6 +145,10 @@ interface PageSceneState {
|
||||
// layout / panels
|
||||
assetModalVisible: boolean;
|
||||
assetModalVisibleDuringDrag: boolean;
|
||||
// True while an asset is being dragged out of the library modal. The modal
|
||||
// stays open but goes pointer-transparent (and translucent when reopen is on)
|
||||
// so the drag passes under it onto the canvas.
|
||||
assetDraggingUnder: boolean;
|
||||
reopenAfterDrag: boolean;
|
||||
|
||||
// overlays
|
||||
@@ -239,6 +243,7 @@ interface PageSceneState {
|
||||
// layout
|
||||
setAssetModalVisible: (visible: boolean) => void;
|
||||
setAssetModalVisibleDuringDrag: (visible: boolean) => void;
|
||||
setAssetDraggingUnder: (dragging: boolean) => void;
|
||||
setReopenAfterDrag: (reopen: boolean) => void;
|
||||
|
||||
// overlays
|
||||
@@ -318,6 +323,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
|
||||
|
||||
assetModalVisible: false,
|
||||
assetModalVisibleDuringDrag: true,
|
||||
assetDraggingUnder: false,
|
||||
reopenAfterDrag: false,
|
||||
|
||||
editorLoader: { isShowing: false, message: "Loading Editor Engine 🦊" },
|
||||
@@ -430,6 +436,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
|
||||
setAssetModalVisible: (visible) => set({ assetModalVisible: visible }),
|
||||
setAssetModalVisibleDuringDrag: (visible) =>
|
||||
set({ assetModalVisibleDuringDrag: visible }),
|
||||
setAssetDraggingUnder: (dragging) => set({ assetDraggingUnder: dragging }),
|
||||
setReopenAfterDrag: (reopen) => set({ reopenAfterDrag: reopen }),
|
||||
|
||||
// overlays actions
|
||||
|
||||
@@ -51,6 +51,7 @@ import { EditorCanvas } from "./comps/EngineCanvases";
|
||||
import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay";
|
||||
import { OnboardingHelper } from "./comps/OnboardingHelper";
|
||||
import { PerfStatsOverlay } from "./comps/PerfStatsOverlay";
|
||||
import { EntranceDebugPanel } from "./comps/EntranceDebugPanel";
|
||||
import { Outliner } from "./comps/Outliner";
|
||||
import { PoseModeSelector } from "./comps/PoseModeSelector";
|
||||
import { PreviewBox } from "./comps/PreviewBox";
|
||||
@@ -433,6 +434,7 @@ export const Stage3DBody = ({
|
||||
</SceneContainer>
|
||||
|
||||
<PerfStatsOverlay />
|
||||
{import.meta.env.DEV && <EntranceDebugPanel />}
|
||||
<FocalLengthDisplay />
|
||||
<PoseModeSelector />
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function addCharacter(
|
||||
|
||||
obj.userData.isCharacter = true;
|
||||
|
||||
// Animate it in (see addObject).
|
||||
editor.entranceAnimator.play(obj);
|
||||
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
|
||||
editor.bus.emit(
|
||||
|
||||
@@ -16,6 +16,11 @@ export async function addObject(
|
||||
);
|
||||
if (!obj) return undefined;
|
||||
|
||||
// Animate it in (mask-reveal for meshes, per-gaussian radial settle for
|
||||
// splats). Runs synchronously before the next render frame, so the object is
|
||||
// never shown fully-formed first.
|
||||
editor.entranceAnimator.play(obj);
|
||||
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
|
||||
editor.bus.emit(
|
||||
|
||||
@@ -26,6 +26,9 @@ export async function addShape(
|
||||
obj.userData.color = "#ffffff";
|
||||
obj.name = item.name ?? "shape";
|
||||
|
||||
// Animate it in (see addObject).
|
||||
editor.entranceAnimator.play(obj);
|
||||
|
||||
editor.history.record(new CreateAction(editor, obj));
|
||||
|
||||
editor.bus.emit(
|
||||
|
||||
@@ -9,8 +9,12 @@ export const AssetMenu = () => {
|
||||
const [, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleOpenModal = () => {
|
||||
usePageSceneStore.getState().setAssetModalVisibleDuringDrag(true);
|
||||
usePageSceneStore.getState().setAssetModalVisible(true);
|
||||
const store = usePageSceneStore.getState();
|
||||
store.setAssetModalVisibleDuringDrag(true);
|
||||
store.setAssetModalVisible(true);
|
||||
// Clear any leftover drag-under state so the panel opens fully shown (a
|
||||
// reopen-off drag leaves it faded-hidden until the next open).
|
||||
store.setAssetDraggingUnder(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -106,14 +106,14 @@ export const AssetModal = () => {
|
||||
|
||||
const {
|
||||
assetModalVisible,
|
||||
assetModalVisibleDuringDrag,
|
||||
assetDraggingUnder,
|
||||
reopenAfterDrag,
|
||||
setAssetModalVisible,
|
||||
setReopenAfterDrag,
|
||||
} = usePageSceneStore(
|
||||
useShallow((s) => ({
|
||||
assetModalVisible: s.assetModalVisible,
|
||||
assetModalVisibleDuringDrag: s.assetModalVisibleDuringDrag,
|
||||
assetDraggingUnder: s.assetDraggingUnder,
|
||||
reopenAfterDrag: s.reopenAfterDrag,
|
||||
setAssetModalVisible: s.setAssetModalVisible,
|
||||
setReopenAfterDrag: s.setReopenAfterDrag,
|
||||
@@ -142,6 +142,15 @@ export const AssetModal = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (assetModalVisible) {
|
||||
// Whenever the modal becomes visible, clear any leftover drag-under state.
|
||||
// A reopen-off drag closes the panel while keeping `assetDraggingUnder`
|
||||
// true (so its fade-out doesn't flash), so without this a later reopen
|
||||
// would render straight back into the hidden state. Resetting here is the
|
||||
// single safety net covering every open path (toolbar +, asset menu,
|
||||
// upload-reopen). It can't clobber an active drag: `assetModalVisible`
|
||||
// doesn't change mid-drag, so this effect doesn't re-run then.
|
||||
usePageSceneStore.getState().setAssetDraggingUnder(false);
|
||||
|
||||
const lastUploadedTab = sessionStorage.getItem("lastUploadedTab");
|
||||
if (lastUploadedTab) {
|
||||
setActiveAssetTab(lastUploadedTab);
|
||||
@@ -457,7 +466,7 @@ export const AssetModal = () => {
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={assetModalVisible && assetModalVisibleDuringDrag}
|
||||
isOpen={assetModalVisible}
|
||||
onClose={handleClose}
|
||||
className="relative h-[640px] max-w-4xl"
|
||||
childPadding={false}
|
||||
@@ -466,6 +475,13 @@ export const AssetModal = () => {
|
||||
draggable={true}
|
||||
closeOnOutsideClick={false}
|
||||
allowBackgroundInteraction={true}
|
||||
// While dragging an asset out, the panel becomes pointer-transparent so
|
||||
// the drag passes under it onto the canvas. With "Reopen after adding"
|
||||
// on it steps back to translucent; with it off it eases all the way out
|
||||
// (closing after the drop). Same drag-under behavior as the gallery.
|
||||
contentInteractive={!assetDraggingUnder}
|
||||
contentDimmed={assetDraggingUnder && reopenAfterDrag}
|
||||
contentHidden={assetDraggingUnder && !reopenAfterDrag}
|
||||
>
|
||||
<Modal.DragHandle>
|
||||
<div className="absolute left-0 top-0 z-[50] h-[46px] w-full cursor-move" />
|
||||
|
||||
@@ -20,6 +20,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
galleryModalVisibleViewMode,
|
||||
galleryModalVisibleDuringDrag,
|
||||
galleryModalDraggingUnder,
|
||||
} from "@storyteller/ui-gallery-modal";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -43,6 +44,7 @@ export const Controls3D = ({
|
||||
assetModalVisible,
|
||||
setAssetModalVisible,
|
||||
setAssetModalVisibleDuringDrag,
|
||||
setAssetDraggingUnder,
|
||||
selectedMode,
|
||||
transformSpace,
|
||||
currentUserToken,
|
||||
@@ -52,6 +54,7 @@ export const Controls3D = ({
|
||||
assetModalVisible: s.assetModalVisible,
|
||||
setAssetModalVisible: s.setAssetModalVisible,
|
||||
setAssetModalVisibleDuringDrag: s.setAssetModalVisibleDuringDrag,
|
||||
setAssetDraggingUnder: s.setAssetDraggingUnder,
|
||||
selectedMode: s.selectedMode,
|
||||
transformSpace: s.transformSpace,
|
||||
currentUserToken: s.currentUserToken,
|
||||
@@ -100,6 +103,9 @@ export const Controls3D = ({
|
||||
const handleOpenModal = () => {
|
||||
setAssetModalVisibleDuringDrag(true);
|
||||
setAssetModalVisible(true);
|
||||
// Clear any leftover drag-under state so the panel opens fully shown (a
|
||||
// reopen-off drag leaves it faded-hidden until the next open).
|
||||
setAssetDraggingUnder(false);
|
||||
};
|
||||
|
||||
const handleOpenCreate3dModal = () => {
|
||||
@@ -109,6 +115,9 @@ export const Controls3D = ({
|
||||
const handleOpenGalleryModal = () => {
|
||||
galleryModalVisibleViewMode.value = true;
|
||||
galleryModalVisibleDuringDrag.value = true;
|
||||
// Clear any leftover drag-under state so the panel opens fully shown (a
|
||||
// reopen-off drag leaves it faded-hidden until the next open).
|
||||
galleryModalDraggingUnder.value = false;
|
||||
};
|
||||
|
||||
const handleAddAssetAction = (action: string) => {
|
||||
|
||||
@@ -1,53 +1,29 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { DragGhost } from "@storyteller/ui-gallery-modal";
|
||||
import { usePageSceneStore } from "../../PageSceneStore";
|
||||
|
||||
// Portaled to <body> so the chip stays anchored to the page (using the
|
||||
// document-coord pageX/pageY values stored in dragPosition) regardless
|
||||
// of any positioned/`transform`ed ancestor the host wraps Stage3D in.
|
||||
// Without the portal, hosts whose Stage3D wrapper is a containing
|
||||
// block (e.g. the webapp's wrapper uses `transform: translateZ(0)` to
|
||||
// scope the lib's fixed overlays) push the chip away from the cursor
|
||||
// by the wrapper's offset. Tauri has no such wrapper so the portal is
|
||||
// a no-op for it.
|
||||
export const DragComponent = () => {
|
||||
const dragItem = usePageSceneStore((s) => s.dragItem);
|
||||
const dragPosition = usePageSceneStore((s) => s.dragPosition);
|
||||
|
||||
if (!dragItem) {
|
||||
return null;
|
||||
}
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
const { currX, currY } = dragPosition;
|
||||
|
||||
const thumbnail = dragItem.thumbnail
|
||||
? dragItem.thumbnail
|
||||
: `/resources/images/default-covers/${dragItem.imageIndex || 0}.webp`;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="pointer-events-none absolute rounded-lg"
|
||||
style={{
|
||||
width: 91,
|
||||
height: 114,
|
||||
top: currY - 57,
|
||||
left: currX + 1,
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
{...{
|
||||
crossOrigin: "anonymous",
|
||||
src: thumbnail,
|
||||
}}
|
||||
alt={dragItem.name}
|
||||
className="pointer-events-none select-none rounded-t-lg bg-gradient-to-b from-[#CCCCCC] to-[#A0A0A0]"
|
||||
/>
|
||||
<div className="w-full truncate rounded-b-lg bg-ui-controls px-2 py-1 text-center text-[12px]">
|
||||
{dragItem.name || dragItem.media_id}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
// The floating chip shown while dragging an asset out of the library. Feeds the
|
||||
// shared DragGhost (lift/tilt/follow) from the pagescene store, so the asset
|
||||
// library gets the exact same pickup motion as the gallery. Gated on
|
||||
// `assetDraggingUnder` so it only appears once a real drag begins (not on a
|
||||
// plain click, which sets dragItem on pointer-down).
|
||||
export const DragComponent = () => (
|
||||
<DragGhost
|
||||
width={96}
|
||||
height={120}
|
||||
crossOrigin="anonymous"
|
||||
getState={() => {
|
||||
const { dragItem, dragPosition, assetDraggingUnder } =
|
||||
usePageSceneStore.getState();
|
||||
if (!dragItem || !assetDraggingUnder) return null;
|
||||
const thumbnail =
|
||||
dragItem.thumbnail ||
|
||||
`/resources/images/default-covers/${dragItem.imageIndex || 0}.webp`;
|
||||
return {
|
||||
x: dragPosition.currX,
|
||||
y: dragPosition.currY,
|
||||
thumbnail,
|
||||
label: dragItem.name || dragItem.media_id,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Dev-only floating control for tuning the drop-in entrance animation timing
|
||||
// without re-editing constants. Mounted only under import.meta.env.DEV in
|
||||
// Stage3DBody. Sets the shared `entranceDebugSettings.durationScale`, which the
|
||||
// EntranceAnimator reads when a new object is dropped — so move the slider, then
|
||||
// drop an object to see it at that speed.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ENTRANCE_BASE_DURATIONS,
|
||||
entranceDebugSettings,
|
||||
} from "../engine/entranceAnimation";
|
||||
|
||||
// The preferred timing is now baked into ENTRANCE_BASE_DURATIONS, so the panel
|
||||
// starts at 1× (real) and just lets you scale around it while tuning.
|
||||
const DEFAULT_SCALE = 1;
|
||||
|
||||
export function EntranceDebugPanel() {
|
||||
const [scale, setScale] = useState(DEFAULT_SCALE);
|
||||
|
||||
// Keep the shared knob in sync with this panel's lifetime (dev only).
|
||||
useEffect(() => {
|
||||
entranceDebugSettings.durationScale = DEFAULT_SCALE;
|
||||
return () => {
|
||||
entranceDebugSettings.durationScale = 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const apply = (value: number) => {
|
||||
entranceDebugSettings.durationScale = value;
|
||||
setScale(value);
|
||||
};
|
||||
|
||||
const meshSec = (ENTRANCE_BASE_DURATIONS.mesh * scale).toFixed(2);
|
||||
const splatSec = (ENTRANCE_BASE_DURATIONS.splat * scale).toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute right-2 top-16 z-50 w-56 rounded-lg border border-white/10 bg-black/70 p-3 text-xs text-white/80 backdrop-blur-md">
|
||||
<div className="mb-2 flex items-center justify-between font-medium text-white/90">
|
||||
<span>Entrance speed</span>
|
||||
<span className="tabular-nums">{scale.toFixed(2)}×</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.25}
|
||||
max={12}
|
||||
step={0.25}
|
||||
value={scale}
|
||||
onChange={(e) => apply(parseFloat(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="mt-2 flex justify-between tabular-nums text-white/50">
|
||||
<span>mesh {meshSec}s</span>
|
||||
<span>splat {splatSec}s</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => apply(1)}
|
||||
className="mt-2 w-full rounded border border-white/10 py-1 text-white/60 transition-colors hover:bg-white/10 hover:text-white/90"
|
||||
>
|
||||
Reset to 1× (real timing)
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { SaveManager } from "./save_manager";
|
||||
|
||||
import { SceneGenereationMetaData } from "../models/sceneGenerationMetadata";
|
||||
import { SceneManager } from "./scene_manager_api";
|
||||
import { EntranceAnimator } from "./entranceAnimation";
|
||||
import { ViewportController } from "./editor/ViewportController";
|
||||
import { PostProcessingPipeline } from "./editor/PostProcessingPipeline";
|
||||
import { GizmoController } from "./editor/GizmoController";
|
||||
@@ -81,6 +82,12 @@ class Editor {
|
||||
// engine/editor/actions/ encapsulates its own apply/revert.
|
||||
history: HistoryManager;
|
||||
|
||||
// Plays the entrance animation for newly added objects (per-gaussian radial
|
||||
// settle for splats, silhouette mask-reveal for meshes). The drop actions
|
||||
// (addObject/addCharacter/addShape) register the object here and the per-frame
|
||||
// render loop ticks it. Field initializer so it's always ready.
|
||||
readonly entranceAnimator = new EntranceAnimator();
|
||||
|
||||
// Typed event bus the engine emits onto. EngineStoreBridge subscribes
|
||||
// once at construction and is the only file under engine/ that
|
||||
// imports the Zustand store.
|
||||
@@ -868,6 +875,9 @@ class Editor {
|
||||
|
||||
this.cameraController.tickPerFrame(delta_time);
|
||||
|
||||
// Advance any in-flight entrance animations.
|
||||
this.entranceAnimator.tick(delta_time);
|
||||
|
||||
this.activeScene.shader_objects.forEach((shader) => {
|
||||
shader.material.uniforms["time"].value += 0.5 * delta_time;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Shared easing curves for the entrance animations. Kept as plain scalar
|
||||
// functions so both the transform-based mesh drop and the shader-driven splat
|
||||
// settle pull from the same vocabulary of motion.
|
||||
|
||||
export function clamp01(t: number): number {
|
||||
return t < 0 ? 0 : t > 1 ? 1 : t;
|
||||
}
|
||||
|
||||
export function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
/** Decelerate to rest — the default for things arriving. */
|
||||
export function easeOutCubic(t: number): number {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
}
|
||||
|
||||
/** Accelerate from rest — reads as gravity for a fall. */
|
||||
export function easeInCubic(t: number): number {
|
||||
return t * t * t;
|
||||
}
|
||||
|
||||
/** Stronger gravity feel for the drop phase. */
|
||||
export function easeInQuart(t: number): number {
|
||||
return t * t * t * t;
|
||||
}
|
||||
|
||||
/** Symmetric ease for settling motions. */
|
||||
export function easeInOutCubic(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
/** Overshoot-and-settle — the "pop" when an object first appears. */
|
||||
export function easeOutBack(t: number, overshoot = 1.70158): number {
|
||||
const c3 = overshoot + 1;
|
||||
return 1 + c3 * Math.pow(t - 1, 3) + overshoot * Math.pow(t - 1, 2);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as THREE from "three";
|
||||
import type { EntranceHandle } from "./types";
|
||||
import { clamp01, easeInQuart, easeOutBack, easeOutCubic, lerp } from "./easing";
|
||||
|
||||
// Transform-based "drop onto the ground" entrance for non-splat objects (GLB
|
||||
// models, primitives, image planes). Replaces the earlier fragment-clip reveal,
|
||||
// which mis-clipped non-convex shapes (e.g. a torus). The object pops into
|
||||
// existence slightly raised, then falls and lands with a soft rubbery vertical
|
||||
// squash — as if it were physically dropped into place. Only the object's
|
||||
// transform is touched (no shaders), so it works for any object uniformly.
|
||||
|
||||
// Phase boundaries on linear time (0..1):
|
||||
const POP_END = 0.22; // appear + rise finishes here
|
||||
const IMPACT = 0.55; // object reaches the ground here; squash begins
|
||||
// Squash/recover runs from IMPACT → 1.
|
||||
|
||||
const POP_START_SCALE = 0.45; // appears at this fraction of full size, then pops up
|
||||
const SQUASH_AMOUNT = 0.12; // peak vertical compression (0.12 = 12%); kept subtle
|
||||
const SQUASH_DECAY = 3.6; // how fast the rubbery wobble dies out
|
||||
const SQUASH_FREQ = 6.5; // wobble frequency (one squash + a small rebound)
|
||||
|
||||
export function buildMeshDrop(object: THREE.Object3D): EntranceHandle | null {
|
||||
const finalPos = object.position.clone();
|
||||
const finalScale = object.scale.clone();
|
||||
|
||||
// Size drives the hop height and the ground-anchor compensation so the motion
|
||||
// reads the same for a teacup and a building.
|
||||
const size = new THREE.Box3().setFromObject(object).getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
const halfHeight = Math.max(size.y * 0.5, 1e-3);
|
||||
const peak = THREE.MathUtils.clamp(0.22 * maxDim, 0.08, 0.45);
|
||||
|
||||
return {
|
||||
setProgress: (t) => {
|
||||
const pop = popScale(t);
|
||||
const yHop = hopOffset(t, peak);
|
||||
const { sy, sxz } = squash(t);
|
||||
|
||||
// Keep the base planted while it squashes: a center-pivot scaleY<1 lifts
|
||||
// the bottom by (1-sy)*halfHeight, so push the object down to compensate.
|
||||
const groundComp = -(1 - sy) * halfHeight * pop;
|
||||
|
||||
object.position.set(
|
||||
finalPos.x,
|
||||
finalPos.y + yHop + groundComp,
|
||||
finalPos.z,
|
||||
);
|
||||
object.scale.set(
|
||||
finalScale.x * pop * sxz,
|
||||
finalScale.y * pop * sy,
|
||||
finalScale.z * pop * sxz,
|
||||
);
|
||||
},
|
||||
finish: () => {
|
||||
object.position.copy(finalPos);
|
||||
object.scale.copy(finalScale);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Appear with an overshooting pop, then hold at full size. */
|
||||
function popScale(t: number): number {
|
||||
const appear = easeOutBack(clamp01(t / POP_END), 2.2);
|
||||
return lerp(POP_START_SCALE, 1, appear);
|
||||
}
|
||||
|
||||
/** Rise up from the ground as it pops in, then fall back down under gravity. */
|
||||
function hopOffset(t: number, peak: number): number {
|
||||
if (t <= POP_END) {
|
||||
// Lift off the ground while it materializes, so the rise is visible.
|
||||
return peak * easeOutCubic(t / POP_END);
|
||||
}
|
||||
if (t < IMPACT) {
|
||||
// Accelerating fall down to the ground.
|
||||
return peak * (1 - easeInQuart((t - POP_END) / (IMPACT - POP_END)));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Rubbery vertical squash-and-stretch that fires on landing and settles out. */
|
||||
function squash(t: number): { sy: number; sxz: number } {
|
||||
if (t < IMPACT) return { sy: 1, sxz: 1 };
|
||||
const u = clamp01((t - IMPACT) / (1 - IMPACT));
|
||||
// sin starts at 0 (no jump at the instant of contact), swells to a squash,
|
||||
// then a damped rebound — a single bouncy wobble that decays to rest.
|
||||
const wobble = Math.exp(-SQUASH_DECAY * u) * Math.sin(SQUASH_FREQ * u);
|
||||
return {
|
||||
sy: 1 - SQUASH_AMOUNT * wobble,
|
||||
sxz: 1 + SQUASH_AMOUNT * 0.6 * wobble,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh, dyno } from "@sparkjsdev/spark";
|
||||
import type { EntranceHandle } from "./types";
|
||||
import { easeInOutCubic } from "./easing";
|
||||
|
||||
// Per-gaussian "radial settle" entrance for gaussian splats. Each gaussian rains
|
||||
// down from a height and arrives as a center-out wave: gaussians near the splat's
|
||||
// centroid fall in and fade up first, the farthest ones last. Done in Spark's
|
||||
// `dyno` shader graph via an objectModifier — the only way to touch gaussians
|
||||
// individually.
|
||||
//
|
||||
// The wave is driven by one float uniform (`uProgress`) we advance each frame
|
||||
// from the editor render loop, so it doesn't depend on Spark's internal time
|
||||
// ticker (which isn't wired up here — SparkRenderer is unused).
|
||||
|
||||
// Wide wavefront so each gaussian fades and falls over a leisurely window rather
|
||||
// than snapping in — the fade should read as a clear alpha ramp during the rain.
|
||||
const SOFT_EDGE = 0.38; // width of the reveal wavefront, as a fraction of radius
|
||||
const MIN_SCALE = 0.25; // gaussians start at this fraction of their size, then pop to full
|
||||
const DROP_FACTOR = 0.5; // fall height as a multiple of the splat radius
|
||||
|
||||
export function buildSplatSettle(splat: SplatMesh): EntranceHandle | null {
|
||||
// Normalize per-gaussian distance by the splat radius so the wave covers the
|
||||
// whole cloud regardless of scale.
|
||||
let radius = 1;
|
||||
try {
|
||||
const box = splat.getBoundingBox(true);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
radius = 0.5 * Math.max(size.x, size.y, size.z);
|
||||
} catch {
|
||||
radius = 1;
|
||||
}
|
||||
if (!(radius > 0) || !Number.isFinite(radius)) radius = 1;
|
||||
|
||||
// World "up" expressed in the splat's local frame, so gaussians fall from
|
||||
// straight above regardless of the splat's orientation (splats are loaded with
|
||||
// a 180° flip, so local +Y is not world up).
|
||||
splat.updateWorldMatrix(true, false);
|
||||
const worldQuat = splat.getWorldQuaternion(new THREE.Quaternion());
|
||||
const upLocal = new THREE.Vector3(0, 1, 0).applyQuaternion(worldQuat.invert());
|
||||
|
||||
const uProgress = dyno.dynoFloat(0);
|
||||
const previousModifier = splat.objectModifier;
|
||||
|
||||
const modifier = dyno.dynoBlock(
|
||||
{ gsplat: dyno.Gsplat },
|
||||
{ gsplat: dyno.Gsplat },
|
||||
({ gsplat }) => {
|
||||
if (!gsplat) throw new Error("splat settle: gsplat undefined");
|
||||
const split = dyno.splitGsplat(gsplat);
|
||||
const center = split.outputs.center; // vec3, object space
|
||||
const opacity = split.outputs.opacity; // float
|
||||
const scales = split.outputs.scales; // vec3
|
||||
|
||||
// Normalized distance of this gaussian from the splat centroid (0..~1).
|
||||
const nd = dyno.div(dyno.length(center), dyno.dynoFloat(radius));
|
||||
|
||||
// Reveal wavefront: gaussians with normalized distance below the growing
|
||||
// threshold are settled. `reveal` goes 1 (arrived) → 0 (not yet).
|
||||
const edge0 = dyno.sub(uProgress, dyno.dynoFloat(SOFT_EDGE));
|
||||
const reveal = dyno.sub(
|
||||
dyno.dynoFloat(1),
|
||||
dyno.smoothstep(edge0, uProgress, nd),
|
||||
);
|
||||
const notArrived = dyno.sub(dyno.dynoFloat(1), reveal);
|
||||
|
||||
// Height drop: un-arrived gaussians sit `DROP_FACTOR * radius` above their
|
||||
// home along world-up, and fall into place as they reveal.
|
||||
const fall = dyno.mul(
|
||||
dyno.dynoVec3(upLocal),
|
||||
dyno.mul(dyno.dynoFloat(radius * DROP_FACTOR), notArrived),
|
||||
);
|
||||
const newCenter = dyno.add(center, fall);
|
||||
|
||||
const newOpacity = dyno.mul(opacity, reveal);
|
||||
const popScale = dyno.clamp(
|
||||
reveal,
|
||||
dyno.dynoFloat(MIN_SCALE),
|
||||
dyno.dynoFloat(1),
|
||||
);
|
||||
const newScales = dyno.mul(scales, popScale);
|
||||
|
||||
return {
|
||||
gsplat: dyno.combineGsplat({
|
||||
gsplat,
|
||||
center: newCenter,
|
||||
opacity: newOpacity,
|
||||
scales: newScales,
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
splat.objectModifier = modifier;
|
||||
splat.updateGenerator();
|
||||
|
||||
return {
|
||||
setProgress: (t) => {
|
||||
// Ease the wavefront so it settles softly, and push a touch past 1 so the
|
||||
// outermost gaussians (nd ≈ 1) fully arrive by the end.
|
||||
uProgress.value = easeInOutCubic(t) * (1 + SOFT_EDGE);
|
||||
splat.updateGenerator();
|
||||
},
|
||||
finish: () => {
|
||||
splat.objectModifier = previousModifier;
|
||||
splat.updateGenerator();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Shared contract for the two entrance-animation techniques (mesh mask-reveal,
|
||||
// splat radial-settle). The coordinator drives `setProgress` each frame and
|
||||
// calls `finish` once when the animation completes.
|
||||
|
||||
export interface EntranceHandle {
|
||||
/**
|
||||
* Advance the animation. `progress` is the eased 0..1 completion; `dt` is the
|
||||
* frame delta in seconds (some techniques need it, some ignore it).
|
||||
*/
|
||||
setProgress(progress: number, dt: number): void;
|
||||
|
||||
/** Snap to fully-shown and release any temporary resources (clones, modifiers). */
|
||||
finish(): void;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as THREE from "three";
|
||||
import { SplatMesh } from "@sparkjsdev/spark";
|
||||
import type { EntranceHandle } from "./entrance/types";
|
||||
import { buildMeshDrop } from "./entrance/meshDrop";
|
||||
import { buildSplatSettle } from "./entrance/splatSettle";
|
||||
|
||||
// Drives entrance animations for newly dropped objects. Two techniques, picked
|
||||
// by object type:
|
||||
// • Gaussian splats → a per-gaussian radial settle: gaussians rain in from a
|
||||
// height, center-out, built in Spark's dyno shader graph. See splatSettle.ts.
|
||||
// • Everything else → a transform-based drop with a rubbery landing squash.
|
||||
// See meshDrop.ts.
|
||||
//
|
||||
// Both expose the same EntranceHandle. This coordinator advances raw linear time
|
||||
// 0..1 each frame (each technique applies its own easing) and snaps to "shown" on
|
||||
// completion. Saves in this editor are explicit (user-triggered), so the
|
||||
// animation window is never persisted.
|
||||
|
||||
// Base durations (seconds). The mesh drop reads best snappy (its physical bounce
|
||||
// would feel sluggish drawn out); the splat rain wants room to breathe. The dev
|
||||
// panel can still scale these live.
|
||||
export const ENTRANCE_BASE_DURATIONS = { mesh: 0.75, splat: 4.0 };
|
||||
|
||||
// Dev-only live tuning knob, driven by EntranceDebugPanel (mounted only in DEV).
|
||||
// Defaults to 1 so the baked timing above is what ships; the panel scales it for
|
||||
// experimentation. Read at play() time, so it applies to the next drop.
|
||||
export const entranceDebugSettings = { durationScale: 1 };
|
||||
|
||||
const MAX_DELTA = 1 / 30; // clamp per-frame step so a paused/resumed loop doesn't jump
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
object: THREE.Object3D;
|
||||
handle: EntranceHandle;
|
||||
elapsed: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class EntranceAnimator {
|
||||
private entries: Entry[] = [];
|
||||
|
||||
/**
|
||||
* Begin an entrance for `object`, treating its current transform as final.
|
||||
* Call immediately after the object is positioned and added to the scene —
|
||||
* before the next render — so it's never shown fully-formed first. No-ops
|
||||
* (leaves the object shown) when the user prefers reduced motion.
|
||||
*/
|
||||
play(object: THREE.Object3D): void {
|
||||
if (prefersReducedMotion()) return;
|
||||
|
||||
const isSplat = object instanceof SplatMesh;
|
||||
const handle = isSplat
|
||||
? buildSplatSettle(object)
|
||||
: buildMeshDrop(object);
|
||||
if (!handle) return;
|
||||
|
||||
// Run the first frame hidden so nothing flashes at full size before the
|
||||
// render loop's first tick.
|
||||
handle.setProgress(0, 0);
|
||||
|
||||
const base = isSplat
|
||||
? ENTRANCE_BASE_DURATIONS.splat
|
||||
: ENTRANCE_BASE_DURATIONS.mesh;
|
||||
this.entries = this.entries.filter((entry) => entry.object !== object);
|
||||
this.entries.push({
|
||||
object,
|
||||
handle,
|
||||
elapsed: 0,
|
||||
duration: base * entranceDebugSettings.durationScale,
|
||||
});
|
||||
}
|
||||
|
||||
/** Advance all active entrances. Call once per frame from the render loop. */
|
||||
tick(delta: number): void {
|
||||
if (this.entries.length === 0) return;
|
||||
const dt = Math.min(delta, MAX_DELTA);
|
||||
const remaining: Entry[] = [];
|
||||
|
||||
for (const entry of this.entries) {
|
||||
entry.elapsed += dt;
|
||||
const t = Math.min(entry.elapsed / entry.duration, 1);
|
||||
// Pass raw linear time; each technique applies its own easing.
|
||||
entry.handle.setProgress(t, dt);
|
||||
if (t < 1) {
|
||||
remaining.push(entry);
|
||||
} else {
|
||||
entry.handle.finish();
|
||||
}
|
||||
}
|
||||
|
||||
this.entries = remaining;
|
||||
}
|
||||
|
||||
/** Finish an object's entrance immediately and stop tracking it. */
|
||||
cancel(object: THREE.Object3D): void {
|
||||
const entry = this.entries.find((candidate) => candidate.object === object);
|
||||
if (!entry) return;
|
||||
entry.handle.finish();
|
||||
this.entries = this.entries.filter((candidate) => candidate !== entry);
|
||||
}
|
||||
|
||||
/** Finish all entrances (e.g. on scene clear/teardown). */
|
||||
clear(): void {
|
||||
for (const entry of this.entries) entry.handle.finish();
|
||||
this.entries = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user