refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor
This commit is contained in:
parent
0dffefdd59
commit
6a22a3ecbd
|
|
@ -1,268 +1,268 @@
|
|||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,239 +1,240 @@
|
|||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { clearDragData, setDragData } from "@/timeline/drag-data";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
onAddToTimeline,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", handleDragOver);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
setDragData({ dataTransfer: e.dataTransfer, dragData });
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.({ e });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
clearDragData();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "w-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-auto w-full cursor-default flex-col gap-1 p-",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
title={name}
|
||||
>
|
||||
<span className="sr-only">{name}</span>
|
||||
<span aria-hidden="true">
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative w-full", containerClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="size-6 shrink-0 overflow-hidden rounded-sm">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-9999"
|
||||
style={{
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
|
||||
>
|
||||
<div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({
|
||||
className,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
tooltipText?: string;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipText) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
export interface DraggableItemProps {
|
||||
name: string;
|
||||
preview: ReactNode;
|
||||
dragData: TimelineDragData;
|
||||
onDragStart?: ({ e }: { e: React.DragEvent }) => void;
|
||||
onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
|
||||
aspectRatio?: number;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
shouldShowPlusOnDrag?: boolean;
|
||||
shouldShowLabel?: boolean;
|
||||
isRounded?: boolean;
|
||||
variant?: "card" | "compact";
|
||||
isDraggable?: boolean;
|
||||
}
|
||||
|
||||
export function DraggableItem({
|
||||
name,
|
||||
preview,
|
||||
dragData,
|
||||
onDragStart,
|
||||
onAddToTimeline,
|
||||
aspectRatio = 16 / 9,
|
||||
className = "",
|
||||
containerClassName,
|
||||
shouldShowPlusOnDrag = true,
|
||||
shouldShowLabel = true,
|
||||
isRounded = true,
|
||||
variant = "card",
|
||||
isDraggable = true,
|
||||
}: DraggableItemProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const dragRef = useRef<HTMLDivElement>(null);
|
||||
const editor = useEditor();
|
||||
|
||||
const handleAddToTimeline = () => {
|
||||
onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
|
||||
};
|
||||
|
||||
const emptyImg = new window.Image();
|
||||
emptyImg.src =
|
||||
"data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
document.addEventListener("dragover", handleDragOver);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("dragover", handleDragOver);
|
||||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
editor.timeline.dragSource.begin({
|
||||
dataTransfer: event.dataTransfer,
|
||||
dragData,
|
||||
});
|
||||
|
||||
setDragPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.({ e: event });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
editor.timeline.dragSource.end();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "card" ? (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative", containerClassName ?? "w-28")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-auto w-full cursor-default flex-col gap-1 p-",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<AspectRatio
|
||||
ratio={aspectRatio}
|
||||
className={cn(
|
||||
"bg-accent relative overflow-hidden",
|
||||
isRounded && "rounded-sm",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
{preview}
|
||||
{!isDragging && (
|
||||
<PlusButton
|
||||
className="opacity-0 group-hover:opacity-100"
|
||||
onClick={handleAddToTimeline}
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
{shouldShowLabel && (
|
||||
<span
|
||||
className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
|
||||
title={name}
|
||||
>
|
||||
<span className="sr-only">{name}</span>
|
||||
<span aria-hidden="true">
|
||||
{name.length > 8
|
||||
? `${name.slice(0, 16)}...${name.slice(-3)}`
|
||||
: name}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={dragRef}
|
||||
className={cn("group relative w-full", containerClassName)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
|
||||
isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
draggable={isDraggable}
|
||||
onDragStart={isDraggable ? handleDragStart : undefined}
|
||||
onDragEnd={isDraggable ? handleDragEnd : undefined}
|
||||
>
|
||||
<div className="size-6 shrink-0 overflow-hidden rounded-sm">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDraggable &&
|
||||
isDragging &&
|
||||
typeof document !== "undefined" &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-9999"
|
||||
style={{
|
||||
left: dragPosition.x - 40,
|
||||
top: dragPosition.y - 40,
|
||||
}}
|
||||
>
|
||||
<div className="w-[80px]">
|
||||
<AspectRatio
|
||||
ratio={1}
|
||||
className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
|
||||
>
|
||||
<div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
|
||||
{preview}
|
||||
</div>
|
||||
{shouldShowPlusOnDrag && (
|
||||
<PlusButton
|
||||
onClick={handleAddToTimeline}
|
||||
tooltipText="Add to timeline or drag to position"
|
||||
/>
|
||||
)}
|
||||
</AspectRatio>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlusButton({
|
||||
className,
|
||||
onClick,
|
||||
tooltipText,
|
||||
}: {
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
tooltipText?: string;
|
||||
}) {
|
||||
const button = (
|
||||
<Button
|
||||
size="icon"
|
||||
className={cn(
|
||||
"bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
}}
|
||||
title={tooltipText}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltipText) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
clampRetimeRate,
|
||||
shouldMaintainPitch,
|
||||
} from "@/retime/rate";
|
||||
import { clampRetimeRate, shouldMaintainPitch } from "@/retime/rate";
|
||||
import type { AudioClipSource } from "@/media/audio";
|
||||
import { createAudioContext, collectAudioClips } from "@/media/audio";
|
||||
import {
|
||||
|
|
@ -56,10 +53,8 @@ export class AudioManager {
|
|||
this.editor.playback.subscribe(this.handlePlaybackChange),
|
||||
this.editor.timeline.subscribe(this.handleTimelineChange),
|
||||
this.editor.media.subscribe(this.handleTimelineChange),
|
||||
this.editor.playback.onSeek(this.handleSeek),
|
||||
);
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("playback-seek", this.handleSeek);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
|
@ -68,9 +63,6 @@ export class AudioManager {
|
|||
unsub();
|
||||
}
|
||||
this.unsubscribers = [];
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("playback-seek", this.handleSeek);
|
||||
}
|
||||
this.disposeSinks();
|
||||
this.preparedClipBuffers.clear();
|
||||
this.decodedBuffers.clear();
|
||||
|
|
@ -102,17 +94,14 @@ export class AudioManager {
|
|||
}
|
||||
};
|
||||
|
||||
private handleSeek = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<{ time: number }>).detail;
|
||||
if (!detail) return;
|
||||
|
||||
private handleSeek = (time: number): void => {
|
||||
if (this.editor.playback.getIsScrubbing()) {
|
||||
this.stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.editor.playback.getIsPlaying()) {
|
||||
void this.startPlayback({ time: detail.time / TICKS_PER_SECOND });
|
||||
void this.startPlayback({ time: time / TICKS_PER_SECOND });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +115,9 @@ export class AudioManager {
|
|||
|
||||
if (!this.editor.playback.getIsPlaying()) return;
|
||||
|
||||
void this.startPlayback({ time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND });
|
||||
void this.startPlayback({
|
||||
time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND,
|
||||
});
|
||||
};
|
||||
|
||||
private ensureAudioContext(): AudioContext | null {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export class PlaybackManager {
|
|||
private previousVolume = 1;
|
||||
private isScrubbing = false;
|
||||
private listeners = new Set<() => void>();
|
||||
private updateListeners = new Set<(time: number) => void>();
|
||||
private seekListeners = new Set<(time: number) => void>();
|
||||
private playbackTimer: number | null = null;
|
||||
private playbackStartWallTime = 0;
|
||||
private playbackStartTime = 0;
|
||||
|
|
@ -67,7 +69,7 @@ export class PlaybackManager {
|
|||
this.playbackStartTime = this.currentTime;
|
||||
}
|
||||
this.notify();
|
||||
this.dispatchSeekEvent(this.currentTime);
|
||||
this.notifySeek(this.currentTime);
|
||||
}
|
||||
|
||||
setVolume({ volume }: { volume: number }): void {
|
||||
|
|
@ -133,6 +135,16 @@ export class PlaybackManager {
|
|||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
onUpdate(listener: (time: number) => void): () => void {
|
||||
this.updateListeners.add(listener);
|
||||
return () => this.updateListeners.delete(listener);
|
||||
}
|
||||
|
||||
onSeek(listener: (time: number) => void): () => void {
|
||||
this.seekListeners.add(listener);
|
||||
return () => this.seekListeners.delete(listener);
|
||||
}
|
||||
|
||||
private reconcileTimelineScope(): void {
|
||||
const maxTime = this.editor.timeline.getTotalDuration();
|
||||
const nextTime = this.clampTimeToTimeline(this.currentTime);
|
||||
|
|
@ -152,7 +164,7 @@ export class PlaybackManager {
|
|||
this.notify();
|
||||
|
||||
if (timeChanged) {
|
||||
this.dispatchSeekEvent(this.currentTime);
|
||||
this.notifySeek(this.currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,12 +208,12 @@ export class PlaybackManager {
|
|||
this.pause();
|
||||
this.currentTime = maxTime;
|
||||
this.notify();
|
||||
this.dispatchSeekEvent(maxTime);
|
||||
this.notifySeek(maxTime);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTime = newTime;
|
||||
this.dispatchUpdateEvent(newTime);
|
||||
this.notifyUpdate(newTime);
|
||||
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
||||
};
|
||||
|
||||
|
|
@ -210,27 +222,15 @@ export class PlaybackManager {
|
|||
return Math.max(0, Math.min(maxTime, time));
|
||||
}
|
||||
|
||||
private dispatchSeekEvent(time: number): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
private notifySeek(time: number): void {
|
||||
for (const fn of this.seekListeners) {
|
||||
fn(time);
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-seek", {
|
||||
detail: { time },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private dispatchUpdateEvent(time: number): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
private notifyUpdate(time: number): void {
|
||||
for (const fn of this.updateListeners) {
|
||||
fn(time);
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("playback-update", {
|
||||
detail: { time },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
RetimeConfig,
|
||||
} from "@/timeline";
|
||||
import { calculateTotalDuration } from "@/timeline";
|
||||
import { TimelineDragSource } from "@/timeline/drag-source";
|
||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||
import {
|
||||
canElementBeHidden,
|
||||
|
|
@ -67,6 +68,7 @@ export class TimelineManager {
|
|||
private listeners = new Set<() => void>();
|
||||
private previewOverlay = new Map<string, Partial<TimelineElement>>();
|
||||
private previewTracks: SceneTracks | null = null;
|
||||
public readonly dragSource = new TimelineDragSource();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
|
|
@ -706,11 +708,11 @@ export class TimelineManager {
|
|||
previewElements({
|
||||
updates,
|
||||
}: {
|
||||
updates: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<TimelineElement>;
|
||||
}>;
|
||||
updates: readonly {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly updates: Partial<TimelineElement>;
|
||||
}[];
|
||||
}): void {
|
||||
let changedOverlayCount = 0;
|
||||
for (const { elementId, updates: elementUpdates } of updates) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { hasDragData } from "@/timeline/drag-data";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
accept?: string;
|
||||
|
|
@ -7,19 +7,23 @@ interface UseFileUploadOptions {
|
|||
onFilesSelected?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files");
|
||||
}
|
||||
|
||||
export function useFileUpload({
|
||||
accept,
|
||||
multiple,
|
||||
onFilesSelected,
|
||||
}: UseFileUploadOptions = {}) {
|
||||
const editor = useEditor();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
return (
|
||||
!editor.timeline.dragSource.isActive() &&
|
||||
dataTransfer.types.includes("Files")
|
||||
);
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,12 +8,10 @@ import { EditableTimecode } from "@/components/editable-timecode";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FullScreenIcon,
|
||||
GridTableIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { getGuideById } from "@/guides";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -24,17 +22,12 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { PREVIEW_ZOOM_PRESETS } from "@/preview/zoom";
|
||||
import { usePreviewViewport } from "./preview-viewport";
|
||||
import { GridPopover } from "./guide-popover";
|
||||
import { usePreviewStore } from "@/preview/preview-store";
|
||||
|
||||
export function PreviewToolbar({
|
||||
onToggleFullscreen,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
const activeGuide = usePreviewStore((state) => state.activeGuide);
|
||||
const activeGuideDefinition = getGuideById(activeGuide);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
||||
<TimecodeDisplay />
|
||||
|
|
@ -72,15 +65,13 @@ function TimecodeDisplay() {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) =>
|
||||
setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
|
||||
window.addEventListener("playback-update", handler);
|
||||
window.addEventListener("playback-seek", handler);
|
||||
const unsubscribeUpdate = editor.playback.onUpdate(setCurrentTime);
|
||||
const unsubscribeSeek = editor.playback.onSeek(setCurrentTime);
|
||||
return () => {
|
||||
window.removeEventListener("playback-update", handler);
|
||||
window.removeEventListener("playback-seek", handler);
|
||||
unsubscribeUpdate();
|
||||
unsubscribeSeek();
|
||||
};
|
||||
}, []);
|
||||
}, [editor.playback]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
|
|
@ -94,7 +85,11 @@ function TimecodeDisplay() {
|
|||
/>
|
||||
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })}
|
||||
{formatTimecode({
|
||||
time: totalDuration,
|
||||
format: "HH:MM:SS:FF",
|
||||
rate: fps,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,583 @@
|
|||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
getHitElements,
|
||||
hitTest,
|
||||
resolvePreferredHit,
|
||||
} from "@/preview/hit-test";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapPosition,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import type { TCanvasSize } from "@/project/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import type {
|
||||
ElementRef,
|
||||
SceneTracks,
|
||||
TextElement,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
VisualElement,
|
||||
} from "@/timeline";
|
||||
|
||||
const MIN_DRAG_DISTANCE = 0.5;
|
||||
const PRIMARY_POINTER_BUTTON = 0;
|
||||
|
||||
type Point = { readonly x: number; readonly y: number };
|
||||
|
||||
interface CapturedPointerState {
|
||||
readonly pointerId: number;
|
||||
readonly captureTarget: HTMLElement;
|
||||
}
|
||||
|
||||
interface PendingGesture extends CapturedPointerState {
|
||||
readonly kind: "pending";
|
||||
readonly origin: Point;
|
||||
readonly topmostHit: ElementWithBounds | null;
|
||||
readonly selectedHit: ElementWithBounds | null;
|
||||
readonly selectedElements: readonly ElementRef[];
|
||||
}
|
||||
|
||||
interface DragElementSnapshot {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
}
|
||||
|
||||
interface DraggingGesture extends CapturedPointerState {
|
||||
readonly kind: "dragging";
|
||||
readonly origin: Point;
|
||||
readonly bounds: {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly rotation: number;
|
||||
};
|
||||
readonly elements: readonly DragElementSnapshot[];
|
||||
}
|
||||
|
||||
type GestureSession =
|
||||
| { readonly kind: "idle" }
|
||||
| PendingGesture
|
||||
| DraggingGesture;
|
||||
|
||||
const IDLE_GESTURE: GestureSession = { kind: "idle" };
|
||||
|
||||
export interface EditingTextState {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly element: TextElement;
|
||||
}
|
||||
|
||||
export interface PreviewViewportAdapter {
|
||||
screenToCanvas: ({
|
||||
clientX,
|
||||
clientY,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}) => Point | null;
|
||||
screenPixelsToLogicalThreshold: ({
|
||||
screenPixels,
|
||||
}: {
|
||||
screenPixels: number;
|
||||
}) => Point;
|
||||
}
|
||||
|
||||
export interface InputAdapter {
|
||||
isShiftHeld: () => boolean;
|
||||
}
|
||||
|
||||
export interface SceneReader {
|
||||
getTracks: () => SceneTracks;
|
||||
getCurrentTime: () => number;
|
||||
getMediaAssets: () => MediaAsset[];
|
||||
getCanvasSize: () => TCanvasSize;
|
||||
}
|
||||
|
||||
export interface SelectionApi {
|
||||
getSelected: () => readonly ElementRef[];
|
||||
setSelected: (elements: readonly ElementRef[]) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export interface TimelinePreviewUpdate {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly updates: Partial<TimelineElement>;
|
||||
}
|
||||
|
||||
export interface TimelineOps {
|
||||
getElementsWithTracks: ({
|
||||
elements,
|
||||
}: {
|
||||
elements: readonly ElementRef[];
|
||||
}) => Array<{ track: TimelineTrack; element: TimelineElement }>;
|
||||
previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
|
||||
commitPreview: () => void;
|
||||
discardPreview: () => void;
|
||||
}
|
||||
|
||||
export interface PlaybackApi {
|
||||
getIsPlaying: () => boolean;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export interface PreviewOptions {
|
||||
isMaskMode: () => boolean;
|
||||
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||
}
|
||||
|
||||
export interface PreviewInteractionDeps {
|
||||
viewport: PreviewViewportAdapter;
|
||||
input: InputAdapter;
|
||||
scene: SceneReader;
|
||||
selection: SelectionApi;
|
||||
timeline: TimelineOps;
|
||||
playback: PlaybackApi;
|
||||
preview: PreviewOptions;
|
||||
}
|
||||
|
||||
export interface PreviewInteractionDepsRef {
|
||||
readonly current: PreviewInteractionDeps;
|
||||
}
|
||||
|
||||
function isSameElementRef({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: ElementRef;
|
||||
right: ElementRef;
|
||||
}): boolean {
|
||||
return left.trackId === right.trackId && left.elementId === right.elementId;
|
||||
}
|
||||
|
||||
function buildDragSelection({
|
||||
selectedElements,
|
||||
dragTarget,
|
||||
}: {
|
||||
selectedElements: readonly ElementRef[];
|
||||
dragTarget: ElementWithBounds;
|
||||
}): ElementRef[] {
|
||||
const dragTargetRef = {
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
};
|
||||
|
||||
if (
|
||||
!selectedElements.some((selectedElement) =>
|
||||
isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
)
|
||||
) {
|
||||
return [dragTargetRef];
|
||||
}
|
||||
|
||||
return [
|
||||
dragTargetRef,
|
||||
...selectedElements.filter(
|
||||
(selectedElement) =>
|
||||
!isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function movedPastDragThreshold({
|
||||
current,
|
||||
origin,
|
||||
}: {
|
||||
current: Point;
|
||||
origin: Point;
|
||||
}): boolean {
|
||||
return (
|
||||
Math.abs(current.x - origin.x) > MIN_DRAG_DISTANCE ||
|
||||
Math.abs(current.y - origin.y) > MIN_DRAG_DISTANCE
|
||||
);
|
||||
}
|
||||
|
||||
function toDragElementSnapshots({
|
||||
elementsWithTracks,
|
||||
}: {
|
||||
elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>;
|
||||
}): DragElementSnapshot[] {
|
||||
const isVisualTrackedElement = (value: {
|
||||
track: TimelineTrack;
|
||||
element: TimelineElement;
|
||||
}): value is { track: TimelineTrack; element: VisualElement } =>
|
||||
isVisualElement(value.element);
|
||||
|
||||
return elementsWithTracks
|
||||
.filter(isVisualTrackedElement)
|
||||
.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialTransform: element.transform,
|
||||
}));
|
||||
}
|
||||
|
||||
export class PreviewInteractionController {
|
||||
private readonly depsRef: PreviewInteractionDepsRef;
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
|
||||
private gesture: GestureSession = IDLE_GESTURE;
|
||||
private editingTextState: EditingTextState | null = null;
|
||||
private wasPlaying: boolean;
|
||||
private unsubscribePlayback: (() => void) | null = null;
|
||||
|
||||
constructor({ depsRef }: { depsRef: PreviewInteractionDepsRef }) {
|
||||
this.depsRef = depsRef;
|
||||
this.wasPlaying = this.deps.playback.getIsPlaying();
|
||||
|
||||
this.onDoubleClick = this.onDoubleClick.bind(this);
|
||||
this.onPointerDown = this.onPointerDown.bind(this);
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
this.commitTextEdit = this.commitTextEdit.bind(this);
|
||||
this.handlePlaybackChange = this.handlePlaybackChange.bind(this);
|
||||
|
||||
this.unsubscribePlayback = this.deps.playback.subscribe(
|
||||
this.handlePlaybackChange,
|
||||
);
|
||||
}
|
||||
|
||||
private get deps(): PreviewInteractionDeps {
|
||||
return this.depsRef.current;
|
||||
}
|
||||
|
||||
get isDragging(): boolean {
|
||||
return this.gesture.kind === "dragging";
|
||||
}
|
||||
|
||||
get editingText(): EditingTextState | null {
|
||||
return this.editingTextState;
|
||||
}
|
||||
|
||||
subscribe({ listener }: { listener: () => void }): () => void {
|
||||
this.subscribers.add(listener);
|
||||
return () => this.subscribers.delete(listener);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unsubscribePlayback?.();
|
||||
this.unsubscribePlayback = null;
|
||||
this.abortActiveGesture();
|
||||
this.editingTextState = null;
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.gesture.kind === "idle") return;
|
||||
this.abortActiveGesture();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private abortActiveGesture(): void {
|
||||
if (this.gesture.kind === "idle") return;
|
||||
|
||||
if (this.gesture.kind === "dragging") {
|
||||
this.deps.timeline.discardPreview();
|
||||
}
|
||||
|
||||
this.releaseCapturedPointer({ pointerState: this.gesture });
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
}
|
||||
|
||||
commitTextEdit(): void {
|
||||
if (!this.editingTextState) return;
|
||||
|
||||
this.editingTextState = null;
|
||||
this.deps.timeline.commitPreview();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onDoubleClick({ clientX, clientY }: ReactMouseEvent): void {
|
||||
if (this.editingTextState || this.deps.preview.isMaskMode()) return;
|
||||
|
||||
const startPos = this.deps.viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const hit = hitTest({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||
});
|
||||
|
||||
if (!hit || hit.element.type !== "text") return;
|
||||
|
||||
this.editingTextState = {
|
||||
trackId: hit.trackId,
|
||||
elementId: hit.elementId,
|
||||
element: hit.element,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onPointerDown({
|
||||
clientX,
|
||||
clientY,
|
||||
currentTarget,
|
||||
pointerId,
|
||||
button,
|
||||
}: ReactPointerEvent): void {
|
||||
if (this.editingTextState) return;
|
||||
if (this.deps.preview.isMaskMode()) return;
|
||||
if (button !== PRIMARY_POINTER_BUTTON) return;
|
||||
|
||||
const startPos = this.deps.viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const hits = getHitElements({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||
});
|
||||
const selectedElements = this.deps.selection.getSelected();
|
||||
|
||||
this.gesture = {
|
||||
kind: "pending",
|
||||
origin: startPos,
|
||||
pointerId,
|
||||
captureTarget: currentTarget as HTMLElement,
|
||||
topmostHit: hits[0] ?? null,
|
||||
selectedHit: resolvePreferredHit({
|
||||
hits,
|
||||
preferredElements: [...selectedElements],
|
||||
}),
|
||||
selectedElements,
|
||||
};
|
||||
|
||||
currentTarget.setPointerCapture(pointerId);
|
||||
}
|
||||
|
||||
onPointerMove({ clientX, clientY }: ReactPointerEvent): void {
|
||||
const currentPos = this.deps.viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!currentPos) return;
|
||||
|
||||
if (this.gesture.kind === "pending") {
|
||||
const pending = this.gesture;
|
||||
if (
|
||||
!movedPastDragThreshold({
|
||||
current: currentPos,
|
||||
origin: pending.origin,
|
||||
})
|
||||
) {
|
||||
this.clearSnapLines();
|
||||
return;
|
||||
}
|
||||
|
||||
this.beginDragFromPending({ pending });
|
||||
}
|
||||
|
||||
if (this.gesture.kind !== "dragging") return;
|
||||
|
||||
this.updateDragPreview({
|
||||
drag: this.gesture,
|
||||
currentPos,
|
||||
});
|
||||
}
|
||||
|
||||
onPointerUp({ type }: ReactPointerEvent): void {
|
||||
if (this.gesture.kind === "dragging") {
|
||||
const drag = this.gesture;
|
||||
|
||||
if (type === "pointercancel") {
|
||||
this.deps.timeline.discardPreview();
|
||||
} else {
|
||||
this.deps.timeline.commitPreview();
|
||||
}
|
||||
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: drag });
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.gesture.kind !== "pending") return;
|
||||
|
||||
const pending = this.gesture;
|
||||
|
||||
if (type !== "pointercancel") {
|
||||
const clickTarget = pending.topmostHit;
|
||||
if (!clickTarget) {
|
||||
this.deps.selection.clearSelection();
|
||||
} else {
|
||||
this.deps.selection.setSelected([
|
||||
{
|
||||
trackId: clickTarget.trackId,
|
||||
elementId: clickTarget.elementId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: pending });
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.subscribers) listener();
|
||||
}
|
||||
|
||||
private clearSnapLines(): void {
|
||||
this.deps.preview.onSnapLinesChange?.([]);
|
||||
}
|
||||
|
||||
private releaseCapturedPointer({
|
||||
pointerState,
|
||||
}: {
|
||||
pointerState: CapturedPointerState | null;
|
||||
}): void {
|
||||
if (!pointerState) return;
|
||||
|
||||
if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
||||
}
|
||||
|
||||
private getVisibleElementsWithBounds(): ElementWithBounds[] {
|
||||
return getVisibleElementsWithBounds({
|
||||
tracks: this.deps.scene.getTracks(),
|
||||
currentTime: this.deps.scene.getCurrentTime(),
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
mediaAssets: this.deps.scene.getMediaAssets(),
|
||||
});
|
||||
}
|
||||
|
||||
private handlePlaybackChange(): void {
|
||||
const isPlaying = this.deps.playback.getIsPlaying();
|
||||
if (isPlaying && !this.wasPlaying && this.editingTextState) {
|
||||
this.commitTextEdit();
|
||||
}
|
||||
this.wasPlaying = isPlaying;
|
||||
}
|
||||
|
||||
private beginDragFromPending({ pending }: { pending: PendingGesture }): void {
|
||||
const dragTarget = pending.selectedHit ?? pending.topmostHit;
|
||||
if (!dragTarget) {
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: pending });
|
||||
return;
|
||||
}
|
||||
|
||||
const dragSelection = buildDragSelection({
|
||||
selectedElements: pending.selectedElements,
|
||||
dragTarget,
|
||||
});
|
||||
const draggableElements = toDragElementSnapshots({
|
||||
elementsWithTracks: this.deps.timeline.getElementsWithTracks({
|
||||
elements: dragSelection,
|
||||
}),
|
||||
});
|
||||
|
||||
if (draggableElements.length === 0) {
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: pending });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.selectedHit === null) {
|
||||
this.deps.selection.setSelected([
|
||||
{
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
this.gesture = {
|
||||
kind: "dragging",
|
||||
origin: pending.origin,
|
||||
pointerId: pending.pointerId,
|
||||
captureTarget: pending.captureTarget,
|
||||
bounds: {
|
||||
width: dragTarget.bounds.width,
|
||||
height: dragTarget.bounds.height,
|
||||
rotation: dragTarget.bounds.rotation,
|
||||
},
|
||||
elements: draggableElements,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private updateDragPreview({
|
||||
drag,
|
||||
currentPos,
|
||||
}: {
|
||||
drag: DraggingGesture;
|
||||
currentPos: Point;
|
||||
}): void {
|
||||
const firstElement = drag.elements[0];
|
||||
if (!firstElement) return;
|
||||
|
||||
const deltaX = currentPos.x - drag.origin.x;
|
||||
const deltaY = currentPos.y - drag.origin.y;
|
||||
|
||||
const proposedPosition = {
|
||||
x: firstElement.initialTransform.position.x + deltaX,
|
||||
y: firstElement.initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
const shouldSnap = !this.deps.input.isShiftHeld();
|
||||
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedPosition, activeLines } = shouldSnap
|
||||
? snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
elementSize: drag.bounds,
|
||||
rotation: drag.bounds.rotation,
|
||||
snapThreshold,
|
||||
})
|
||||
: {
|
||||
snappedPosition: proposedPosition,
|
||||
activeLines: [] as SnapLine[],
|
||||
};
|
||||
|
||||
this.deps.preview.onSnapLinesChange?.(activeLines);
|
||||
|
||||
const deltaSnappedX =
|
||||
snappedPosition.x - firstElement.initialTransform.position.x;
|
||||
const deltaSnappedY =
|
||||
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||
|
||||
this.deps.timeline.previewElements(
|
||||
drag.elements.map(({ trackId, elementId, initialTransform }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: {
|
||||
x: initialTransform.position.x + deltaSnappedX,
|
||||
y: initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,753 @@
|
|||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type Corner,
|
||||
type Edge,
|
||||
type ElementBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
MIN_SCALE,
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
snapScaleAxes,
|
||||
type ScaleEdgePreference,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
hasKeyframesForPath,
|
||||
resolveTransformAtTime,
|
||||
setChannel,
|
||||
} from "@/animation";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type {
|
||||
ElementRef,
|
||||
SceneTracks,
|
||||
TimelineElement,
|
||||
VisualElement,
|
||||
} from "@/timeline";
|
||||
|
||||
type Point = { readonly x: number; readonly y: number };
|
||||
type CanvasSize = { readonly width: number; readonly height: number };
|
||||
type HandleType = Corner | Edge | "rotation";
|
||||
|
||||
interface CapturedPointerState {
|
||||
readonly pointerId: number;
|
||||
readonly captureTarget: HTMLElement;
|
||||
}
|
||||
|
||||
interface CornerScaleSession extends CapturedPointerState {
|
||||
readonly kind: "corner-scale";
|
||||
readonly corner: Corner;
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialDistance: number;
|
||||
readonly initialBoundsCx: number;
|
||||
readonly initialBoundsCy: number;
|
||||
readonly baseWidth: number;
|
||||
readonly baseHeight: number;
|
||||
readonly shouldClearScaleAnimation: boolean;
|
||||
readonly animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
interface EdgeScaleSession extends CapturedPointerState {
|
||||
readonly kind: "edge-scale";
|
||||
readonly edge: Edge;
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialBoundsCx: number;
|
||||
readonly initialBoundsCy: number;
|
||||
readonly baseWidth: number;
|
||||
readonly baseHeight: number;
|
||||
readonly rotationRad: number;
|
||||
readonly shouldClearScaleAnimation: boolean;
|
||||
readonly animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
interface RotationSession extends CapturedPointerState {
|
||||
readonly kind: "rotation";
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialAngle: number;
|
||||
readonly initialBoundsCx: number;
|
||||
readonly initialBoundsCy: number;
|
||||
}
|
||||
|
||||
type TransformSession =
|
||||
| { readonly kind: "idle" }
|
||||
| CornerScaleSession
|
||||
| EdgeScaleSession
|
||||
| RotationSession;
|
||||
|
||||
const IDLE_SESSION: TransformSession = { kind: "idle" };
|
||||
|
||||
interface VisualSelectionContext {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly element: VisualElement;
|
||||
readonly bounds: ElementBounds;
|
||||
readonly resolvedTransform: Transform;
|
||||
}
|
||||
|
||||
export interface PreviewViewportAdapter {
|
||||
screenToCanvas: ({
|
||||
clientX,
|
||||
clientY,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}) => Point | null;
|
||||
screenPixelsToLogicalThreshold: ({
|
||||
screenPixels,
|
||||
}: {
|
||||
screenPixels: number;
|
||||
}) => Point;
|
||||
}
|
||||
|
||||
export interface InputAdapter {
|
||||
isShiftHeld: () => boolean;
|
||||
}
|
||||
|
||||
export interface SceneReader {
|
||||
getSelectedElements: () => readonly ElementRef[];
|
||||
getTracks: () => SceneTracks;
|
||||
getCurrentTime: () => number;
|
||||
getMediaAssets: () => MediaAsset[];
|
||||
getCanvasSize: () => CanvasSize;
|
||||
}
|
||||
|
||||
export interface TimelinePreviewUpdate {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly updates: Partial<TimelineElement>;
|
||||
}
|
||||
|
||||
export interface TimelineOps {
|
||||
previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
|
||||
commitPreview: () => void;
|
||||
discardPreview: () => void;
|
||||
}
|
||||
|
||||
export interface PreviewOptions {
|
||||
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||
}
|
||||
|
||||
export interface TransformHandleDeps {
|
||||
viewport: PreviewViewportAdapter;
|
||||
input: InputAdapter;
|
||||
scene: SceneReader;
|
||||
timeline: TimelineOps;
|
||||
preview: PreviewOptions;
|
||||
}
|
||||
|
||||
export interface TransformHandleDepsRef {
|
||||
readonly current: TransformHandleDeps;
|
||||
}
|
||||
|
||||
function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
|
||||
return edge === "right"
|
||||
? { right: true }
|
||||
: edge === "left"
|
||||
? { left: true }
|
||||
: { bottom: true };
|
||||
}
|
||||
|
||||
function clampScaleNonZero(scale: number): number {
|
||||
if (Math.abs(scale) < MIN_SCALE) {
|
||||
return scale < 0 ? -MIN_SCALE : MIN_SCALE;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
function getCornerDistance({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
corner: Corner;
|
||||
}): number {
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
|
||||
|
||||
const rotatedX = localX * cos - localY * sin;
|
||||
const rotatedY = localX * sin + localY * cos;
|
||||
return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
|
||||
}
|
||||
|
||||
function buildSelectedWithBounds({
|
||||
selectedElements,
|
||||
elementsWithBounds,
|
||||
}: {
|
||||
selectedElements: readonly ElementRef[];
|
||||
elementsWithBounds: readonly ElementWithBounds[];
|
||||
}): ElementWithBounds | null {
|
||||
if (selectedElements.length !== 1) return null;
|
||||
|
||||
return (
|
||||
elementsWithBounds.find(
|
||||
(entry) =>
|
||||
entry.trackId === selectedElements[0].trackId &&
|
||||
entry.elementId === selectedElements[0].elementId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildCornerScaleAnimationReset({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): {
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
} {
|
||||
const shouldClearScaleAnimation =
|
||||
hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
}) ||
|
||||
hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
});
|
||||
|
||||
return {
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale: shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: setChannel({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
channel: undefined,
|
||||
}),
|
||||
propertyPath: "transform.scaleY",
|
||||
channel: undefined,
|
||||
})
|
||||
: animations,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEdgeScaleAnimationReset({
|
||||
animations,
|
||||
edge,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
edge: Edge;
|
||||
}): {
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
} {
|
||||
const propertyPath =
|
||||
edge === "right" || edge === "left"
|
||||
? "transform.scaleX"
|
||||
: "transform.scaleY";
|
||||
|
||||
const shouldClearScaleAnimation = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
});
|
||||
|
||||
return {
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale: shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel: undefined,
|
||||
})
|
||||
: animations,
|
||||
};
|
||||
}
|
||||
|
||||
export class TransformHandleController {
|
||||
private readonly depsRef: TransformHandleDepsRef;
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
|
||||
private session: TransformSession = IDLE_SESSION;
|
||||
|
||||
constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) {
|
||||
this.depsRef = depsRef;
|
||||
|
||||
this.onCornerPointerDown = this.onCornerPointerDown.bind(this);
|
||||
this.onEdgePointerDown = this.onEdgePointerDown.bind(this);
|
||||
this.onRotationPointerDown = this.onRotationPointerDown.bind(this);
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
}
|
||||
|
||||
private get deps(): TransformHandleDeps {
|
||||
return this.depsRef.current;
|
||||
}
|
||||
|
||||
get selectedWithBounds(): ElementWithBounds | null {
|
||||
return buildSelectedWithBounds({
|
||||
selectedElements: this.deps.scene.getSelectedElements(),
|
||||
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||
});
|
||||
}
|
||||
|
||||
get activeHandle(): HandleType | null {
|
||||
switch (this.session.kind) {
|
||||
case "corner-scale":
|
||||
return this.session.corner;
|
||||
case "edge-scale":
|
||||
return this.session.edge;
|
||||
case "rotation":
|
||||
return "rotation";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.session.kind !== "idle";
|
||||
}
|
||||
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.session.kind !== "idle") {
|
||||
const session = this.session;
|
||||
this.session = IDLE_SESSION;
|
||||
this.deps.timeline.discardPreview();
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer(session);
|
||||
}
|
||||
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.session.kind === "idle") return;
|
||||
|
||||
const session = this.session;
|
||||
this.session = IDLE_SESSION;
|
||||
this.deps.timeline.discardPreview();
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer(session);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onCornerPointerDown({
|
||||
event,
|
||||
corner,
|
||||
}: {
|
||||
event: ReactPointerEvent;
|
||||
corner: Corner;
|
||||
}): void {
|
||||
const context = this.getSelectedVisualContext();
|
||||
if (!context) return;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
const { shouldClearScaleAnimation, animationsWithoutScale } =
|
||||
buildCornerScaleAnimationReset({
|
||||
animations: context.element.animations,
|
||||
});
|
||||
|
||||
this.session = {
|
||||
kind: "corner-scale",
|
||||
corner,
|
||||
trackId: context.trackId,
|
||||
elementId: context.elementId,
|
||||
initialTransform: context.resolvedTransform,
|
||||
initialDistance: getCornerDistance({
|
||||
bounds: context.bounds,
|
||||
corner,
|
||||
}),
|
||||
initialBoundsCx: context.bounds.cx,
|
||||
initialBoundsCy: context.bounds.cy,
|
||||
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
|
||||
baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
pointerId: event.pointerId,
|
||||
captureTarget: this.capturePointer({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
pointerId: event.pointerId,
|
||||
}),
|
||||
};
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onRotationPointerDown({ event }: { event: ReactPointerEvent }): void {
|
||||
const context = this.getSelectedVisualContext();
|
||||
if (!context) return;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
const position = this.deps.viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
|
||||
const deltaX = position.x - context.bounds.cx;
|
||||
const deltaY = position.y - context.bounds.cy;
|
||||
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
|
||||
this.session = {
|
||||
kind: "rotation",
|
||||
trackId: context.trackId,
|
||||
elementId: context.elementId,
|
||||
initialTransform: context.resolvedTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx: context.bounds.cx,
|
||||
initialBoundsCy: context.bounds.cy,
|
||||
pointerId: event.pointerId,
|
||||
captureTarget: this.capturePointer({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
pointerId: event.pointerId,
|
||||
}),
|
||||
};
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onEdgePointerDown({
|
||||
event,
|
||||
edge,
|
||||
}: {
|
||||
event: ReactPointerEvent;
|
||||
edge: Edge;
|
||||
}): void {
|
||||
const context = this.getSelectedVisualContext();
|
||||
if (!context) return;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
const { shouldClearScaleAnimation, animationsWithoutScale } =
|
||||
buildEdgeScaleAnimationReset({
|
||||
animations: context.element.animations,
|
||||
edge,
|
||||
});
|
||||
|
||||
this.session = {
|
||||
kind: "edge-scale",
|
||||
edge,
|
||||
trackId: context.trackId,
|
||||
elementId: context.elementId,
|
||||
initialTransform: context.resolvedTransform,
|
||||
initialBoundsCx: context.bounds.cx,
|
||||
initialBoundsCy: context.bounds.cy,
|
||||
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
|
||||
baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
|
||||
rotationRad: (context.bounds.rotation * Math.PI) / 180,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
pointerId: event.pointerId,
|
||||
captureTarget: this.capturePointer({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
pointerId: event.pointerId,
|
||||
}),
|
||||
};
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onPointerMove({ event }: { event: ReactPointerEvent }): void {
|
||||
if (this.session.kind === "idle") return;
|
||||
|
||||
const position = this.deps.viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
|
||||
switch (this.session.kind) {
|
||||
case "corner-scale":
|
||||
this.previewCornerScale({
|
||||
session: this.session,
|
||||
position,
|
||||
});
|
||||
return;
|
||||
case "edge-scale":
|
||||
this.previewEdgeScale({
|
||||
session: this.session,
|
||||
position,
|
||||
});
|
||||
return;
|
||||
case "rotation":
|
||||
this.previewRotation({
|
||||
session: this.session,
|
||||
position,
|
||||
});
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
if (this.session.kind === "idle") return;
|
||||
|
||||
const session = this.session;
|
||||
this.session = IDLE_SESSION;
|
||||
this.deps.timeline.commitPreview();
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer(session);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.subscribers) fn();
|
||||
}
|
||||
|
||||
private clearSnapLines(): void {
|
||||
this.deps.preview.onSnapLinesChange?.([]);
|
||||
}
|
||||
|
||||
private capturePointer({
|
||||
target,
|
||||
pointerId,
|
||||
}: {
|
||||
target: HTMLElement;
|
||||
pointerId: number;
|
||||
}): HTMLElement {
|
||||
target.setPointerCapture(pointerId);
|
||||
return target;
|
||||
}
|
||||
|
||||
private releaseCapturedPointer(pointerState: CapturedPointerState): void {
|
||||
if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
||||
}
|
||||
|
||||
private getVisibleElementsWithBounds(): ElementWithBounds[] {
|
||||
return getVisibleElementsWithBounds({
|
||||
tracks: this.deps.scene.getTracks(),
|
||||
currentTime: this.deps.scene.getCurrentTime(),
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
mediaAssets: this.deps.scene.getMediaAssets(),
|
||||
});
|
||||
}
|
||||
|
||||
private getSelectedVisualContext(): VisualSelectionContext | null {
|
||||
const selectedWithBounds = this.selectedWithBounds;
|
||||
if (!selectedWithBounds) return null;
|
||||
if (!isVisualElement(selectedWithBounds.element)) return null;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: this.deps.scene.getCurrentTime(),
|
||||
elementStartTime: selectedWithBounds.element.startTime,
|
||||
elementDuration: selectedWithBounds.element.duration,
|
||||
});
|
||||
|
||||
return {
|
||||
trackId: selectedWithBounds.trackId,
|
||||
elementId: selectedWithBounds.elementId,
|
||||
element: selectedWithBounds.element,
|
||||
bounds: selectedWithBounds.bounds,
|
||||
resolvedTransform: resolveTransformAtTime({
|
||||
baseTransform: selectedWithBounds.element.transform,
|
||||
animations: selectedWithBounds.element.animations,
|
||||
localTime,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private previewCornerScale({
|
||||
session,
|
||||
position,
|
||||
}: {
|
||||
session: CornerScaleSession;
|
||||
position: Point;
|
||||
}): void {
|
||||
const deltaX = position.x - session.initialBoundsCx;
|
||||
const deltaY = position.y - session.initialBoundsCy;
|
||||
const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
||||
const scaleFactor = currentDistance / session.initialDistance;
|
||||
|
||||
// Use actual element dimensions (base * current scale) so snapping is
|
||||
// computed from the rendered geometry when scaleX != scaleY.
|
||||
const effectiveWidth = session.baseWidth * session.initialTransform.scaleX;
|
||||
const effectiveHeight =
|
||||
session.baseHeight * session.initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedScale, activeLines } = this.deps.input.isShiftHeld()
|
||||
? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
|
||||
: snapScale({
|
||||
proposedScale: scaleFactor,
|
||||
position: session.initialTransform.position,
|
||||
baseWidth: effectiveWidth,
|
||||
baseHeight: effectiveHeight,
|
||||
rotation: session.initialTransform.rotate,
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
this.deps.preview.onSnapLinesChange?.(activeLines);
|
||||
|
||||
this.deps.timeline.previewElements([
|
||||
{
|
||||
trackId: session.trackId,
|
||||
elementId: session.elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
scaleX: clampScaleNonZero(
|
||||
session.initialTransform.scaleX * snappedScale,
|
||||
),
|
||||
scaleY: clampScaleNonZero(
|
||||
session.initialTransform.scaleY * snappedScale,
|
||||
),
|
||||
},
|
||||
...(session.shouldClearScaleAnimation && {
|
||||
animations: session.animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private previewEdgeScale({
|
||||
session,
|
||||
position,
|
||||
}: {
|
||||
session: EdgeScaleSession;
|
||||
position: Point;
|
||||
}): void {
|
||||
const deltaX = position.x - session.initialBoundsCx;
|
||||
const deltaY = position.y - session.initialBoundsCy;
|
||||
const xProjection =
|
||||
deltaX * Math.cos(session.rotationRad) +
|
||||
deltaY * Math.sin(session.rotationRad);
|
||||
const yProjection =
|
||||
-deltaX * Math.sin(session.rotationRad) +
|
||||
deltaY * Math.cos(session.rotationRad);
|
||||
const projection =
|
||||
session.edge === "right"
|
||||
? xProjection
|
||||
: session.edge === "left"
|
||||
? -xProjection
|
||||
: yProjection;
|
||||
|
||||
const baseAxisHalf =
|
||||
session.edge === "right" || session.edge === "left"
|
||||
? session.baseWidth / 2
|
||||
: session.baseHeight / 2;
|
||||
const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
|
||||
|
||||
const proposedScaleX =
|
||||
session.edge === "right" || session.edge === "left"
|
||||
? proposedScale
|
||||
: session.initialTransform.scaleX;
|
||||
const proposedScaleY =
|
||||
session.edge === "bottom"
|
||||
? proposedScale
|
||||
: session.initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld()
|
||||
? {
|
||||
x: {
|
||||
snappedScale: proposedScaleX,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
y: {
|
||||
snappedScale: proposedScaleY,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
}
|
||||
: snapScaleAxes({
|
||||
proposedScaleX,
|
||||
proposedScaleY,
|
||||
position: session.initialTransform.position,
|
||||
baseWidth: session.baseWidth,
|
||||
baseHeight: session.baseHeight,
|
||||
rotation: session.initialTransform.rotate,
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
snapThreshold,
|
||||
preferredEdges: getPreferredEdge({ edge: session.edge }),
|
||||
});
|
||||
|
||||
const relevantSnap =
|
||||
session.edge === "right" || session.edge === "left" ? xSnap : ySnap;
|
||||
this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines);
|
||||
|
||||
this.deps.timeline.previewElements([
|
||||
{
|
||||
trackId: session.trackId,
|
||||
elementId: session.elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
scaleX:
|
||||
session.edge === "right" || session.edge === "left"
|
||||
? xSnap.snappedScale
|
||||
: session.initialTransform.scaleX,
|
||||
scaleY:
|
||||
session.edge === "bottom"
|
||||
? ySnap.snappedScale
|
||||
: session.initialTransform.scaleY,
|
||||
},
|
||||
...(session.shouldClearScaleAnimation && {
|
||||
animations: session.animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private previewRotation({
|
||||
session,
|
||||
position,
|
||||
}: {
|
||||
session: RotationSession;
|
||||
position: Point;
|
||||
}): void {
|
||||
const deltaX = position.x - session.initialBoundsCx;
|
||||
const deltaY = position.y - session.initialBoundsCy;
|
||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
let deltaAngle = currentAngle - session.initialAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
|
||||
const newRotate = session.initialTransform.rotate + deltaAngle;
|
||||
const { snappedRotation } = this.deps.input.isShiftHeld()
|
||||
? { snappedRotation: newRotate }
|
||||
: snapRotation({ proposedRotation: newRotate });
|
||||
|
||||
this.deps.timeline.previewElements([
|
||||
{
|
||||
trackId: session.trackId,
|
||||
elementId: session.elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
rotate: snappedRotation,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +1,17 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { ElementRef, TextElement } from "@/timeline";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
getHitElements,
|
||||
hitTest,
|
||||
resolvePreferredHit,
|
||||
} from "@/preview/hit-test";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapPosition,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import type { SnapLine } from "@/preview/preview-snap";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
import {
|
||||
PreviewInteractionController,
|
||||
type PreviewInteractionDeps,
|
||||
type PreviewInteractionDepsRef,
|
||||
} from "@/preview/controllers/preview-interaction-controller";
|
||||
|
||||
export type OnSnapLinesChange = (lines: SnapLine[]) => void;
|
||||
|
||||
const MIN_DRAG_DISTANCE = 0.5;
|
||||
|
||||
interface CapturedPointerState {
|
||||
pointerId: number;
|
||||
captureTarget: HTMLElement;
|
||||
}
|
||||
|
||||
interface PendingGestureState extends CapturedPointerState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
topmostHit: ElementWithBounds | null;
|
||||
selectedHit: ElementWithBounds | null;
|
||||
selectedElements: ElementRef[];
|
||||
}
|
||||
|
||||
interface DragState extends CapturedPointerState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
bounds: {
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
elements: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
}>;
|
||||
}
|
||||
|
||||
function isSameElementRef({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: ElementRef;
|
||||
right: ElementRef;
|
||||
}): boolean {
|
||||
return left.trackId === right.trackId && left.elementId === right.elementId;
|
||||
}
|
||||
|
||||
function buildDragSelection({
|
||||
selectedElements,
|
||||
dragTarget,
|
||||
}: {
|
||||
selectedElements: ElementRef[];
|
||||
dragTarget: ElementWithBounds;
|
||||
}): ElementRef[] {
|
||||
const dragTargetRef = {
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
};
|
||||
|
||||
if (
|
||||
!selectedElements.some((selectedElement) =>
|
||||
isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
)
|
||||
) {
|
||||
return [dragTargetRef];
|
||||
}
|
||||
|
||||
return [
|
||||
dragTargetRef,
|
||||
...selectedElements.filter(
|
||||
(selectedElement) =>
|
||||
!isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function usePreviewInteraction({
|
||||
onSnapLinesChange,
|
||||
isMaskMode = false,
|
||||
|
|
@ -102,355 +22,74 @@ export function usePreviewInteraction({
|
|||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const viewport = usePreviewViewport();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [editingText, setEditingText] = useState<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TextElement;
|
||||
originalOpacity: number;
|
||||
} | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const pendingGestureRef = useRef<PendingGestureState | null>(null);
|
||||
const wasPlayingRef = useRef(editor.playback.getIsPlaying());
|
||||
const editingTextRef = useRef(editingText);
|
||||
editingTextRef.current = editingText;
|
||||
|
||||
const releaseCapturedPointer = useCallback(
|
||||
(pointerState: CapturedPointerState | null) => {
|
||||
if (!pointerState) return;
|
||||
|
||||
if (
|
||||
!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
||||
const deps: PreviewInteractionDeps = {
|
||||
viewport: {
|
||||
screenToCanvas: viewport.screenToCanvas,
|
||||
screenPixelsToLogicalThreshold: viewport.screenPixelsToLogicalThreshold,
|
||||
},
|
||||
[],
|
||||
);
|
||||
input: {
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
},
|
||||
scene: {
|
||||
getTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getCurrentTime: () => editor.playback.getCurrentTime(),
|
||||
getMediaAssets: () => editor.media.getAssets(),
|
||||
getCanvasSize: () => editor.project.getActive().settings.canvasSize,
|
||||
},
|
||||
selection: {
|
||||
getSelected: () => editor.selection.getSelectedElements(),
|
||||
setSelected: (elements) =>
|
||||
editor.selection.setSelectedElements({ elements: [...elements] }),
|
||||
clearSelection: () => editor.selection.clearSelection(),
|
||||
},
|
||||
timeline: {
|
||||
getElementsWithTracks: ({ elements }) =>
|
||||
editor.timeline.getElementsWithTracks({ elements: [...elements] }),
|
||||
previewElements: (updates) =>
|
||||
editor.timeline.previewElements({ updates: [...updates] }),
|
||||
commitPreview: () => editor.timeline.commitPreview(),
|
||||
discardPreview: () => editor.timeline.discardPreview(),
|
||||
},
|
||||
playback: {
|
||||
getIsPlaying: () => editor.playback.getIsPlaying(),
|
||||
subscribe: (listener) => editor.playback.subscribe(listener),
|
||||
},
|
||||
preview: {
|
||||
isMaskMode: () => isMaskMode,
|
||||
onSnapLinesChange,
|
||||
},
|
||||
};
|
||||
|
||||
const commitTextEdit = useCallback(() => {
|
||||
const current = editingTextRef.current;
|
||||
if (!current) return;
|
||||
editingTextRef.current = null;
|
||||
editor.timeline.commitPreview();
|
||||
setEditingText(null);
|
||||
}, [editor.timeline]);
|
||||
const depsRef = useRef<PreviewInteractionDeps>(deps);
|
||||
depsRef.current = deps;
|
||||
|
||||
const controllerRef = useRef<PreviewInteractionController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new PreviewInteractionController({
|
||||
depsRef: depsRef as PreviewInteractionDepsRef,
|
||||
});
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(
|
||||
() => controller.subscribe({ listener: rerender }),
|
||||
[controller],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = editor.playback.subscribe(() => {
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
|
||||
commitTextEdit();
|
||||
}
|
||||
wasPlayingRef.current = isPlaying;
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [editor.playback, commitTextEdit]);
|
||||
if (!controller.isDragging) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller.isDragging, controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState) return;
|
||||
|
||||
editor.timeline.discardPreview();
|
||||
dragStateRef.current = null;
|
||||
pendingGestureRef.current = null;
|
||||
setIsDragging(false);
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(dragState);
|
||||
},
|
||||
});
|
||||
}, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
({ clientX, clientY }: React.MouseEvent) => {
|
||||
if (editingText || isMaskMode) return;
|
||||
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const startPos = viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const hit = hitTest({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds,
|
||||
});
|
||||
|
||||
if (!hit || hit.element.type !== "text") return;
|
||||
|
||||
const textElement = hit.element as TextElement;
|
||||
setEditingText({
|
||||
trackId: hit.trackId,
|
||||
elementId: hit.elementId,
|
||||
element: textElement,
|
||||
originalOpacity: textElement.opacity,
|
||||
});
|
||||
},
|
||||
[editor, editingText, isMaskMode, viewport],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
({
|
||||
clientX,
|
||||
clientY,
|
||||
currentTarget,
|
||||
pointerId,
|
||||
button,
|
||||
}: React.PointerEvent) => {
|
||||
if (editingText) return;
|
||||
if (isMaskMode) return;
|
||||
if (button !== 0) return;
|
||||
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const startPos = viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const hits = getHitElements({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds,
|
||||
});
|
||||
const selectedElements = editor.selection.getSelectedElements();
|
||||
const topmostHit = hits[0] ?? null;
|
||||
|
||||
pendingGestureRef.current = {
|
||||
startX: startPos.x,
|
||||
startY: startPos.y,
|
||||
pointerId,
|
||||
captureTarget: currentTarget as HTMLElement,
|
||||
topmostHit,
|
||||
selectedHit: resolvePreferredHit({
|
||||
hits,
|
||||
preferredElements: selectedElements,
|
||||
}),
|
||||
selectedElements,
|
||||
};
|
||||
currentTarget.setPointerCapture(pointerId);
|
||||
},
|
||||
[editor, editingText, isMaskMode, viewport],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
({ clientX, clientY }: React.PointerEvent) => {
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const currentPos = viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!currentPos) return;
|
||||
|
||||
let dragState = dragStateRef.current;
|
||||
|
||||
if (!dragState) {
|
||||
const pendingGesture = pendingGestureRef.current;
|
||||
if (!pendingGesture) return;
|
||||
|
||||
const deltaX = currentPos.x - pendingGesture.startX;
|
||||
const deltaY = currentPos.y - pendingGesture.startY;
|
||||
const hasMovement =
|
||||
Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
|
||||
Math.abs(deltaY) > MIN_DRAG_DISTANCE;
|
||||
|
||||
if (!hasMovement) {
|
||||
onSnapLinesChange?.([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
|
||||
if (!dragTarget) {
|
||||
pendingGestureRef.current = null;
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(pendingGesture);
|
||||
return;
|
||||
}
|
||||
|
||||
const dragSelection = buildDragSelection({
|
||||
selectedElements: pendingGesture.selectedElements,
|
||||
dragTarget,
|
||||
});
|
||||
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
||||
elements: dragSelection,
|
||||
});
|
||||
const draggableElements = elementsWithTracks.filter(({ element }) =>
|
||||
isVisualElement(element),
|
||||
);
|
||||
|
||||
if (draggableElements.length === 0) {
|
||||
pendingGestureRef.current = null;
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(pendingGesture);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingGesture.selectedHit === null) {
|
||||
editor.selection.setSelectedElements({
|
||||
elements: [
|
||||
{
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
dragState = {
|
||||
startX: pendingGesture.startX,
|
||||
startY: pendingGesture.startY,
|
||||
pointerId: pendingGesture.pointerId,
|
||||
captureTarget: pendingGesture.captureTarget,
|
||||
bounds: {
|
||||
width: dragTarget.bounds.width,
|
||||
height: dragTarget.bounds.height,
|
||||
rotation: dragTarget.bounds.rotation,
|
||||
},
|
||||
elements: draggableElements.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialTransform: (element as { transform: Transform }).transform,
|
||||
})),
|
||||
};
|
||||
dragStateRef.current = dragState;
|
||||
pendingGestureRef.current = null;
|
||||
setIsDragging(true);
|
||||
}
|
||||
|
||||
const deltaX = currentPos.x - dragState.startX;
|
||||
const deltaY = currentPos.y - dragState.startY;
|
||||
const firstElement = dragState.elements[0];
|
||||
const proposedPosition = {
|
||||
x: firstElement.initialTransform.position.x + deltaX,
|
||||
y: firstElement.initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
const shouldSnap = !isShiftHeldRef.current;
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedPosition, activeLines } = shouldSnap
|
||||
? snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize,
|
||||
elementSize: dragState.bounds,
|
||||
rotation: dragState.bounds.rotation,
|
||||
snapThreshold,
|
||||
})
|
||||
: {
|
||||
snappedPosition: proposedPosition,
|
||||
activeLines: [] as SnapLine[],
|
||||
};
|
||||
|
||||
onSnapLinesChange?.(activeLines);
|
||||
|
||||
const deltaSnappedX =
|
||||
snappedPosition.x - firstElement.initialTransform.position.x;
|
||||
const deltaSnappedY =
|
||||
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||
|
||||
const updates = dragState.elements.map(
|
||||
({ trackId, elementId, initialTransform }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: {
|
||||
x: initialTransform.position.x + deltaSnappedX,
|
||||
y: initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
editor.timeline.previewElements({ updates });
|
||||
},
|
||||
[editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
({ type }: React.PointerEvent) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (dragState) {
|
||||
if (type === "pointercancel") {
|
||||
editor.timeline.discardPreview();
|
||||
} else {
|
||||
editor.timeline.commitPreview();
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
pendingGestureRef.current = null;
|
||||
setIsDragging(false);
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(dragState);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingGesture = pendingGestureRef.current;
|
||||
if (!pendingGesture) return;
|
||||
|
||||
if (type !== "pointercancel") {
|
||||
const clickTarget = pendingGesture.topmostHit;
|
||||
if (!clickTarget) {
|
||||
editor.selection.clearSelection();
|
||||
} else {
|
||||
editor.selection.setSelectedElements({
|
||||
elements: [
|
||||
{
|
||||
trackId: clickTarget.trackId,
|
||||
elementId: clickTarget.elementId,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pendingGestureRef.current = null;
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(pendingGesture);
|
||||
},
|
||||
[editor, onSnapLinesChange, releaseCapturedPointer],
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
onDoubleClick: handleDoubleClick,
|
||||
editingText,
|
||||
commitTextEdit,
|
||||
onPointerDown: controller.onPointerDown,
|
||||
onPointerMove: controller.onPointerMove,
|
||||
onPointerUp: controller.onPointerUp,
|
||||
onDoubleClick: controller.onDoubleClick,
|
||||
editingText: controller.editingText,
|
||||
commitTextEdit: controller.commitTextEdit,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,629 +1,84 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
MIN_SCALE,
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
snapScaleAxes,
|
||||
type ScaleEdgePreference,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
hasKeyframesForPath,
|
||||
resolveTransformAtTime,
|
||||
setChannel,
|
||||
} from "@/animation";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
|
||||
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type Edge = "right" | "left" | "bottom";
|
||||
type HandleType = Corner | Edge | "rotation";
|
||||
|
||||
function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
|
||||
return edge === "right"
|
||||
? { right: true }
|
||||
: edge === "left"
|
||||
? { left: true }
|
||||
: { bottom: true };
|
||||
}
|
||||
|
||||
interface ScaleState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialDistance: number;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
interface RotationState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialAngle: number;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
}
|
||||
|
||||
interface EdgeScaleState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
edge: Edge;
|
||||
rotationRad: number;
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
function clampScaleNonZero(scale: number): number {
|
||||
if (Math.abs(scale) < MIN_SCALE) {
|
||||
return scale < 0 ? -MIN_SCALE : MIN_SCALE;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
function getCornerDistance({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
corner: Corner;
|
||||
}): number {
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
|
||||
|
||||
const rotatedX = localX * cos - localY * sin;
|
||||
const rotatedY = localX * sin + localY * cos;
|
||||
return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
|
||||
}
|
||||
import {
|
||||
TransformHandleController,
|
||||
type TransformHandleDeps,
|
||||
} from "@/preview/controllers/transform-handle-controller";
|
||||
|
||||
export function useTransformHandles({
|
||||
onSnapLinesChange,
|
||||
}: {
|
||||
onSnapLinesChange?: OnSnapLinesChange;
|
||||
}) {
|
||||
const viewport = usePreviewViewport();
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const viewport = usePreviewViewport();
|
||||
const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
|
||||
const scaleStateRef = useRef<ScaleState | null>(null);
|
||||
const rotationStateRef = useRef<RotationState | null>(null);
|
||||
const edgeScaleStateRef = useRef<EdgeScaleState | null>(null);
|
||||
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
||||
const tracks = useEditor(
|
||||
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||
);
|
||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
currentTimeRef.current = currentTime;
|
||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||
const canvasSize = useEditor(
|
||||
(e) => e.project.getActive().settings.canvasSize,
|
||||
);
|
||||
const deps: TransformHandleDeps = {
|
||||
viewport,
|
||||
input: {
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
},
|
||||
scene: {
|
||||
getSelectedElements: () => selectedElements,
|
||||
getTracks: () => tracks,
|
||||
getCurrentTime: () => currentTime,
|
||||
getMediaAssets: () => mediaAssets,
|
||||
getCanvasSize: () => canvasSize,
|
||||
},
|
||||
timeline: {
|
||||
previewElements: (updates) =>
|
||||
editor.timeline.previewElements({ updates }),
|
||||
commitPreview: () => editor.timeline.commitPreview(),
|
||||
discardPreview: () => editor.timeline.discardPreview(),
|
||||
},
|
||||
preview: {
|
||||
onSnapLinesChange,
|
||||
},
|
||||
};
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
const depsRef = useRef<TransformHandleDeps>(deps);
|
||||
depsRef.current = deps;
|
||||
|
||||
const selectedWithBounds: ElementWithBounds | null =
|
||||
selectedElements.length === 1
|
||||
? (elementsWithBounds.find(
|
||||
(entry) =>
|
||||
entry.trackId === selectedElements[0].trackId &&
|
||||
entry.elementId === selectedElements[0].elementId,
|
||||
) ?? null)
|
||||
: null;
|
||||
const controllerRef = useRef<TransformHandleController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new TransformHandleController({ depsRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const hasVisualSelection =
|
||||
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
|
||||
|
||||
const clearActiveHandleState = useCallback(() => {
|
||||
scaleStateRef.current = null;
|
||||
rotationStateRef.current = null;
|
||||
edgeScaleStateRef.current = null;
|
||||
setActiveHandle(null);
|
||||
onSnapLinesChange?.([]);
|
||||
}, [onSnapLinesChange]);
|
||||
|
||||
const releaseCapturedPointer = useCallback(() => {
|
||||
const capture = captureRef.current;
|
||||
if (!capture) return;
|
||||
|
||||
if (capture.element.hasPointerCapture(capture.pointerId)) {
|
||||
capture.element.releasePointerCapture(capture.pointerId);
|
||||
}
|
||||
|
||||
captureRef.current = null;
|
||||
}, []);
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHandle) return;
|
||||
if (!controller.isActive) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller, controller.isActive]);
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
editor.timeline.discardPreview();
|
||||
clearActiveHandleState();
|
||||
releaseCapturedPointer();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
activeHandle,
|
||||
clearActiveHandleState,
|
||||
editor.timeline,
|
||||
releaseCapturedPointer,
|
||||
]);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
const handleCornerPointerDown = useCallback(
|
||||
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const initialDistance = getCornerDistance({ bounds, corner });
|
||||
const baseWidth = bounds.width / resolvedTransform.scaleX;
|
||||
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
||||
const shouldClearScaleAnimation =
|
||||
hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
}) ||
|
||||
hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
});
|
||||
const animationsWithoutScale = shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: setChannel({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
channel: undefined,
|
||||
}),
|
||||
propertyPath: "transform.scaleY",
|
||||
channel: undefined,
|
||||
})
|
||||
: element.animations;
|
||||
|
||||
scaleStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: resolvedTransform,
|
||||
initialDistance,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
};
|
||||
setActiveHandle(corner);
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithBounds],
|
||||
);
|
||||
|
||||
const handleRotationPointerDown = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const position = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
const deltaX = position.x - bounds.cx;
|
||||
const deltaY = position.y - bounds.cy;
|
||||
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
|
||||
rotationStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: resolvedTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
};
|
||||
setActiveHandle("rotation");
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithBounds, viewport],
|
||||
);
|
||||
|
||||
const handleEdgePointerDown = useCallback(
|
||||
({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const baseWidth = bounds.width / resolvedTransform.scaleX;
|
||||
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
||||
const rotationRad = (bounds.rotation * Math.PI) / 180;
|
||||
|
||||
const propertyPath =
|
||||
edge === "right" || edge === "left"
|
||||
? "transform.scaleX"
|
||||
: "transform.scaleY";
|
||||
const shouldClearScaleAnimation = hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
});
|
||||
const animationsWithoutScale = shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
channel: undefined,
|
||||
})
|
||||
: element.animations;
|
||||
|
||||
edgeScaleStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: resolvedTransform,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
edge,
|
||||
rotationRad,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
};
|
||||
setActiveHandle(edge);
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithBounds],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (
|
||||
!scaleStateRef.current &&
|
||||
!rotationStateRef.current &&
|
||||
!edgeScaleStateRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
const position = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
|
||||
if (
|
||||
scaleStateRef.current &&
|
||||
activeHandle &&
|
||||
activeHandle !== "rotation"
|
||||
) {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialDistance,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
} = scaleStateRef.current;
|
||||
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const currentDistance =
|
||||
Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
||||
const scaleFactor = currentDistance / initialDistance;
|
||||
|
||||
// Use actual element dimensions (base * current scale) so snap
|
||||
// computes the correct edges when scaleX ≠ scaleY
|
||||
const effectiveWidth = baseWidth * initialTransform.scaleX;
|
||||
const effectiveHeight = baseHeight * initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedScale: snappedFactor, activeLines } =
|
||||
isShiftHeldRef.current
|
||||
? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
|
||||
: snapScale({
|
||||
proposedScale: scaleFactor,
|
||||
position: initialTransform.position,
|
||||
baseWidth: effectiveWidth,
|
||||
baseHeight: effectiveHeight,
|
||||
rotation: initialTransform.rotate,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
onSnapLinesChange?.(activeLines);
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
scaleX: clampScaleNonZero(
|
||||
initialTransform.scaleX * snappedFactor,
|
||||
),
|
||||
scaleY: clampScaleNonZero(
|
||||
initialTransform.scaleY * snappedFactor,
|
||||
),
|
||||
},
|
||||
...(shouldClearScaleAnimation && {
|
||||
animations: animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
edgeScaleStateRef.current &&
|
||||
(activeHandle === "right" ||
|
||||
activeHandle === "left" ||
|
||||
activeHandle === "bottom")
|
||||
) {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
edge,
|
||||
rotationRad,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
} = edgeScaleStateRef.current;
|
||||
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const xProjection =
|
||||
deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
|
||||
const yProjection =
|
||||
-deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
|
||||
const projection =
|
||||
edge === "right"
|
||||
? xProjection
|
||||
: edge === "left"
|
||||
? -xProjection
|
||||
: yProjection;
|
||||
|
||||
const baseAxisHalf =
|
||||
edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
|
||||
const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
|
||||
|
||||
const proposedScaleX =
|
||||
edge === "right" || edge === "left"
|
||||
? proposedScale
|
||||
: initialTransform.scaleX;
|
||||
const proposedScaleY =
|
||||
edge === "bottom" ? proposedScale : initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { x: xSnap, y: ySnap } = isShiftHeldRef.current
|
||||
? {
|
||||
x: {
|
||||
snappedScale: proposedScaleX,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
y: {
|
||||
snappedScale: proposedScaleY,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
}
|
||||
: snapScaleAxes({
|
||||
proposedScaleX,
|
||||
proposedScaleY,
|
||||
position: initialTransform.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation: initialTransform.rotate,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges: getPreferredEdge({ edge }),
|
||||
});
|
||||
|
||||
const relevantSnap =
|
||||
edge === "right" || edge === "left" ? xSnap : ySnap;
|
||||
onSnapLinesChange?.(relevantSnap.activeLines);
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
scaleX:
|
||||
edge === "right" || edge === "left"
|
||||
? xSnap.snappedScale
|
||||
: initialTransform.scaleX,
|
||||
scaleY:
|
||||
edge === "bottom"
|
||||
? ySnap.snappedScale
|
||||
: initialTransform.scaleY,
|
||||
},
|
||||
...(shouldClearScaleAnimation && {
|
||||
animations: animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (rotationStateRef.current && activeHandle === "rotation") {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
} = rotationStateRef.current;
|
||||
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
let deltaAngle = currentAngle - initialAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
const newRotate = initialTransform.rotate + deltaAngle;
|
||||
const { snappedRotation } = isShiftHeldRef.current
|
||||
? { snappedRotation: newRotate }
|
||||
: snapRotation({ proposedRotation: newRotate });
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: { ...initialTransform, rotate: snappedRotation },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
canvasSize,
|
||||
editor,
|
||||
isShiftHeldRef,
|
||||
onSnapLinesChange,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (
|
||||
scaleStateRef.current ||
|
||||
rotationStateRef.current ||
|
||||
edgeScaleStateRef.current
|
||||
) {
|
||||
editor.timeline.commitPreview();
|
||||
clearActiveHandleState();
|
||||
}
|
||||
releaseCapturedPointer();
|
||||
}, [clearActiveHandleState, editor, releaseCapturedPointer]);
|
||||
const selectedWithBounds = controller.selectedWithBounds;
|
||||
const hasVisualSelection = selectedWithBounds !== null;
|
||||
|
||||
return {
|
||||
selectedWithBounds,
|
||||
hasVisualSelection,
|
||||
activeHandle,
|
||||
handleCornerPointerDown,
|
||||
handleEdgePointerDown,
|
||||
handleRotationPointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
activeHandle: controller.activeHandle,
|
||||
handleCornerPointerDown: controller.onCornerPointerDown,
|
||||
handleEdgePointerDown: controller.onEdgePointerDown,
|
||||
handleRotationPointerDown: controller.onRotationPointerDown,
|
||||
handlePointerMove: controller.onPointerMove,
|
||||
handlePointerUp: controller.onPointerUp,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export class VideoCache {
|
|||
private sinks = new Map<string, VideoSinkData>();
|
||||
private initPromises = new Map<string, Promise<void>>();
|
||||
private frameChain = new Map<string, Promise<unknown>>();
|
||||
private seekGenerations = new Map<string, number>();
|
||||
|
||||
async getFrameAt({
|
||||
mediaId,
|
||||
|
|
@ -36,11 +37,20 @@ export class VideoCache {
|
|||
const sinkData = this.sinks.get(mediaId);
|
||||
if (!sinkData) return null;
|
||||
|
||||
const generation = (this.seekGenerations.get(mediaId) ?? 0) + 1;
|
||||
this.seekGenerations.set(mediaId, generation);
|
||||
|
||||
const previous = this.frameChain.get(mediaId) ?? Promise.resolve();
|
||||
const current = previous.then(() =>
|
||||
this.resolveFrame({ sinkData, time }),
|
||||
const current = previous.then(() => {
|
||||
if (this.seekGenerations.get(mediaId) !== generation) {
|
||||
return sinkData.currentFrame ?? null;
|
||||
}
|
||||
return this.resolveFrame({ sinkData, time });
|
||||
});
|
||||
this.frameChain.set(
|
||||
mediaId,
|
||||
current.catch(() => {}),
|
||||
);
|
||||
this.frameChain.set(mediaId, current.catch(() => {}));
|
||||
return current;
|
||||
}
|
||||
|
||||
|
|
@ -173,18 +183,7 @@ export class VideoCache {
|
|||
|
||||
if (frame) {
|
||||
sinkData.currentFrame = frame;
|
||||
|
||||
// Aggressively fetch next frame immediately to fill buffer
|
||||
// This matches the mediaplayer example which fetches 2 frames on start
|
||||
try {
|
||||
const { value: next } = await sinkData.iterator.next();
|
||||
if (next) {
|
||||
sinkData.nextFrame = next;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to pre-fetch next frame on seek:", e);
|
||||
}
|
||||
|
||||
this.startPrefetch({ sinkData });
|
||||
return frame;
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -313,6 +312,8 @@ export class VideoCache {
|
|||
}
|
||||
|
||||
this.initPromises.delete(mediaId);
|
||||
this.frameChain.delete(mediaId);
|
||||
this.seekGenerations.delete(mediaId);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { ElementDragState, DropTarget } from "@/timeline";
|
||||
import type { ElementDragView, DropTarget } from "@/timeline";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import { TimelinePlayhead } from "./timeline-playhead";
|
||||
import { SelectionBox } from "@/selection/selection-box";
|
||||
|
|
@ -287,20 +287,15 @@ export function Timeline() {
|
|||
isReady: tracks.length > 0,
|
||||
});
|
||||
|
||||
const {
|
||||
dragState,
|
||||
dragDropTarget,
|
||||
handleElementMouseDown,
|
||||
handleElementClick,
|
||||
lastMouseXRef,
|
||||
} = useElementInteraction({
|
||||
zoomLevel,
|
||||
timelineRef,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
const { dragView, handleElementMouseDown, handleElementClick } =
|
||||
useElementInteraction({
|
||||
zoomLevel,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
const isElementDragging = dragView.kind === "dragging";
|
||||
|
||||
const {
|
||||
dragState: bookmarkDragState,
|
||||
|
|
@ -391,10 +386,19 @@ export function Timeline() {
|
|||
contentWidth: dynamicTimelineWidth,
|
||||
});
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: isElementDragging,
|
||||
getMouseClientX: () =>
|
||||
dragView.kind === "dragging" ? dragView.currentMouseX : 0,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: dynamicTimelineWidth,
|
||||
});
|
||||
|
||||
const showSnapIndicator =
|
||||
snappingEnabled &&
|
||||
currentSnapPoint !== null &&
|
||||
(dragState.isDragging || bookmarkDragState.isDragging || isResizing);
|
||||
(isElementDragging || bookmarkDragState.isDragging || isResizing);
|
||||
|
||||
const {
|
||||
handleTracksMouseDown,
|
||||
|
|
@ -457,9 +461,9 @@ export function Timeline() {
|
|||
headerHeight={timelineHeaderHeight}
|
||||
/>
|
||||
<DragLine
|
||||
dropTarget={dragDropTarget}
|
||||
dropTarget={isElementDragging ? dragView.dropTarget : null}
|
||||
tracks={tracks}
|
||||
isVisible={dragState.isDragging}
|
||||
isVisible={isElementDragging}
|
||||
headerHeight={timelineHeaderHeight}
|
||||
/>
|
||||
|
||||
|
|
@ -540,9 +544,7 @@ export function Timeline() {
|
|||
<TimelineTrackRows
|
||||
mainTrackId={mainTrackId}
|
||||
zoomLevel={zoomLevel}
|
||||
dragState={dragState}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
lastMouseXRef={lastMouseXRef}
|
||||
dragView={dragView}
|
||||
onResizeStart={handleResizeStart}
|
||||
onElementMouseDown={handleElementMouseDown}
|
||||
onElementClick={handleElementClick}
|
||||
|
|
@ -718,9 +720,7 @@ function TrackLabelsPanel({
|
|||
function TimelineTrackRows({
|
||||
mainTrackId,
|
||||
zoomLevel,
|
||||
dragState,
|
||||
tracksScrollRef,
|
||||
lastMouseXRef,
|
||||
dragView,
|
||||
onResizeStart,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
|
|
@ -732,9 +732,7 @@ function TimelineTrackRows({
|
|||
}: {
|
||||
mainTrackId: string | null;
|
||||
zoomLevel: number;
|
||||
dragState: ElementDragState;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
lastMouseXRef: React.RefObject<number>;
|
||||
dragView: ElementDragView;
|
||||
onResizeStart: React.ComponentProps<
|
||||
typeof TimelineTrackContent
|
||||
>["onResizeStart"];
|
||||
|
|
@ -776,8 +774,16 @@ function TimelineTrackRows({
|
|||
[tracks, expandedElementIds],
|
||||
);
|
||||
|
||||
const draggingElementIds = useMemo(
|
||||
() =>
|
||||
dragView.kind === "dragging"
|
||||
? dragView.memberTimeOffsets
|
||||
: (null as ReadonlyMap<string, number> | null),
|
||||
[dragView],
|
||||
);
|
||||
const sortedTracks = useMemo(() => {
|
||||
const draggingElementIds = new Set(dragState.dragElementIds);
|
||||
if (!draggingElementIds)
|
||||
return tracks.map((track, index) => ({ track, index }));
|
||||
return [...tracks]
|
||||
.map((track, index) => ({ track, index }))
|
||||
.sort((a, b) => {
|
||||
|
|
@ -791,7 +797,7 @@ function TimelineTrackRows({
|
|||
if (bHasDragged) return -1;
|
||||
return 0;
|
||||
});
|
||||
}, [tracks, dragState.dragElementIds]);
|
||||
}, [tracks, draggingElementIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -811,10 +817,7 @@ function TimelineTrackRows({
|
|||
<TimelineTrackContent
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
dragState={dragState}
|
||||
rulerScrollRef={tracksScrollRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
lastMouseXRef={lastMouseXRef}
|
||||
dragView={dragView}
|
||||
onResizeStart={onResizeStart}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
onElementClick={onElementClick}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
ElementDragState,
|
||||
ElementDragView,
|
||||
VideoElement,
|
||||
ImageElement,
|
||||
AudioElement,
|
||||
|
|
@ -214,7 +214,7 @@ interface TimelineElementProps {
|
|||
event: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
dragState: ElementDragState;
|
||||
dragView: ElementDragView;
|
||||
isDropTarget?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -226,7 +226,7 @@ export function TimelineElement({
|
|||
onResizeStart,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
dragState,
|
||||
dragView,
|
||||
isDropTarget = false,
|
||||
}: TimelineElementProps) {
|
||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||
|
|
@ -252,15 +252,18 @@ export function TimelineElement({
|
|||
selected.elementId === element.id && selected.trackId === track.id,
|
||||
);
|
||||
|
||||
const isBeingDragged = dragState.dragElementIds.includes(element.id);
|
||||
const isDragging = dragView.kind === "dragging";
|
||||
const dragTimeOffset = isDragging
|
||||
? dragView.memberTimeOffsets.get(element.id)
|
||||
: undefined;
|
||||
const isBeingDragged = dragTimeOffset !== undefined;
|
||||
const dragOffsetY =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentMouseY - dragState.startMouseY
|
||||
isDragging && isBeingDragged
|
||||
? dragView.currentMouseY - dragView.startMouseY
|
||||
: 0;
|
||||
const dragTimeOffset = dragState.dragTimeOffsets[element.id] ?? 0;
|
||||
const elementStartTime =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentTime + dragTimeOffset
|
||||
isDragging && isBeingDragged
|
||||
? dragView.currentTime + dragTimeOffset
|
||||
: renderElement.startTime;
|
||||
const displayedStartTime = elementStartTime;
|
||||
const displayedDuration = renderElement.duration;
|
||||
|
|
@ -382,7 +385,7 @@ export function TimelineElement({
|
|||
? `${baseTrackHeight + expansionHeight}px`
|
||||
: "100%",
|
||||
transform:
|
||||
isBeingDragged && dragState.isDragging
|
||||
isBeingDragged && isDragging
|
||||
? `translate3d(0, ${dragOffsetY}px, 0)`
|
||||
: undefined,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -5,18 +5,12 @@ import { TimelineElement } from "./timeline-element";
|
|||
import type { TimelineTrack } from "@/timeline";
|
||||
import type { TimelineElement as TimelineElementType } from "@/timeline";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
||||
import type { ElementDragState } from "@/timeline";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { ElementDragView } from "@/timeline";
|
||||
|
||||
interface TimelineTrackContentProps {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
dragState: ElementDragState;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
lastMouseXRef: React.RefObject<number>;
|
||||
dragView: ElementDragView;
|
||||
onResizeStart: (params: {
|
||||
event: React.MouseEvent;
|
||||
element: TimelineElementType;
|
||||
|
|
@ -42,10 +36,7 @@ interface TimelineTrackContentProps {
|
|||
export function TimelineTrackContent({
|
||||
track,
|
||||
zoomLevel,
|
||||
dragState,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
lastMouseXRef,
|
||||
dragView,
|
||||
onResizeStart,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
|
|
@ -55,15 +46,6 @@ export function TimelineTrackContent({
|
|||
targetElementId = null,
|
||||
}: TimelineTrackContentProps) {
|
||||
const { isElementSelected } = useElementSelection();
|
||||
const duration = useEditor((e) => e.timeline.getTotalDuration());
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: dragState.isDragging,
|
||||
getMouseClientX: () => lastMouseXRef.current ?? 0,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
|
|
@ -120,7 +102,7 @@ export function TimelineTrackContent({
|
|||
onElementClick={(event, element) =>
|
||||
onElementClick({ event, element, track })
|
||||
}
|
||||
dragState={dragState}
|
||||
dragView={dragView}
|
||||
isDropTarget={element.id === targetElementId}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,577 @@
|
|||
import type { DragEvent } from "react";
|
||||
import { processMediaAssets } from "@/media/processing";
|
||||
import { showMediaUploadToast } from "@/media/upload-toast";
|
||||
import {
|
||||
DEFAULT_NEW_ELEMENT_DURATION,
|
||||
toElementDurationTicks,
|
||||
} from "@/timeline/creation";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { roundToFrame, type FrameRate } from "opencut-wasm";
|
||||
import {
|
||||
buildTextElement,
|
||||
buildGraphicElement,
|
||||
buildStickerElement,
|
||||
buildElementFromMedia,
|
||||
buildEffectElement,
|
||||
} from "@/timeline/element-utils";
|
||||
import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import type { Command } from "@/commands/base-command";
|
||||
import { computeDropTarget } from "@/timeline/components/drop-target";
|
||||
import type { TimelineDragSource } from "@/timeline/drag-source";
|
||||
import type {
|
||||
TrackType,
|
||||
DropTarget,
|
||||
ElementType,
|
||||
SceneTracks,
|
||||
TimelineTrack,
|
||||
CreateTimelineElement,
|
||||
} from "@/timeline";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import type { ProcessedMediaAsset } from "@/media/processing";
|
||||
|
||||
// --- Config ---
|
||||
|
||||
export interface DragDropConfig {
|
||||
zoomLevel: number;
|
||||
getContainerEl: () => HTMLDivElement | null;
|
||||
getHeaderEl: () => HTMLElement | null;
|
||||
getTracksScrollEl: () => HTMLDivElement | null;
|
||||
getActiveProjectFps: () => FrameRate | null;
|
||||
getActiveProjectId: () => string | null;
|
||||
getSceneTracks: () => SceneTracks;
|
||||
getCurrentPlayheadTime: () => number;
|
||||
getMediaAssets: () => MediaAsset[];
|
||||
dragSource: TimelineDragSource;
|
||||
addMediaAsset: (args: {
|
||||
projectId: string;
|
||||
asset: ProcessedMediaAsset;
|
||||
}) => Promise<MediaAsset | null>;
|
||||
executeCommand: (command: Command) => void;
|
||||
insertElement: (args: {
|
||||
placement: { mode: "explicit"; trackId: string };
|
||||
element: CreateTimelineElement;
|
||||
}) => void;
|
||||
addClipEffect: (args: {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectType: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface DragDropConfigRef {
|
||||
readonly current: DragDropConfig;
|
||||
}
|
||||
|
||||
// --- State ---
|
||||
|
||||
interface DragOverState {
|
||||
kind: "over";
|
||||
dropTarget: DropTarget | null;
|
||||
elementType: ElementType | null;
|
||||
}
|
||||
|
||||
type DragDropState = { kind: "idle" } | DragOverState;
|
||||
|
||||
interface TimelineCoords {
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
}
|
||||
|
||||
// --- Pure helpers ---
|
||||
|
||||
function elementTypeFromDrag({
|
||||
dragData,
|
||||
}: {
|
||||
dragData: TimelineDragData;
|
||||
}): ElementType {
|
||||
switch (dragData.type) {
|
||||
case "text":
|
||||
return "text";
|
||||
case "graphic":
|
||||
return "graphic";
|
||||
case "sticker":
|
||||
return "sticker";
|
||||
case "effect":
|
||||
return "effect";
|
||||
case "media":
|
||||
return dragData.mediaType;
|
||||
}
|
||||
}
|
||||
|
||||
function getTargetElementTypesForDrag({
|
||||
dragData,
|
||||
}: {
|
||||
dragData: TimelineDragData;
|
||||
}): string[] | undefined {
|
||||
if (dragData.type === "effect") return dragData.targetElementTypes;
|
||||
if (dragData.type === "media") return dragData.targetElementTypes;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getDurationForDrag({
|
||||
dragData,
|
||||
mediaAssets,
|
||||
}: {
|
||||
dragData: TimelineDragData;
|
||||
mediaAssets: MediaAsset[];
|
||||
}): number {
|
||||
if (dragData.type !== "media") return DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const media = mediaAssets.find((asset) => asset.id === dragData.id);
|
||||
return toElementDurationTicks({ seconds: media?.duration });
|
||||
}
|
||||
|
||||
function orderedTracks({
|
||||
sceneTracks,
|
||||
}: {
|
||||
sceneTracks: SceneTracks;
|
||||
}): TimelineTrack[] {
|
||||
return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio];
|
||||
}
|
||||
|
||||
// --- Controller ---
|
||||
|
||||
export class DragDropController {
|
||||
private state: DragDropState = { kind: "idle" };
|
||||
private enterCount = 0;
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
private readonly configRef: DragDropConfigRef;
|
||||
|
||||
constructor(deps: { configRef: DragDropConfigRef }) {
|
||||
this.configRef = deps.configRef;
|
||||
this.onDragEnter = this.onDragEnter.bind(this);
|
||||
this.onDragOver = this.onDragOver.bind(this);
|
||||
this.onDragLeave = this.onDragLeave.bind(this);
|
||||
this.onDrop = this.onDrop.bind(this);
|
||||
}
|
||||
|
||||
private get config(): DragDropConfig {
|
||||
return this.configRef.current;
|
||||
}
|
||||
|
||||
get isDragOver(): boolean {
|
||||
return this.state.kind !== "idle";
|
||||
}
|
||||
|
||||
get dropTarget(): DropTarget | null {
|
||||
return this.state.kind === "over" ? this.state.dropTarget : null;
|
||||
}
|
||||
|
||||
get dragElementType(): ElementType | null {
|
||||
return this.state.kind === "over" ? this.state.elementType : null;
|
||||
}
|
||||
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
// --- Drag event handlers (bound, stable, passed as React props) ---
|
||||
|
||||
onDragEnter(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
const hasAsset = this.config.dragSource.isActive();
|
||||
const hasFiles = event.dataTransfer.types.includes("Files");
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
|
||||
this.enterCount += 1;
|
||||
if (this.state.kind === "idle") {
|
||||
this.setOver({ dropTarget: null, elementType: null });
|
||||
}
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
|
||||
const coords = this.getMouseTimelineCoords({ event });
|
||||
if (!coords) return;
|
||||
|
||||
const dragData = this.config.dragSource.getActive();
|
||||
const hasFiles = event.dataTransfer.types.includes("Files");
|
||||
const isExternal = hasFiles && !dragData;
|
||||
|
||||
if (!dragData) {
|
||||
if (hasFiles && isExternal) {
|
||||
this.setOver({ dropTarget: null, elementType: null });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const elementType = elementTypeFromDrag({ dragData });
|
||||
const duration = getDurationForDrag({
|
||||
dragData,
|
||||
mediaAssets: this.config.getMediaAssets(),
|
||||
});
|
||||
const targetElementTypes = getTargetElementTypesForDrag({ dragData });
|
||||
|
||||
const sceneTracks = this.config.getSceneTracks();
|
||||
const target = computeDropTarget({
|
||||
elementType,
|
||||
mouseX: coords.mouseX,
|
||||
mouseY: coords.mouseY,
|
||||
tracks: sceneTracks,
|
||||
playheadTime: this.config.getCurrentPlayheadTime(),
|
||||
isExternalDrop: isExternal,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
zoomLevel: this.config.zoomLevel,
|
||||
targetElementTypes,
|
||||
});
|
||||
|
||||
const fps = this.config.getActiveProjectFps();
|
||||
target.xPosition = fps
|
||||
? (roundToFrame({ time: target.xPosition, rate: fps }) ??
|
||||
target.xPosition)
|
||||
: target.xPosition;
|
||||
|
||||
this.setOver({ dropTarget: target, elementType });
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
|
||||
onDragLeave(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
if (this.enterCount === 0) return;
|
||||
this.enterCount -= 1;
|
||||
if (this.enterCount === 0) {
|
||||
this.setIdle();
|
||||
}
|
||||
}
|
||||
|
||||
onDrop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
this.enterCount = 0;
|
||||
|
||||
const dragData = this.config.dragSource.getActive();
|
||||
const hasFiles = event.dataTransfer.files?.length > 0;
|
||||
if (!dragData && !hasFiles) return;
|
||||
|
||||
const currentTarget = this.dropTarget;
|
||||
this.setIdle();
|
||||
|
||||
try {
|
||||
if (dragData) {
|
||||
if (!currentTarget) return;
|
||||
this.executeAssetDrop({ target: currentTarget, dragData });
|
||||
return;
|
||||
}
|
||||
|
||||
const coords = this.getMouseTimelineCoords({ event });
|
||||
if (!coords) return;
|
||||
this.executeFileDrop({
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
mouseX: coords.mouseX,
|
||||
mouseY: coords.mouseY,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to process file drop:", error);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to process drop:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
private setOver(state: {
|
||||
dropTarget: DropTarget | null;
|
||||
elementType: ElementType | null;
|
||||
}): void {
|
||||
this.state = { kind: "over", ...state };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private setIdle(): void {
|
||||
this.state = { kind: "idle" };
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.subscribers) fn();
|
||||
}
|
||||
|
||||
private getMouseTimelineCoords({
|
||||
event,
|
||||
}: {
|
||||
event: DragEvent;
|
||||
}): TimelineCoords | null {
|
||||
const scrollContainer = this.config.getTracksScrollEl();
|
||||
const referenceRect =
|
||||
scrollContainer?.getBoundingClientRect() ??
|
||||
this.config.getContainerEl()?.getBoundingClientRect();
|
||||
if (!referenceRect) return null;
|
||||
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
const headerHeight =
|
||||
this.config.getHeaderEl()?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
return {
|
||||
mouseX: event.clientX - referenceRect.left + scrollLeft,
|
||||
mouseY: event.clientY - referenceRect.top + scrollTop - headerHeight,
|
||||
};
|
||||
}
|
||||
|
||||
// Shared insertion logic — new track vs existing track.
|
||||
private insertAtTarget({
|
||||
element,
|
||||
target,
|
||||
trackType,
|
||||
}: {
|
||||
element: CreateTimelineElement;
|
||||
target: DropTarget;
|
||||
trackType: TrackType;
|
||||
}): void {
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
|
||||
this.config.executeCommand(
|
||||
new BatchCommand([
|
||||
addTrackCmd,
|
||||
new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
}),
|
||||
]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() });
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
this.config.insertElement({
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
}
|
||||
|
||||
private executeAssetDrop({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: TimelineDragData;
|
||||
}): void {
|
||||
switch (dragData.type) {
|
||||
case "text":
|
||||
this.executeTextDrop({ target, dragData });
|
||||
return;
|
||||
case "graphic":
|
||||
this.executeGraphicDrop({ target, dragData });
|
||||
return;
|
||||
case "sticker":
|
||||
this.executeStickerDrop({ target, dragData });
|
||||
return;
|
||||
case "effect":
|
||||
this.executeEffectDrop({ target, dragData });
|
||||
return;
|
||||
case "media":
|
||||
this.executeMediaDrop({ target, dragData });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private executeTextDrop({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: Extract<TimelineDragData, { type: "text" }>;
|
||||
}): void {
|
||||
const element = buildTextElement({
|
||||
raw: { name: dragData.name ?? "", content: dragData.content ?? "" },
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
this.insertAtTarget({ element, target, trackType: "text" });
|
||||
}
|
||||
|
||||
private executeStickerDrop({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: Extract<TimelineDragData, { type: "sticker" }>;
|
||||
}): void {
|
||||
const element = buildStickerElement({
|
||||
stickerId: dragData.stickerId,
|
||||
name: dragData.name,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
this.insertAtTarget({ element, target, trackType: "graphic" });
|
||||
}
|
||||
|
||||
private executeGraphicDrop({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: Extract<TimelineDragData, { type: "graphic" }>;
|
||||
}): void {
|
||||
const element = buildGraphicElement({
|
||||
definitionId: dragData.definitionId,
|
||||
name: dragData.name,
|
||||
startTime: target.xPosition,
|
||||
params: dragData.params,
|
||||
});
|
||||
this.insertAtTarget({ element, target, trackType: "graphic" });
|
||||
}
|
||||
|
||||
private executeMediaDrop({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: Extract<TimelineDragData, { type: "media" }>;
|
||||
}): void {
|
||||
if (target.targetElement) {
|
||||
// Replace media source — not yet implemented
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAsset = this.config
|
||||
.getMediaAssets()
|
||||
.find((asset) => asset.id === dragData.id);
|
||||
if (!mediaAsset) return;
|
||||
|
||||
const trackType: TrackType =
|
||||
dragData.mediaType === "audio" ? "audio" : "video";
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: mediaAsset.id,
|
||||
mediaType: mediaAsset.type,
|
||||
name: mediaAsset.name,
|
||||
duration: toElementDurationTicks({ seconds: mediaAsset.duration }),
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
this.insertAtTarget({ element, target, trackType });
|
||||
}
|
||||
|
||||
private executeEffectDrop({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: Extract<TimelineDragData, { type: "effect" }>;
|
||||
}): void {
|
||||
if (target.targetElement) {
|
||||
this.config.addClipEffect({
|
||||
trackId: target.targetElement.trackId,
|
||||
elementId: target.targetElement.elementId,
|
||||
effectType: dragData.effectType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const element = buildEffectElement({
|
||||
effectType: dragData.effectType,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
const existingEffectTrack = orderedTracks({
|
||||
sceneTracks: this.config.getSceneTracks(),
|
||||
}).find((track) => track.type === "effect");
|
||||
|
||||
if (existingEffectTrack) {
|
||||
this.config.insertElement({
|
||||
placement: { mode: "explicit", trackId: existingEffectTrack.id },
|
||||
element,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.insertAtTarget({ element, target, trackType: "effect" });
|
||||
}
|
||||
|
||||
private async executeFileDrop({
|
||||
files,
|
||||
mouseX,
|
||||
mouseY,
|
||||
}: {
|
||||
files: File[];
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
}): Promise<void> {
|
||||
const projectId = this.config.getActiveProjectId();
|
||||
if (!projectId) return;
|
||||
|
||||
await showMediaUploadToast({
|
||||
filesCount: files.length,
|
||||
promise: async () => {
|
||||
const processedAssets = await processMediaAssets({ files });
|
||||
|
||||
// Sequential on purpose: each iteration reads getSceneTracks()
|
||||
// to decide placement (reuse empty main vs new track) and that
|
||||
// decision depends on the effects of prior inserts.
|
||||
for (const asset of processedAssets) {
|
||||
const createdAsset = await this.config.addMediaAsset({
|
||||
projectId,
|
||||
asset,
|
||||
});
|
||||
if (!createdAsset) continue;
|
||||
|
||||
const duration = toElementDurationTicks({
|
||||
seconds: createdAsset.duration,
|
||||
});
|
||||
|
||||
const sceneTracks = this.config.getSceneTracks();
|
||||
const currentTime = this.config.getCurrentPlayheadTime();
|
||||
|
||||
const reuseMainTrackId =
|
||||
createdAsset.type !== "audio" &&
|
||||
sceneTracks.overlay.length === 0 &&
|
||||
sceneTracks.audio.length === 0 &&
|
||||
sceneTracks.main.elements.length === 0
|
||||
? sceneTracks.main.id
|
||||
: null;
|
||||
|
||||
if (reuseMainTrackId) {
|
||||
this.config.insertElement({
|
||||
placement: { mode: "explicit", trackId: reuseMainTrackId },
|
||||
element: buildElementFromMedia({
|
||||
mediaId: createdAsset.id,
|
||||
mediaType: createdAsset.type,
|
||||
name: createdAsset.name,
|
||||
duration,
|
||||
startTime: currentTime,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const dropTarget = computeDropTarget({
|
||||
elementType: createdAsset.type,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks: sceneTracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: true,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
zoomLevel: this.config.zoomLevel,
|
||||
});
|
||||
|
||||
const trackType: TrackType =
|
||||
createdAsset.type === "audio" ? "audio" : "video";
|
||||
this.insertAtTarget({
|
||||
element: buildElementFromMedia({
|
||||
mediaId: createdAsset.id,
|
||||
mediaType: createdAsset.type,
|
||||
name: createdAsset.name,
|
||||
duration,
|
||||
startTime: dropTarget.xPosition,
|
||||
}),
|
||||
target: dropTarget,
|
||||
trackType,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
uploadedCount: processedAssets.length,
|
||||
assetNames: processedAssets.map((asset) => asset.name),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,691 @@
|
|||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import {
|
||||
buildMoveGroup,
|
||||
resolveGroupMove,
|
||||
snapGroupEdges,
|
||||
type GroupMoveResult,
|
||||
type MoveGroup,
|
||||
} from "@/timeline/group-move";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||
import { roundToFrame, type FrameRate } from "opencut-wasm";
|
||||
import { computeDropTarget } from "@/timeline/components/drop-target";
|
||||
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import type {
|
||||
DropTarget,
|
||||
ElementRef,
|
||||
ElementDragView,
|
||||
SceneTracks,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
} from "@/timeline";
|
||||
|
||||
const MOUSE_BUTTON_RIGHT = 2;
|
||||
|
||||
// --- Config ---
|
||||
|
||||
export interface ViewportAdapter {
|
||||
getZoomLevel: () => number;
|
||||
getTracksScrollEl: () => HTMLDivElement | null;
|
||||
getTracksContainerEl: () => HTMLDivElement | null;
|
||||
getHeaderEl: () => HTMLElement | null;
|
||||
}
|
||||
|
||||
export interface InputAdapter {
|
||||
isShiftHeld: () => boolean;
|
||||
}
|
||||
|
||||
export interface SceneReader {
|
||||
getTracks: () => SceneTracks;
|
||||
getActiveFps: () => FrameRate | null;
|
||||
}
|
||||
|
||||
export interface ElementSelectionApi {
|
||||
getSelected: () => readonly ElementRef[];
|
||||
isSelected: (ref: ElementRef) => boolean;
|
||||
select: (ref: ElementRef) => void;
|
||||
handleClick: (args: ElementRef & { isMultiKey: boolean }) => void;
|
||||
clearKeyframeSelection: () => void;
|
||||
}
|
||||
|
||||
export interface PlaybackReader {
|
||||
getCurrentTime: () => number;
|
||||
}
|
||||
|
||||
export interface TimelineOps {
|
||||
moveElements: (args: Pick<GroupMoveResult, "moves" | "createTracks">) => void;
|
||||
}
|
||||
|
||||
export interface SnapConfig {
|
||||
isEnabled: () => boolean;
|
||||
onChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
export interface ElementInteractionDeps {
|
||||
viewport: ViewportAdapter;
|
||||
input: InputAdapter;
|
||||
scene: SceneReader;
|
||||
selection: ElementSelectionApi;
|
||||
playback: PlaybackReader;
|
||||
timeline: TimelineOps;
|
||||
snap: SnapConfig;
|
||||
}
|
||||
|
||||
export interface ElementInteractionDepsRef {
|
||||
readonly current: ElementInteractionDeps;
|
||||
}
|
||||
|
||||
// --- Session ---
|
||||
|
||||
type Point = { readonly x: number; readonly y: number };
|
||||
|
||||
interface MousedownSnapshot {
|
||||
readonly origin: Point;
|
||||
readonly elementId: string;
|
||||
readonly trackId: string;
|
||||
readonly startElementTime: number;
|
||||
readonly clickOffsetTime: number;
|
||||
readonly selectedElements: readonly ElementRef[];
|
||||
}
|
||||
|
||||
interface DragProgress {
|
||||
moveGroup: MoveGroup;
|
||||
// Pre-minted per member so the identity of any "new track" created by
|
||||
// this drag stays stable across mousemove-driven drop-target recomputes.
|
||||
// `resolveGroupMoveForDrop` runs every mousemove and emits a
|
||||
// `createTracks[]` carrying these IDs; downstream consumers (snap
|
||||
// indicator, drop-line, commit path) see the same entity every frame
|
||||
// instead of a churning UUID.
|
||||
reservedNewTrackIds: readonly string[];
|
||||
currentTime: number;
|
||||
currentMouseX: number;
|
||||
currentMouseY: number;
|
||||
groupMoveResult: GroupMoveResult | null;
|
||||
dropTarget: DropTarget | null;
|
||||
}
|
||||
|
||||
type Session =
|
||||
| { kind: "idle" }
|
||||
| { kind: "pending"; mousedown: MousedownSnapshot }
|
||||
| { kind: "dragging"; mousedown: MousedownSnapshot; drag: DragProgress };
|
||||
|
||||
const IDLE_VIEW: ElementDragView = { kind: "idle" };
|
||||
|
||||
// --- Pure helpers ---
|
||||
|
||||
function pixelToClickOffsetTime(
|
||||
clientX: number,
|
||||
elementRect: DOMRect,
|
||||
zoomLevel: number,
|
||||
): number {
|
||||
const clickOffsetX = clientX - elementRect.left;
|
||||
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
||||
return Math.round(seconds * TICKS_PER_SECOND);
|
||||
}
|
||||
|
||||
function verticalDirection(
|
||||
startMouseY: number,
|
||||
currentMouseY: number,
|
||||
): "up" | "down" | null {
|
||||
if (currentMouseY < startMouseY) return "up";
|
||||
if (currentMouseY > startMouseY) return "down";
|
||||
return null;
|
||||
}
|
||||
|
||||
function orderedTracks(sceneTracks: SceneTracks): TimelineTrack[] {
|
||||
return [...sceneTracks.overlay, sceneTracks.main, ...sceneTracks.audio];
|
||||
}
|
||||
|
||||
function movedPastDragThreshold(current: Point, origin: Point): boolean {
|
||||
return (
|
||||
Math.abs(current.x - origin.x) > TIMELINE_DRAG_THRESHOLD_PX ||
|
||||
Math.abs(current.y - origin.y) > TIMELINE_DRAG_THRESHOLD_PX
|
||||
);
|
||||
}
|
||||
|
||||
function frameSnappedMouseTime({
|
||||
clientX,
|
||||
scrollContainer,
|
||||
zoomLevel,
|
||||
clickOffsetTime,
|
||||
fps,
|
||||
}: {
|
||||
clientX: number;
|
||||
scrollContainer: HTMLDivElement;
|
||||
zoomLevel: number;
|
||||
clickOffsetTime: number;
|
||||
fps: FrameRate;
|
||||
}): number {
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft: scrollContainer.scrollLeft,
|
||||
});
|
||||
const adjusted = Math.max(0, mouseTime - clickOffsetTime);
|
||||
return roundToFrame({ time: adjusted, rate: fps }) ?? adjusted;
|
||||
}
|
||||
|
||||
function resolveDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
elementId,
|
||||
trackId,
|
||||
tracks,
|
||||
viewport,
|
||||
zoomLevel,
|
||||
snappedTime,
|
||||
verticalDragDirection,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
tracks: SceneTracks;
|
||||
viewport: ViewportAdapter;
|
||||
zoomLevel: number;
|
||||
snappedTime: number;
|
||||
verticalDragDirection: "up" | "down" | null;
|
||||
}): DropTarget | null {
|
||||
const containerRect = viewport
|
||||
.getTracksContainerEl()
|
||||
?.getBoundingClientRect();
|
||||
const scrollContainer = viewport.getTracksScrollEl();
|
||||
if (!containerRect || !scrollContainer) return null;
|
||||
|
||||
const sourceTrack = orderedTracks(tracks).find(({ id }) => id === trackId);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
({ id }) => id === elementId,
|
||||
);
|
||||
if (!movingElement) return null;
|
||||
|
||||
const scrollRect = scrollContainer.getBoundingClientRect();
|
||||
const headerHeight =
|
||||
viewport.getHeaderEl()?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
return computeDropTarget({
|
||||
elementType: movingElement.type,
|
||||
mouseX: clientX - scrollRect.left + scrollContainer.scrollLeft,
|
||||
mouseY: clientY - scrollRect.top + scrollContainer.scrollTop - headerHeight,
|
||||
tracks,
|
||||
playheadTime: snappedTime,
|
||||
isExternalDrop: false,
|
||||
elementDuration: movingElement.duration,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
startTimeOverride: snappedTime,
|
||||
excludeElementId: movingElement.id,
|
||||
verticalDragDirection,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveGroupMoveForDrop({
|
||||
group,
|
||||
tracks,
|
||||
anchorStartTime,
|
||||
dropTarget,
|
||||
reservedNewTrackIds,
|
||||
}: {
|
||||
group: MoveGroup;
|
||||
tracks: SceneTracks;
|
||||
anchorStartTime: number;
|
||||
dropTarget: DropTarget;
|
||||
reservedNewTrackIds: readonly string[];
|
||||
}): GroupMoveResult | null {
|
||||
const newTracksFallback = () =>
|
||||
resolveGroupMove({
|
||||
group,
|
||||
tracks,
|
||||
anchorStartTime,
|
||||
target: {
|
||||
kind: "newTracks",
|
||||
anchorInsertIndex: dropTarget.trackIndex,
|
||||
newTrackIds: [...reservedNewTrackIds],
|
||||
},
|
||||
});
|
||||
|
||||
if (dropTarget.isNewTrack) return newTracksFallback();
|
||||
|
||||
const targetTrack = orderedTracks(tracks)[dropTarget.trackIndex];
|
||||
if (!targetTrack) return null;
|
||||
|
||||
return (
|
||||
resolveGroupMove({
|
||||
group,
|
||||
tracks,
|
||||
anchorStartTime,
|
||||
target: { kind: "existingTrack", anchorTargetTrackId: targetTrack.id },
|
||||
}) ?? newTracksFallback()
|
||||
);
|
||||
}
|
||||
|
||||
// --- Controller ---
|
||||
|
||||
export class ElementInteractionController {
|
||||
private session: Session = { kind: "idle" };
|
||||
// True once the active gesture crossed the drag threshold. Read by
|
||||
// onElementClick, which fires after mouseup — by which point the session
|
||||
// has already returned to idle, so the "was this a drag?" answer must
|
||||
// outlive the session. Reset on the next mousedown.
|
||||
private lastGestureWasDrag = false;
|
||||
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
private readonly depsRef: ElementInteractionDepsRef;
|
||||
|
||||
constructor(args: { depsRef: ElementInteractionDepsRef }) {
|
||||
this.depsRef = args.depsRef;
|
||||
}
|
||||
|
||||
private get deps(): ElementInteractionDeps {
|
||||
return this.depsRef.current;
|
||||
}
|
||||
|
||||
get view(): ElementDragView {
|
||||
if (this.session.kind !== "dragging") return IDLE_VIEW;
|
||||
const { mousedown, drag } = this.session;
|
||||
const memberTimeOffsets = new Map<string, number>();
|
||||
for (const member of drag.moveGroup.members) {
|
||||
memberTimeOffsets.set(member.elementId, member.timeOffset);
|
||||
}
|
||||
return {
|
||||
kind: "dragging",
|
||||
anchorElementId: mousedown.elementId,
|
||||
trackId: mousedown.trackId,
|
||||
memberTimeOffsets,
|
||||
startMouseX: mousedown.origin.x,
|
||||
startMouseY: mousedown.origin.y,
|
||||
startElementTime: mousedown.startElementTime,
|
||||
clickOffsetTime: mousedown.clickOffsetTime,
|
||||
currentTime: drag.currentTime,
|
||||
currentMouseX: drag.currentMouseX,
|
||||
currentMouseY: drag.currentMouseY,
|
||||
dropTarget: drag.dropTarget,
|
||||
};
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.session.kind !== "idle";
|
||||
}
|
||||
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
cancel = (): void => {
|
||||
this.lastGestureWasDrag = false;
|
||||
this.finishSession();
|
||||
};
|
||||
|
||||
destroy(): void {
|
||||
this.cancel();
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
onElementMouseDown = ({
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}): void => {
|
||||
// Right-click must not stopPropagation — ContextMenu needs the bubble.
|
||||
if (event.button === MOUSE_BUTTON_RIGHT) {
|
||||
const ref = { trackId: track.id, elementId: element.id };
|
||||
if (!this.deps.selection.isSelected(ref)) {
|
||||
this.deps.selection.handleClick({ ...ref, isMultiKey: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
this.lastGestureWasDrag = false;
|
||||
|
||||
const ref = { trackId: track.id, elementId: element.id };
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) {
|
||||
this.deps.selection.handleClick({ ...ref, isMultiKey: true });
|
||||
}
|
||||
|
||||
const selectedElements = this.deps.selection.isSelected(ref)
|
||||
? this.deps.selection.getSelected()
|
||||
: [ref];
|
||||
|
||||
this.session = {
|
||||
kind: "pending",
|
||||
mousedown: {
|
||||
origin: { x: event.clientX, y: event.clientY },
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
startElementTime: element.startTime,
|
||||
clickOffsetTime: pixelToClickOffsetTime(
|
||||
event.clientX,
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
this.deps.viewport.getZoomLevel(),
|
||||
),
|
||||
selectedElements,
|
||||
},
|
||||
};
|
||||
this.activate();
|
||||
this.notify();
|
||||
};
|
||||
|
||||
onElementClick = ({
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}): void => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (this.lastGestureWasDrag) {
|
||||
this.lastGestureWasDrag = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
const ref = { trackId: track.id, elementId: element.id };
|
||||
if (
|
||||
!this.deps.selection.isSelected(ref) ||
|
||||
this.deps.selection.getSelected().length > 1
|
||||
) {
|
||||
this.deps.selection.select(ref);
|
||||
return;
|
||||
}
|
||||
|
||||
this.deps.selection.clearKeyframeSelection();
|
||||
};
|
||||
|
||||
private activate(): void {
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
private deactivate(): void {
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.subscribers) fn();
|
||||
}
|
||||
|
||||
private finishSession(): void {
|
||||
this.session = { kind: "idle" };
|
||||
this.deactivate();
|
||||
this.deps.snap.onChange?.(null);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private snapResult(
|
||||
frameSnappedTime: number,
|
||||
group: MoveGroup,
|
||||
): { snappedTime: number; snapPoint: SnapPoint | null } {
|
||||
const { snap, input, scene, viewport, playback } = this.deps;
|
||||
|
||||
if (!snap.isEnabled() || input.isShiftHeld()) {
|
||||
return { snappedTime: frameSnappedTime, snapPoint: null };
|
||||
}
|
||||
|
||||
const result = snapGroupEdges({
|
||||
group,
|
||||
anchorStartTime: frameSnappedTime,
|
||||
tracks: scene.getTracks(),
|
||||
playheadTime: playback.getCurrentTime(),
|
||||
zoomLevel: viewport.getZoomLevel(),
|
||||
});
|
||||
|
||||
return {
|
||||
snappedTime: result.snappedAnchorStartTime,
|
||||
snapPoint: result.snapPoint,
|
||||
};
|
||||
}
|
||||
|
||||
private updateDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
mousedown,
|
||||
drag,
|
||||
snappedTime,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
mousedown: MousedownSnapshot;
|
||||
drag: DragProgress;
|
||||
snappedTime: number;
|
||||
}): void {
|
||||
const { scene, viewport } = this.deps;
|
||||
const tracks = scene.getTracks();
|
||||
const zoomLevel = viewport.getZoomLevel();
|
||||
|
||||
const anchorDropTarget = resolveDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
elementId: mousedown.elementId,
|
||||
trackId: mousedown.trackId,
|
||||
tracks,
|
||||
viewport,
|
||||
zoomLevel,
|
||||
snappedTime,
|
||||
verticalDragDirection: verticalDirection(mousedown.origin.y, clientY),
|
||||
});
|
||||
|
||||
const nextGroupMoveResult = anchorDropTarget
|
||||
? resolveGroupMoveForDrop({
|
||||
group: drag.moveGroup,
|
||||
tracks,
|
||||
anchorStartTime: snappedTime,
|
||||
dropTarget: anchorDropTarget,
|
||||
reservedNewTrackIds: drag.reservedNewTrackIds,
|
||||
})
|
||||
: null;
|
||||
|
||||
drag.groupMoveResult = nextGroupMoveResult;
|
||||
drag.dropTarget =
|
||||
anchorDropTarget && (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
|
||||
? { ...anchorDropTarget, isNewTrack: true }
|
||||
: null;
|
||||
}
|
||||
|
||||
private handleMouseMove = ({ clientX, clientY }: MouseEvent): void => {
|
||||
const scrollContainer = this.deps.viewport.getTracksScrollEl();
|
||||
if (!scrollContainer) return;
|
||||
|
||||
if (this.session.kind === "pending") {
|
||||
this.beginDragFromPending({
|
||||
mousedown: this.session.mousedown,
|
||||
clientX,
|
||||
clientY,
|
||||
scrollContainer,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.session.kind === "dragging") {
|
||||
this.updateActiveDrag({
|
||||
mousedown: this.session.mousedown,
|
||||
drag: this.session.drag,
|
||||
clientX,
|
||||
clientY,
|
||||
scrollContainer,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private beginDragFromPending({
|
||||
mousedown,
|
||||
clientX,
|
||||
clientY,
|
||||
scrollContainer,
|
||||
}: {
|
||||
mousedown: MousedownSnapshot;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
scrollContainer: HTMLDivElement;
|
||||
}): void {
|
||||
if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fps = this.deps.scene.getActiveFps();
|
||||
if (!fps) return;
|
||||
|
||||
const moveGroup = buildMoveGroup({
|
||||
anchorRef: {
|
||||
trackId: mousedown.trackId,
|
||||
elementId: mousedown.elementId,
|
||||
},
|
||||
selectedElements: [...mousedown.selectedElements],
|
||||
tracks: this.deps.scene.getTracks(),
|
||||
});
|
||||
if (!moveGroup) return;
|
||||
|
||||
const zoomLevel = this.deps.viewport.getZoomLevel();
|
||||
const frameSnappedTime = frameSnappedMouseTime({
|
||||
clientX,
|
||||
scrollContainer,
|
||||
zoomLevel,
|
||||
clickOffsetTime: mousedown.clickOffsetTime,
|
||||
fps,
|
||||
});
|
||||
const { snappedTime, snapPoint } = this.snapResult(
|
||||
frameSnappedTime,
|
||||
moveGroup,
|
||||
);
|
||||
|
||||
// Ensure the anchor is selected before we render the drag — covers the
|
||||
// case where the selection store hasn't committed the mousedown-time
|
||||
// selection click yet.
|
||||
const anchorRef = {
|
||||
trackId: mousedown.trackId,
|
||||
elementId: mousedown.elementId,
|
||||
};
|
||||
if (!this.deps.selection.isSelected(anchorRef)) {
|
||||
this.deps.selection.select(anchorRef);
|
||||
}
|
||||
|
||||
const drag: DragProgress = {
|
||||
moveGroup,
|
||||
reservedNewTrackIds: moveGroup.members.map(() => generateUUID()),
|
||||
currentTime: snappedTime,
|
||||
currentMouseX: clientX,
|
||||
currentMouseY: clientY,
|
||||
groupMoveResult: null,
|
||||
dropTarget: null,
|
||||
};
|
||||
|
||||
this.session = { kind: "dragging", mousedown, drag };
|
||||
this.lastGestureWasDrag = true;
|
||||
|
||||
this.updateDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
mousedown,
|
||||
drag,
|
||||
snappedTime,
|
||||
});
|
||||
|
||||
this.deps.snap.onChange?.(snapPoint);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private updateActiveDrag({
|
||||
mousedown,
|
||||
drag,
|
||||
clientX,
|
||||
clientY,
|
||||
scrollContainer,
|
||||
}: {
|
||||
mousedown: MousedownSnapshot;
|
||||
drag: DragProgress;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
scrollContainer: HTMLDivElement;
|
||||
}): void {
|
||||
const fps = this.deps.scene.getActiveFps();
|
||||
if (!fps) return;
|
||||
|
||||
const frameSnappedTime = frameSnappedMouseTime({
|
||||
clientX,
|
||||
scrollContainer,
|
||||
zoomLevel: this.deps.viewport.getZoomLevel(),
|
||||
clickOffsetTime: mousedown.clickOffsetTime,
|
||||
fps,
|
||||
});
|
||||
const { snappedTime, snapPoint } = this.snapResult(
|
||||
frameSnappedTime,
|
||||
drag.moveGroup,
|
||||
);
|
||||
|
||||
drag.currentTime = snappedTime;
|
||||
drag.currentMouseX = clientX;
|
||||
drag.currentMouseY = clientY;
|
||||
|
||||
this.updateDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
mousedown,
|
||||
drag,
|
||||
snappedTime,
|
||||
});
|
||||
|
||||
this.deps.snap.onChange?.(snapPoint);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private handleMouseUp = ({ clientX, clientY }: MouseEvent): void => {
|
||||
if (this.session.kind === "pending") {
|
||||
this.finishSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.session.kind !== "dragging") return;
|
||||
|
||||
const { mousedown, drag } = this.session;
|
||||
|
||||
// If the drag returned within the click threshold of its origin, treat
|
||||
// this as a cancel rather than a commit — the user dragged then put the
|
||||
// element back.
|
||||
if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
|
||||
this.lastGestureWasDrag = false;
|
||||
this.finishSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const { moveGroup, groupMoveResult } = drag;
|
||||
if (!groupMoveResult) {
|
||||
this.finishSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const didMove = groupMoveResult.moves.some((move) => {
|
||||
const member = moveGroup.members.find(
|
||||
(m) => m.elementId === move.elementId,
|
||||
);
|
||||
const originalStartTime =
|
||||
mousedown.startElementTime + (member?.timeOffset ?? 0);
|
||||
return (
|
||||
member?.trackId !== move.targetTrackId ||
|
||||
originalStartTime !== move.newStartTime
|
||||
);
|
||||
});
|
||||
|
||||
if (didMove || groupMoveResult.createTracks.length > 0) {
|
||||
this.deps.timeline.moveElements({
|
||||
moves: groupMoveResult.moves,
|
||||
createTracks: groupMoveResult.createTracks,
|
||||
});
|
||||
}
|
||||
|
||||
this.finishSession();
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,342 @@
|
|||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { roundToFrame, snappedSeekTime, type FrameRate } from "opencut-wasm";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||
import { timelineTimeToSnappedPixels } from "@/timeline";
|
||||
import { getKeyframeById } from "@/animation";
|
||||
import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import type { SelectedKeyframeRef } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import type { Command } from "@/commands/base-command";
|
||||
|
||||
// --- Session ---
|
||||
|
||||
interface PendingSession {
|
||||
kind: "pending";
|
||||
keyframeRefs: SelectedKeyframeRef[];
|
||||
startMouseX: number;
|
||||
}
|
||||
|
||||
interface ActiveSession {
|
||||
kind: "active";
|
||||
keyframeRefs: SelectedKeyframeRef[];
|
||||
startMouseX: number;
|
||||
deltaTicks: number;
|
||||
}
|
||||
|
||||
type Session = { kind: "idle" } | PendingSession | ActiveSession;
|
||||
|
||||
// --- Public state ---
|
||||
|
||||
export interface KeyframeDragState {
|
||||
isDragging: boolean;
|
||||
draggingKeyframeIds: Set<string>;
|
||||
deltaTicks: number;
|
||||
}
|
||||
|
||||
const IDLE_DRAG_STATE: KeyframeDragState = {
|
||||
isDragging: false,
|
||||
draggingKeyframeIds: new Set(),
|
||||
deltaTicks: 0,
|
||||
};
|
||||
|
||||
// --- Config ---
|
||||
|
||||
export interface KeyframeDragConfig {
|
||||
zoomLevel: number;
|
||||
getFps: () => FrameRate | null;
|
||||
element: TimelineElement;
|
||||
displayedStartTime: number;
|
||||
selectedKeyframes: SelectedKeyframeRef[];
|
||||
isKeyframeSelected: (args: { keyframe: SelectedKeyframeRef }) => boolean;
|
||||
setKeyframeSelection: (args: { keyframes: SelectedKeyframeRef[] }) => void;
|
||||
toggleKeyframeSelection: (args: {
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
isMultiKey: boolean;
|
||||
}) => void;
|
||||
selectKeyframeRange: (args: {
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
targetKeyframes: SelectedKeyframeRef[];
|
||||
isAdditive: boolean;
|
||||
}) => void;
|
||||
executeCommand: (command: Command) => void;
|
||||
seek: (args: { time: number }) => void;
|
||||
getTotalDuration: () => number;
|
||||
}
|
||||
|
||||
export interface KeyframeDragConfigRef {
|
||||
readonly current: KeyframeDragConfig;
|
||||
}
|
||||
|
||||
// --- Controller ---
|
||||
|
||||
export class KeyframeDragController {
|
||||
private session: Session = { kind: "idle" };
|
||||
// Persists through mouseup so the click handler can detect drag vs click
|
||||
private mouseDownX: number | null = null;
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
private readonly configRef: KeyframeDragConfigRef;
|
||||
|
||||
constructor(deps: { configRef: KeyframeDragConfigRef }) {
|
||||
this.configRef = deps.configRef;
|
||||
this.onKeyframeMouseDown = this.onKeyframeMouseDown.bind(this);
|
||||
this.onKeyframeClick = this.onKeyframeClick.bind(this);
|
||||
this.getVisualOffsetPx = this.getVisualOffsetPx.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
}
|
||||
|
||||
private get config(): KeyframeDragConfig {
|
||||
return this.configRef.current;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.session.kind !== "idle";
|
||||
}
|
||||
|
||||
get keyframeDragState(): KeyframeDragState {
|
||||
if (this.session.kind !== "active") return IDLE_DRAG_STATE;
|
||||
return {
|
||||
isDragging: true,
|
||||
draggingKeyframeIds: new Set(
|
||||
this.session.keyframeRefs.map((kf) => kf.keyframeId),
|
||||
),
|
||||
deltaTicks: this.session.deltaTicks,
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.mouseDownX = null;
|
||||
this.finishSession();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.deactivate();
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
onKeyframeMouseDown({
|
||||
event,
|
||||
keyframes,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.mouseDownX = event.clientX;
|
||||
|
||||
const anySelected = keyframes.some((kf) =>
|
||||
this.config.isKeyframeSelected({ keyframe: kf }),
|
||||
);
|
||||
const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
|
||||
|
||||
if (!anySelected && !isModifierKey) {
|
||||
this.config.setKeyframeSelection({ keyframes });
|
||||
}
|
||||
|
||||
this.session = {
|
||||
kind: "pending",
|
||||
keyframeRefs: anySelected ? this.config.selectedKeyframes : keyframes,
|
||||
startMouseX: event.clientX,
|
||||
};
|
||||
this.activate();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onKeyframeClick({
|
||||
event,
|
||||
keyframes,
|
||||
orderedKeyframes,
|
||||
indicatorTime,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
indicatorTime: number;
|
||||
}): void {
|
||||
event.stopPropagation();
|
||||
|
||||
const wasDrag =
|
||||
this.mouseDownX !== null &&
|
||||
Math.abs(event.clientX - this.mouseDownX) > TIMELINE_DRAG_THRESHOLD_PX;
|
||||
this.mouseDownX = null;
|
||||
|
||||
if (wasDrag) return;
|
||||
|
||||
const { displayedStartTime, getFps, getTotalDuration, seek } = this.config;
|
||||
const fps = getFps();
|
||||
const seekTime =
|
||||
fps != null
|
||||
? (snappedSeekTime({
|
||||
time: displayedStartTime + indicatorTime,
|
||||
duration: getTotalDuration(),
|
||||
rate: fps,
|
||||
}) ?? displayedStartTime + indicatorTime)
|
||||
: displayedStartTime + indicatorTime;
|
||||
seek({ time: seekTime });
|
||||
|
||||
if (event.shiftKey) {
|
||||
this.config.selectKeyframeRange({
|
||||
orderedKeyframes,
|
||||
targetKeyframes: keyframes,
|
||||
isAdditive: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.toggleKeyframeSelection({
|
||||
keyframes,
|
||||
isMultiKey: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
}
|
||||
|
||||
getVisualOffsetPx({
|
||||
indicatorTime,
|
||||
indicatorOffsetPx,
|
||||
isBeingDragged,
|
||||
displayedStartTime,
|
||||
elementLeft,
|
||||
}: {
|
||||
indicatorTime: number;
|
||||
indicatorOffsetPx: number;
|
||||
isBeingDragged: boolean;
|
||||
displayedStartTime: number;
|
||||
elementLeft: number;
|
||||
}): number {
|
||||
if (!isBeingDragged || this.session.kind !== "active")
|
||||
return indicatorOffsetPx;
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.config.element.duration,
|
||||
indicatorTime + this.session.deltaTicks,
|
||||
),
|
||||
);
|
||||
return (
|
||||
timelineTimeToSnappedPixels({
|
||||
time: displayedStartTime + clampedTime,
|
||||
zoomLevel: this.config.zoomLevel,
|
||||
}) - elementLeft
|
||||
);
|
||||
}
|
||||
|
||||
private activate(): void {
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
private deactivate(): void {
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.subscribers) fn();
|
||||
}
|
||||
|
||||
private finishSession(): void {
|
||||
this.session = { kind: "idle" };
|
||||
this.deactivate();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private commitDrag({
|
||||
keyframeRefs,
|
||||
deltaTicks,
|
||||
}: {
|
||||
keyframeRefs: SelectedKeyframeRef[];
|
||||
deltaTicks: number;
|
||||
}): void {
|
||||
const { element } = this.config;
|
||||
const commands: Command[] = keyframeRefs.flatMap((ref) => {
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath: ref.propertyPath,
|
||||
keyframeId: ref.keyframeId,
|
||||
});
|
||||
if (!keyframe) return [];
|
||||
return [
|
||||
new RetimeKeyframeCommand({
|
||||
trackId: ref.trackId,
|
||||
elementId: ref.elementId,
|
||||
propertyPath: ref.propertyPath,
|
||||
keyframeId: ref.keyframeId,
|
||||
nextTime: Math.max(
|
||||
0,
|
||||
Math.min(element.duration, keyframe.time + deltaTicks),
|
||||
),
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
const [first, ...rest] = commands;
|
||||
if (!first) return;
|
||||
if (rest.length === 0) {
|
||||
this.config.executeCommand(first);
|
||||
} else {
|
||||
this.config.executeCommand(new BatchCommand([first, ...rest]));
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseMove({ clientX }: MouseEvent): void {
|
||||
if (this.session.kind === "pending") {
|
||||
const deltaX = Math.abs(clientX - this.session.startMouseX);
|
||||
if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
|
||||
|
||||
this.session = {
|
||||
kind: "active",
|
||||
keyframeRefs: this.session.keyframeRefs,
|
||||
startMouseX: this.session.startMouseX,
|
||||
deltaTicks: 0,
|
||||
};
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.session.kind !== "active") return;
|
||||
|
||||
const fps = this.config.getFps();
|
||||
if (!fps) return;
|
||||
|
||||
const pixelsPerSecond =
|
||||
BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel;
|
||||
const rawDeltaTicks = Math.round(
|
||||
((clientX - this.session.startMouseX) / pixelsPerSecond) *
|
||||
TICKS_PER_SECOND,
|
||||
);
|
||||
this.session.deltaTicks =
|
||||
roundToFrame({ time: rawDeltaTicks, rate: fps }) ?? rawDeltaTicks;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private handleMouseUp(): void {
|
||||
if (this.session.kind === "pending") {
|
||||
this.finishSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.session.kind !== "active") return;
|
||||
|
||||
const { selectedKeyframes, element } = this.config;
|
||||
const { keyframeRefs, deltaTicks } = this.session;
|
||||
const draggingIds = new Set(keyframeRefs.map((r) => r.keyframeId));
|
||||
const draggingRefs = selectedKeyframes.filter(
|
||||
(kf) => kf.elementId === element.id && draggingIds.has(kf.keyframeId),
|
||||
);
|
||||
|
||||
if (draggingRefs.length > 0 && deltaTicks !== 0) {
|
||||
this.commitDrag({ keyframeRefs: draggingRefs, deltaTicks });
|
||||
}
|
||||
|
||||
this.finishSession();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,314 @@
|
|||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { snappedSeekTime, type FrameRate } from "opencut-wasm";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
buildTimelineSnapPoints,
|
||||
getTimelineSnapThresholdInTicks,
|
||||
resolveTimelineSnap,
|
||||
} from "@/timeline/snapping";
|
||||
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import {
|
||||
getCenteredLineLeft,
|
||||
timelineTimeToPixels,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/timeline";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import type { Bookmark, SceneTracks } from "@/timeline";
|
||||
|
||||
// --- Session ---
|
||||
|
||||
interface ScrubSession {
|
||||
kind: "scrubbing";
|
||||
/** True when scrub started from a ruler click (not the playhead handle). */
|
||||
didStartFromRuler: boolean;
|
||||
/** True once the mouse has moved during a ruler drag. */
|
||||
hasMoved: boolean;
|
||||
/** Most recent frame-snapped time set by scrub(). */
|
||||
currentTime: number | null;
|
||||
}
|
||||
|
||||
type Session = { kind: "idle" } | ScrubSession;
|
||||
|
||||
// --- Config ---
|
||||
|
||||
export interface PlayheadConfig {
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
getActiveProjectFps: () => FrameRate | null;
|
||||
isShiftHeld: () => boolean;
|
||||
getIsPlaying: () => boolean;
|
||||
getRulerEl: () => HTMLDivElement | null;
|
||||
getRulerScrollEl: () => HTMLDivElement | null;
|
||||
getTracksScrollEl: () => HTMLDivElement | null;
|
||||
getPlayheadEl: () => HTMLDivElement | null;
|
||||
getSceneTracks: () => SceneTracks;
|
||||
getSceneBookmarks: () => Bookmark[];
|
||||
seek: (time: number) => void;
|
||||
setScrubbing: (isScrubbing: boolean) => void;
|
||||
setTimelineViewState: (viewState: {
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
playheadTime: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface PlayheadConfigRef {
|
||||
readonly current: PlayheadConfig;
|
||||
}
|
||||
|
||||
// --- Pure helpers (px → logical) ---
|
||||
|
||||
function pixelToTime({
|
||||
clientX,
|
||||
rulerEl,
|
||||
zoomLevel,
|
||||
duration,
|
||||
}: {
|
||||
clientX: number;
|
||||
rulerEl: HTMLDivElement;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
}): number {
|
||||
const rulerRect = rulerEl.getBoundingClientRect();
|
||||
const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
|
||||
const clampedX = Math.max(
|
||||
0,
|
||||
Math.min(contentWidth, clientX - rulerRect.left),
|
||||
);
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration / TICKS_PER_SECOND,
|
||||
clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
return Math.round(seconds * TICKS_PER_SECOND);
|
||||
}
|
||||
|
||||
// --- Controller ---
|
||||
|
||||
export class PlayheadController {
|
||||
private lastMouseClientX = 0;
|
||||
|
||||
private session: Session = { kind: "idle" };
|
||||
private readonly configRef: PlayheadConfigRef;
|
||||
|
||||
constructor(deps: { configRef: PlayheadConfigRef }) {
|
||||
this.configRef = deps.configRef;
|
||||
this.onPlayheadMouseDown = this.onPlayheadMouseDown.bind(this);
|
||||
this.onRulerMouseDown = this.onRulerMouseDown.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
}
|
||||
|
||||
private get config(): PlayheadConfig {
|
||||
return this.configRef.current;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.session.kind !== "idle";
|
||||
}
|
||||
|
||||
getLastMouseClientX(): number {
|
||||
return this.lastMouseClientX;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.deactivate();
|
||||
}
|
||||
|
||||
// --- Public event handlers (bound, stable references) ---
|
||||
|
||||
onPlayheadMouseDown(event: ReactMouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.session = {
|
||||
kind: "scrubbing",
|
||||
didStartFromRuler: false,
|
||||
hasMoved: false,
|
||||
currentTime: null,
|
||||
};
|
||||
this.config.setScrubbing(true);
|
||||
this.scrub({ event, isElementSnappingEnabled: true });
|
||||
this.activate();
|
||||
}
|
||||
|
||||
onRulerMouseDown(event: ReactMouseEvent): void {
|
||||
if (event.button !== 0) return;
|
||||
if (this.config.getPlayheadEl()?.contains(event.target as Node)) return;
|
||||
|
||||
event.preventDefault();
|
||||
this.session = {
|
||||
kind: "scrubbing",
|
||||
didStartFromRuler: true,
|
||||
hasMoved: false,
|
||||
currentTime: null,
|
||||
};
|
||||
this.config.setScrubbing(true);
|
||||
// No element-edge snapping on initial ruler click — avoids a jarring jump.
|
||||
this.scrub({ event, isElementSnappingEnabled: false });
|
||||
this.activate();
|
||||
}
|
||||
|
||||
// --- Public non-session methods ---
|
||||
|
||||
/**
|
||||
* Imperatively updates the playhead DOM element's `left` style.
|
||||
* Called on scroll and playback events to avoid React re-renders
|
||||
* during animation frame updates.
|
||||
*/
|
||||
updatePlayheadLeft(time: number): void {
|
||||
const playheadEl = this.config.getPlayheadEl();
|
||||
if (!playheadEl) return;
|
||||
|
||||
const centerPixel = timelineTimeToSnappedPixels({
|
||||
time,
|
||||
zoomLevel: this.config.zoomLevel,
|
||||
});
|
||||
const scrollLeft = this.config.getRulerScrollEl()?.scrollLeft ?? 0;
|
||||
playheadEl.style.left = `${getCenteredLineLeft({ centerPixel }) - scrollLeft}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the playhead position and auto-scrolls to keep the playhead
|
||||
* visible during playback.
|
||||
*/
|
||||
handlePlaybackUpdate(time: number): void {
|
||||
this.updatePlayheadLeft(time);
|
||||
|
||||
// Auto-scroll only during playback, not while scrubbing.
|
||||
if (!this.config.getIsPlaying() || this.session.kind === "scrubbing")
|
||||
return;
|
||||
|
||||
const rulerViewport = this.config.getRulerScrollEl();
|
||||
const tracksViewport = this.config.getTracksScrollEl();
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const playheadPixels = timelineTimeToPixels({
|
||||
time,
|
||||
zoomLevel: this.config.zoomLevel,
|
||||
});
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const isOutOfView =
|
||||
playheadPixels < rulerViewport.scrollLeft ||
|
||||
playheadPixels > rulerViewport.scrollLeft + viewportWidth;
|
||||
|
||||
if (isOutOfView) {
|
||||
const desiredScroll = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
rulerViewport.scrollWidth - viewportWidth,
|
||||
playheadPixels - viewportWidth / 2,
|
||||
),
|
||||
);
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private ---
|
||||
|
||||
private activate(): void {
|
||||
window.addEventListener("mousemove", this.handleMouseMove);
|
||||
window.addEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
private deactivate(): void {
|
||||
window.removeEventListener("mousemove", this.handleMouseMove);
|
||||
window.removeEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts pointer position to a frame-snapped timeline time and seeks.
|
||||
* `isElementSnappingEnabled` controls element-edge snapping; frame-level snapping
|
||||
* is always applied.
|
||||
*/
|
||||
private scrub({
|
||||
event,
|
||||
isElementSnappingEnabled,
|
||||
}: {
|
||||
event: MouseEvent | ReactMouseEvent;
|
||||
isElementSnappingEnabled: boolean;
|
||||
}): void {
|
||||
const ruler = this.config.getRulerEl();
|
||||
if (!ruler) return;
|
||||
|
||||
const fps = this.config.getActiveProjectFps();
|
||||
if (!fps) return;
|
||||
|
||||
const { zoomLevel, duration } = this.config;
|
||||
const rawTime = pixelToTime({
|
||||
clientX: event.clientX,
|
||||
rulerEl: ruler,
|
||||
zoomLevel,
|
||||
duration,
|
||||
});
|
||||
const frameTime =
|
||||
snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime;
|
||||
|
||||
const time = (() => {
|
||||
if (!isElementSnappingEnabled || this.config.isShiftHeld())
|
||||
return frameTime;
|
||||
|
||||
const snapPoints = buildTimelineSnapPoints({
|
||||
sources: [
|
||||
() =>
|
||||
getElementEdgeSnapPoints({ tracks: this.config.getSceneTracks() }),
|
||||
() =>
|
||||
getBookmarkSnapPoints({
|
||||
bookmarks: this.config.getSceneBookmarks(),
|
||||
}),
|
||||
() =>
|
||||
getAnimationKeyframeSnapPointsForTimeline({
|
||||
tracks: this.config.getSceneTracks(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = resolveTimelineSnap({
|
||||
targetTime: frameTime,
|
||||
snapPoints,
|
||||
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
|
||||
});
|
||||
return result.snapPoint ? result.snappedTime : frameTime;
|
||||
})();
|
||||
|
||||
if (this.session.kind === "scrubbing") {
|
||||
this.session.currentTime = time;
|
||||
}
|
||||
this.config.seek(time);
|
||||
this.lastMouseClientX = event.clientX;
|
||||
}
|
||||
|
||||
private handleMouseMove(event: MouseEvent): void {
|
||||
if (this.session.kind !== "scrubbing") return;
|
||||
this.scrub({ event, isElementSnappingEnabled: true });
|
||||
if (this.session.didStartFromRuler) {
|
||||
this.session.hasMoved = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleMouseUp(event: MouseEvent): void {
|
||||
if (this.session.kind !== "scrubbing") return;
|
||||
|
||||
const session = this.session;
|
||||
this.config.setScrubbing(false);
|
||||
|
||||
if (session.currentTime !== null) {
|
||||
this.config.seek(session.currentTime);
|
||||
this.config.setTimelineViewState({
|
||||
zoomLevel: this.config.zoomLevel,
|
||||
scrollLeft: this.config.getTracksScrollEl()?.scrollLeft ?? 0,
|
||||
playheadTime: session.currentTime,
|
||||
});
|
||||
}
|
||||
|
||||
// Ruler click without drag: snap to clicked position on mouseup.
|
||||
if (session.didStartFromRuler && !session.hasMoved) {
|
||||
this.scrub({ event, isElementSnappingEnabled: false });
|
||||
}
|
||||
|
||||
this.session = { kind: "idle" };
|
||||
this.deactivate();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
computeGroupResize,
|
||||
type GroupResizeMember,
|
||||
type GroupResizeResult,
|
||||
type GroupResizeUpdate,
|
||||
type ResizeSide,
|
||||
} from "@/timeline/group-resize";
|
||||
import {
|
||||
buildTimelineSnapPoints,
|
||||
getTimelineSnapThresholdInTicks,
|
||||
resolveTimelineSnap,
|
||||
type SnapPoint,
|
||||
} from "@/timeline/snapping";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import {
|
||||
isRetimableElement,
|
||||
type SceneTracks,
|
||||
type TimelineElement,
|
||||
type TimelineTrack,
|
||||
} from "@/timeline";
|
||||
import type { ElementRef } from "@/timeline/types";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
|
||||
// --- Session ---
|
||||
|
||||
interface ResizeSession {
|
||||
kind: "active";
|
||||
side: ResizeSide;
|
||||
startX: number;
|
||||
fps: FrameRate;
|
||||
members: GroupResizeMember[];
|
||||
result: GroupResizeResult | null;
|
||||
}
|
||||
|
||||
type Session = { kind: "idle" } | ResizeSession;
|
||||
|
||||
// --- Config ---
|
||||
|
||||
export interface ResizeConfig {
|
||||
zoomLevel: number;
|
||||
snappingEnabled: boolean;
|
||||
isShiftHeld: () => boolean;
|
||||
getSceneTracks: () => SceneTracks;
|
||||
getCurrentPlayheadTime: () => number;
|
||||
getActiveProjectFps: () => FrameRate | null;
|
||||
selectedElements: ElementRef[];
|
||||
discardPreview: () => void;
|
||||
previewElements: (updates: GroupResizeUpdate[]) => void;
|
||||
commitElements: (updates: GroupResizeUpdate[]) => void;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
export interface ResizeConfigRef {
|
||||
readonly current: ResizeConfig;
|
||||
}
|
||||
|
||||
// --- Pure helpers ---
|
||||
|
||||
export function buildResizeMembers({
|
||||
tracks,
|
||||
selectedElements,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
selectedElements: ElementRef[];
|
||||
}): GroupResizeMember[] {
|
||||
const selectedElementIds = new Set(
|
||||
selectedElements.map((el) => el.elementId),
|
||||
);
|
||||
const trackMap = new Map(
|
||||
[...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
|
||||
track.id,
|
||||
track,
|
||||
]),
|
||||
);
|
||||
|
||||
return selectedElements.flatMap(({ trackId, elementId }) => {
|
||||
const track = trackMap.get(trackId);
|
||||
const element = track?.elements.find((el) => el.id === elementId);
|
||||
if (!track || !element) return [];
|
||||
|
||||
const otherElements = track.elements.filter(
|
||||
(el) => !selectedElementIds.has(el.id),
|
||||
);
|
||||
const leftNeighborBound = otherElements
|
||||
.filter((el) => el.startTime + el.duration <= element.startTime)
|
||||
.reduce(
|
||||
(bound, el) => Math.max(bound, el.startTime + el.duration),
|
||||
-Infinity,
|
||||
);
|
||||
const rightNeighborBound = otherElements
|
||||
.filter((el) => el.startTime >= element.startTime + element.duration)
|
||||
.reduce((bound, el) => Math.min(bound, el.startTime), Infinity);
|
||||
|
||||
return [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
sourceDuration: element.sourceDuration,
|
||||
retime: isRetimableElement(element) ? element.retime : undefined,
|
||||
leftNeighborBound,
|
||||
rightNeighborBound,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function hasResizeChanges({
|
||||
members,
|
||||
result,
|
||||
}: {
|
||||
members: GroupResizeMember[];
|
||||
result: GroupResizeResult;
|
||||
}): boolean {
|
||||
return result.updates.some((update) => {
|
||||
const member = members.find((m) => m.elementId === update.elementId);
|
||||
return (
|
||||
member?.trimStart !== update.patch.trimStart ||
|
||||
member?.trimEnd !== update.patch.trimEnd ||
|
||||
member?.startTime !== update.patch.startTime ||
|
||||
member?.duration !== update.patch.duration
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Controller ---
|
||||
|
||||
export class ResizeController {
|
||||
private session: Session = { kind: "idle" };
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
private readonly configRef: ResizeConfigRef;
|
||||
|
||||
constructor(deps: { configRef: ResizeConfigRef }) {
|
||||
this.configRef = deps.configRef;
|
||||
this.onResizeStart = this.onResizeStart.bind(this);
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
}
|
||||
|
||||
private get config(): ResizeConfig {
|
||||
return this.configRef.current;
|
||||
}
|
||||
|
||||
get isResizing(): boolean {
|
||||
return this.session.kind === "active";
|
||||
}
|
||||
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.config.discardPreview();
|
||||
this.finishSession();
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.deactivate();
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
onResizeStart({
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
side,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
side: ResizeSide;
|
||||
}): void {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// UI should prevent this, but be explicit: a new resize start
|
||||
// means the previous one is abandoned, not silently replaced.
|
||||
if (this.session.kind === "active") this.cancel();
|
||||
|
||||
const fps = this.config.getActiveProjectFps();
|
||||
if (!fps) return;
|
||||
|
||||
const ref = { trackId: track.id, elementId: element.id };
|
||||
const activeSelection = this.config.selectedElements.some(
|
||||
(el) => el.trackId === track.id && el.elementId === element.id,
|
||||
)
|
||||
? this.config.selectedElements
|
||||
: [ref];
|
||||
|
||||
const members = buildResizeMembers({
|
||||
tracks: this.config.getSceneTracks(),
|
||||
selectedElements: activeSelection,
|
||||
});
|
||||
if (members.length === 0) return;
|
||||
|
||||
this.config.discardPreview();
|
||||
|
||||
this.session = {
|
||||
kind: "active",
|
||||
side,
|
||||
startX: event.clientX,
|
||||
fps,
|
||||
members,
|
||||
result: null,
|
||||
};
|
||||
this.activate();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private activate(): void {
|
||||
document.addEventListener("mousemove", this.handleMouseMove);
|
||||
document.addEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
private deactivate(): void {
|
||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.subscribers) fn();
|
||||
}
|
||||
|
||||
private finishSession(): void {
|
||||
this.session = { kind: "idle" };
|
||||
this.deactivate();
|
||||
this.config.onSnapPointChange?.(null);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private snappedDelta(session: ResizeSession, rawDeltaTicks: number): number {
|
||||
const { snappingEnabled, isShiftHeld, zoomLevel } = this.config;
|
||||
|
||||
if (!snappingEnabled || isShiftHeld()) {
|
||||
this.config.onSnapPointChange?.(null);
|
||||
return rawDeltaTicks;
|
||||
}
|
||||
|
||||
const tracks = this.config.getSceneTracks();
|
||||
const playheadTime = this.config.getCurrentPlayheadTime();
|
||||
const excludeElementIds = new Set(session.members.map((m) => m.elementId));
|
||||
|
||||
const snapPoints = buildTimelineSnapPoints({
|
||||
sources: [
|
||||
() => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
|
||||
() => getPlayheadSnapPoints({ playheadTime }),
|
||||
() =>
|
||||
getAnimationKeyframeSnapPointsForTimeline({
|
||||
tracks,
|
||||
excludeElementIds,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
|
||||
|
||||
let closestSnapPoint: SnapPoint | null = null;
|
||||
let closestSnapDistance = Infinity;
|
||||
let deltaTicks = rawDeltaTicks;
|
||||
|
||||
for (const member of session.members) {
|
||||
const baseEdgeTime =
|
||||
session.side === "left"
|
||||
? member.startTime
|
||||
: member.startTime + member.duration;
|
||||
const snapResult = resolveTimelineSnap({
|
||||
targetTime: baseEdgeTime + rawDeltaTicks,
|
||||
snapPoints,
|
||||
maxSnapDistance,
|
||||
});
|
||||
if (
|
||||
snapResult.snapPoint &&
|
||||
snapResult.snapDistance < closestSnapDistance
|
||||
) {
|
||||
closestSnapDistance = snapResult.snapDistance;
|
||||
closestSnapPoint = snapResult.snapPoint;
|
||||
deltaTicks = snapResult.snappedTime - baseEdgeTime;
|
||||
}
|
||||
}
|
||||
|
||||
this.config.onSnapPointChange?.(closestSnapPoint);
|
||||
return deltaTicks;
|
||||
}
|
||||
|
||||
private handleMouseMove({ clientX }: MouseEvent): void {
|
||||
if (this.session.kind !== "active") return;
|
||||
const session = this.session;
|
||||
|
||||
const rawDeltaTicks = Math.round(
|
||||
((clientX - session.startX) /
|
||||
(BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
|
||||
TICKS_PER_SECOND,
|
||||
);
|
||||
const deltaTicks = this.snappedDelta(session, rawDeltaTicks);
|
||||
const result = computeGroupResize({
|
||||
members: session.members,
|
||||
side: session.side,
|
||||
deltaTicks,
|
||||
fps: session.fps,
|
||||
});
|
||||
|
||||
session.result = result;
|
||||
this.config.previewElements(result.updates);
|
||||
}
|
||||
|
||||
private handleMouseUp(): void {
|
||||
if (this.session.kind !== "active") return;
|
||||
const session = this.session;
|
||||
|
||||
this.config.discardPreview();
|
||||
|
||||
if (
|
||||
session.result &&
|
||||
hasResizeChanges({ members: session.members, result: session.result })
|
||||
) {
|
||||
this.config.commitElements(session.result.updates);
|
||||
}
|
||||
|
||||
this.finishSession();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { snappedSeekTime, type FrameRate } from "opencut-wasm";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
|
||||
type SeekSource = "ruler" | "tracks";
|
||||
|
||||
interface PendingSeekSession {
|
||||
kind: "pending";
|
||||
source: SeekSource;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}
|
||||
|
||||
type Session = { kind: "idle" } | PendingSeekSession;
|
||||
|
||||
export interface SeekConfig {
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
isSelecting: boolean;
|
||||
getPlayheadEl: () => HTMLDivElement | null;
|
||||
getTrackLabelsEl: () => HTMLDivElement | null;
|
||||
getRulerScrollEl: () => HTMLDivElement | null;
|
||||
getTracksScrollEl: () => HTMLDivElement | null;
|
||||
getActiveProjectFps: () => FrameRate | null;
|
||||
clearSelectedElements: () => void;
|
||||
seek: (time: number) => void;
|
||||
setTimelineViewState: (viewState: {
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
playheadTime: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface SeekConfigRef {
|
||||
readonly current: SeekConfig;
|
||||
}
|
||||
|
||||
function pixelToTime({
|
||||
clientX,
|
||||
scrollContainer,
|
||||
zoomLevel,
|
||||
duration,
|
||||
}: {
|
||||
clientX: number;
|
||||
scrollContainer: HTMLDivElement;
|
||||
zoomLevel: number;
|
||||
duration: number;
|
||||
}): number {
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
|
||||
const rawTimeSeconds = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration / TICKS_PER_SECOND,
|
||||
(mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
|
||||
return Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
||||
}
|
||||
|
||||
function isClickGesture({
|
||||
event,
|
||||
session,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
session: PendingSeekSession;
|
||||
}): boolean {
|
||||
const deltaX = Math.abs(event.clientX - session.downX);
|
||||
const deltaY = Math.abs(event.clientY - session.downY);
|
||||
const deltaTime = event.timeStamp - session.downTime;
|
||||
|
||||
return deltaX <= 5 && deltaY <= 5 && deltaTime <= 500;
|
||||
}
|
||||
|
||||
export class SeekController {
|
||||
private session: Session = { kind: "idle" };
|
||||
private readonly configRef: SeekConfigRef;
|
||||
|
||||
constructor(deps: { configRef: SeekConfigRef }) {
|
||||
this.configRef = deps.configRef;
|
||||
this.onTracksMouseDown = this.onTracksMouseDown.bind(this);
|
||||
this.onRulerMouseDown = this.onRulerMouseDown.bind(this);
|
||||
this.onTracksClick = this.onTracksClick.bind(this);
|
||||
this.onRulerClick = this.onRulerClick.bind(this);
|
||||
}
|
||||
|
||||
private get config(): SeekConfig {
|
||||
return this.configRef.current;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.session = { kind: "idle" };
|
||||
}
|
||||
|
||||
onTracksMouseDown(event: ReactMouseEvent): void {
|
||||
this.beginPendingSeek({ event, source: "tracks" });
|
||||
}
|
||||
|
||||
onRulerMouseDown(event: ReactMouseEvent): void {
|
||||
this.beginPendingSeek({ event, source: "ruler" });
|
||||
}
|
||||
|
||||
onTracksClick(event: ReactMouseEvent): void {
|
||||
this.handleClick({ event, source: "tracks" });
|
||||
}
|
||||
|
||||
onRulerClick(event: ReactMouseEvent): void {
|
||||
this.handleClick({ event, source: "ruler" });
|
||||
}
|
||||
|
||||
private beginPendingSeek({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
source: SeekSource;
|
||||
}): void {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
this.session = {
|
||||
kind: "pending",
|
||||
source,
|
||||
downX: event.clientX,
|
||||
downY: event.clientY,
|
||||
downTime: event.timeStamp,
|
||||
};
|
||||
}
|
||||
|
||||
private handleClick({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
source: SeekSource;
|
||||
}): void {
|
||||
const shouldProcess = this.shouldProcessClick({ event, source });
|
||||
this.session = { kind: "idle" };
|
||||
|
||||
if (!shouldProcess) return;
|
||||
|
||||
this.config.clearSelectedElements();
|
||||
this.seekFromEvent({ event, source });
|
||||
}
|
||||
|
||||
private shouldProcessClick({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
source: SeekSource;
|
||||
}): boolean {
|
||||
if (this.session.kind !== "pending") return false;
|
||||
if (this.session.source !== source) return false;
|
||||
if (!isClickGesture({ event, session: this.session })) return false;
|
||||
if (this.config.isSelecting) return false;
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
if (this.config.getPlayheadEl()?.contains(target)) return false;
|
||||
|
||||
if (this.config.getTrackLabelsEl()?.contains(target)) {
|
||||
this.config.clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private seekFromEvent({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
source: SeekSource;
|
||||
}): void {
|
||||
const scrollContainer =
|
||||
source === "ruler"
|
||||
? this.config.getRulerScrollEl()
|
||||
: this.config.getTracksScrollEl();
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const rawTime = pixelToTime({
|
||||
clientX: event.clientX,
|
||||
scrollContainer,
|
||||
zoomLevel: this.config.zoomLevel,
|
||||
duration: this.config.duration,
|
||||
});
|
||||
|
||||
const fps = this.config.getActiveProjectFps();
|
||||
const time =
|
||||
fps != null
|
||||
? (snappedSeekTime({
|
||||
time: rawTime,
|
||||
duration: this.config.duration,
|
||||
rate: fps,
|
||||
}) ?? rawTime)
|
||||
: rawTime;
|
||||
|
||||
this.config.seek(time);
|
||||
this.config.setTimelineViewState({
|
||||
zoomLevel: this.config.zoomLevel,
|
||||
scrollLeft: scrollContainer.scrollLeft,
|
||||
playheadTime: time,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
import type { WheelEvent as ReactWheelEvent } from "react";
|
||||
import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction";
|
||||
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
|
||||
import { TIMELINE_ZOOM_MAX } from "@/timeline/scale";
|
||||
import { zoomToSlider } from "@/timeline/zoom-utils";
|
||||
|
||||
type ZoomUpdater = number | ((prev: number) => number);
|
||||
|
||||
export interface ZoomConfig {
|
||||
minZoom: number;
|
||||
getContainerEl: () => HTMLDivElement | null;
|
||||
getTracksScrollEl: () => HTMLDivElement | null;
|
||||
getRulerScrollEl: () => HTMLDivElement | null;
|
||||
getCurrentPlayheadTime: () => number;
|
||||
seek: (time: number) => void;
|
||||
setTimelineViewState: (viewState: {
|
||||
zoomLevel: number;
|
||||
scrollLeft: number;
|
||||
playheadTime: number;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export interface ZoomConfigRef {
|
||||
readonly current: ZoomConfig;
|
||||
}
|
||||
|
||||
function clampZoom(zoomLevel: number, minZoom: number): number {
|
||||
return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, zoomLevel));
|
||||
}
|
||||
|
||||
export class ZoomController {
|
||||
private readonly configRef: ZoomConfigRef;
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
|
||||
private zoomLevelValue: number;
|
||||
private hasInitialized = false;
|
||||
private hasRestoredPlayhead = false;
|
||||
private hasRestoredScroll = false;
|
||||
private previousZoom: number;
|
||||
private preZoomScrollLeft = 0;
|
||||
private prePlayheadAnchorScrollLeft = 0;
|
||||
private isInPlayheadAnchorMode = false;
|
||||
private scrollSaveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(deps: { configRef: ZoomConfigRef; initialZoom?: number }) {
|
||||
this.configRef = deps.configRef;
|
||||
|
||||
const minZoom = this.config.minZoom;
|
||||
this.zoomLevelValue =
|
||||
deps.initialZoom !== undefined
|
||||
? clampZoom(deps.initialZoom, minZoom)
|
||||
: minZoom;
|
||||
this.previousZoom = this.zoomLevelValue;
|
||||
this.hasInitialized = deps.initialZoom !== undefined;
|
||||
|
||||
this.setZoomLevel = this.setZoomLevel.bind(this);
|
||||
this.handleWheel = this.handleWheel.bind(this);
|
||||
this.saveScrollPosition = this.saveScrollPosition.bind(this);
|
||||
}
|
||||
|
||||
private get config(): ZoomConfig {
|
||||
return this.configRef.current;
|
||||
}
|
||||
|
||||
get zoomLevel(): number {
|
||||
return this.zoomLevelValue;
|
||||
}
|
||||
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.scrollSaveTimeout) {
|
||||
clearTimeout(this.scrollSaveTimeout);
|
||||
this.scrollSaveTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
setZoomLevel(zoomLevelOrUpdater: ZoomUpdater): void {
|
||||
const scrollElement = this.config.getTracksScrollEl();
|
||||
if (scrollElement) {
|
||||
this.preZoomScrollLeft = scrollElement.scrollLeft;
|
||||
}
|
||||
|
||||
const nextZoomRaw =
|
||||
typeof zoomLevelOrUpdater === "function"
|
||||
? zoomLevelOrUpdater(this.zoomLevelValue)
|
||||
: zoomLevelOrUpdater;
|
||||
const nextZoom = clampZoom(nextZoomRaw, this.config.minZoom);
|
||||
if (nextZoom === this.zoomLevelValue) return;
|
||||
|
||||
this.zoomLevelValue = nextZoom;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
handleWheel(event: ReactWheelEvent): void {
|
||||
const isZoomGesture = event.ctrlKey || event.metaKey;
|
||||
const isHorizontalScrollGesture =
|
||||
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
||||
|
||||
if (isHorizontalScrollGesture) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isZoomGesture) {
|
||||
const normalizedDelta =
|
||||
event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
||||
const cappedDelta =
|
||||
Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
|
||||
const zoomFactor = Math.exp(-cappedDelta / 300);
|
||||
this.setZoomLevel((prev) => prev * zoomFactor);
|
||||
}
|
||||
}
|
||||
|
||||
reconcileInitialAndMinZoom(minZoom: number, initialZoom?: number): void {
|
||||
if (initialZoom !== undefined && !this.hasInitialized) {
|
||||
this.hasInitialized = true;
|
||||
this.setZoomLevel(clampZoom(initialZoom, minZoom));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.zoomLevelValue < minZoom) {
|
||||
this.setZoomLevel(minZoom);
|
||||
}
|
||||
}
|
||||
|
||||
applyZoomLayout(zoomLevel: number): void {
|
||||
const previousZoom = this.previousZoom;
|
||||
if (previousZoom === zoomLevel) return;
|
||||
|
||||
const scrollElement = this.config.getTracksScrollEl();
|
||||
if (!scrollElement) {
|
||||
this.previousZoom = zoomLevel;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentScrollLeft = this.preZoomScrollLeft;
|
||||
const playheadTime = this.config.getCurrentPlayheadTime();
|
||||
const sliderPercent = zoomToSlider({
|
||||
zoomLevel,
|
||||
minZoom: this.config.minZoom,
|
||||
});
|
||||
const previousSliderPercent = zoomToSlider({
|
||||
zoomLevel: previousZoom,
|
||||
minZoom: this.config.minZoom,
|
||||
});
|
||||
const isCrossingThresholdUp =
|
||||
previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
const isCrossingThresholdDown =
|
||||
previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
|
||||
const syncScroll = (scrollLeft: number) => {
|
||||
scrollElement.scrollLeft = scrollLeft;
|
||||
const ruler = this.config.getRulerScrollEl();
|
||||
if (ruler) {
|
||||
ruler.scrollLeft = scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
const clampScrollLeft = (scrollLeft: number) => {
|
||||
const maxScrollLeft =
|
||||
scrollElement.scrollWidth - scrollElement.clientWidth;
|
||||
return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
|
||||
};
|
||||
|
||||
if (isCrossingThresholdUp) {
|
||||
this.prePlayheadAnchorScrollLeft = currentScrollLeft;
|
||||
this.isInPlayheadAnchorMode = true;
|
||||
}
|
||||
|
||||
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||
const playheadPixelsBefore = timelineTimeToPixels({
|
||||
time: playheadTime,
|
||||
zoomLevel: previousZoom,
|
||||
});
|
||||
const playheadPixelsAfter = timelineTimeToPixels({
|
||||
time: playheadTime,
|
||||
zoomLevel,
|
||||
});
|
||||
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
||||
const nextScrollLeft = playheadPixelsAfter - viewportOffset;
|
||||
syncScroll(clampScrollLeft(nextScrollLeft));
|
||||
} else if (isCrossingThresholdDown && this.isInPlayheadAnchorMode) {
|
||||
syncScroll(clampScrollLeft(this.prePlayheadAnchorScrollLeft));
|
||||
this.isInPlayheadAnchorMode = false;
|
||||
}
|
||||
|
||||
this.previousZoom = zoomLevel;
|
||||
|
||||
this.config.setTimelineViewState({
|
||||
zoomLevel,
|
||||
scrollLeft: scrollElement.scrollLeft,
|
||||
playheadTime,
|
||||
});
|
||||
}
|
||||
|
||||
saveScrollPosition(): void {
|
||||
if (this.scrollSaveTimeout) {
|
||||
clearTimeout(this.scrollSaveTimeout);
|
||||
}
|
||||
|
||||
this.scrollSaveTimeout = setTimeout(() => {
|
||||
const scrollElement = this.config.getTracksScrollEl();
|
||||
if (!scrollElement) return;
|
||||
|
||||
this.config.setTimelineViewState({
|
||||
zoomLevel: this.zoomLevelValue,
|
||||
scrollLeft: scrollElement.scrollLeft,
|
||||
playheadTime: this.config.getCurrentPlayheadTime(),
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
restoreInitialScrollIfNeeded(
|
||||
initialScrollLeft?: number,
|
||||
): (() => void) | undefined {
|
||||
if (initialScrollLeft === undefined) return;
|
||||
if (this.hasRestoredScroll) return;
|
||||
|
||||
const scrollElement = this.config.getTracksScrollEl();
|
||||
if (!scrollElement) return;
|
||||
|
||||
const restoreScroll = () => {
|
||||
scrollElement.scrollLeft = initialScrollLeft;
|
||||
const ruler = this.config.getRulerScrollEl();
|
||||
if (ruler) {
|
||||
ruler.scrollLeft = initialScrollLeft;
|
||||
}
|
||||
this.hasRestoredScroll = true;
|
||||
this.preZoomScrollLeft = initialScrollLeft;
|
||||
};
|
||||
|
||||
if (scrollElement.scrollWidth > 0) {
|
||||
restoreScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (scrollElement.scrollWidth > 0) {
|
||||
restoreScroll();
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(scrollElement);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
restoreInitialPlayheadIfNeeded(initialPlayheadTime?: number): void {
|
||||
if (initialPlayheadTime === undefined) return;
|
||||
if (this.hasRestoredPlayhead) return;
|
||||
|
||||
this.hasRestoredPlayhead = true;
|
||||
this.config.seek(initialPlayheadTime);
|
||||
}
|
||||
|
||||
bindPreventBrowserZoom(): () => void {
|
||||
const preventZoom = (event: WheelEvent) => {
|
||||
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
|
||||
const container = this.config.getContainerEl();
|
||||
const isInContainer = container?.contains(event.target as Node) ?? false;
|
||||
if (isZoomKeyPressed && isInContainer) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("wheel", preventZoom, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom, { capture: true });
|
||||
};
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.subscribers) {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,20 @@
|
|||
import { TICKS_PER_SECOND } from "@/wasm/ticks";
|
||||
|
||||
export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND;
|
||||
|
||||
/**
|
||||
* Resolves the duration in ticks that a newly created timeline element
|
||||
* should have, given an optional source duration in seconds.
|
||||
*
|
||||
* Falls back to {@link DEFAULT_NEW_ELEMENT_DURATION} when the source has
|
||||
* no known duration (e.g. stickers, graphics, text, or media whose
|
||||
* probe failed).
|
||||
*/
|
||||
export function toElementDurationTicks({
|
||||
seconds,
|
||||
}: {
|
||||
seconds: number | null | undefined;
|
||||
}): number {
|
||||
if (seconds == null) return DEFAULT_NEW_ELEMENT_DURATION;
|
||||
return Math.round(seconds * TICKS_PER_SECOND);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
|
||||
const MIME_TYPE = "application/x-timeline-drag";
|
||||
let lastDragData: TimelineDragData | null = null;
|
||||
|
||||
export function setDragData({
|
||||
dataTransfer,
|
||||
dragData,
|
||||
}: {
|
||||
dataTransfer: DataTransfer;
|
||||
dragData: TimelineDragData;
|
||||
}): void {
|
||||
dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
|
||||
dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
||||
lastDragData = dragData;
|
||||
}
|
||||
|
||||
export function getDragData({
|
||||
dataTransfer,
|
||||
}: {
|
||||
dataTransfer: DataTransfer;
|
||||
}): TimelineDragData | null {
|
||||
const data = dataTransfer.getData(MIME_TYPE);
|
||||
if (data) return JSON.parse(data) as TimelineDragData;
|
||||
|
||||
const textData = dataTransfer.getData("text/plain");
|
||||
if (textData) {
|
||||
try {
|
||||
return JSON.parse(textData) as TimelineDragData;
|
||||
} catch {
|
||||
return lastDragData;
|
||||
}
|
||||
}
|
||||
|
||||
return lastDragData;
|
||||
}
|
||||
|
||||
export function hasDragData({
|
||||
dataTransfer,
|
||||
}: {
|
||||
dataTransfer: DataTransfer;
|
||||
}): boolean {
|
||||
return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null;
|
||||
}
|
||||
|
||||
export function clearDragData(): void {
|
||||
lastDragData = null;
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
|
||||
const TIMELINE_DRAG_MIME = "application/x-timeline-drag";
|
||||
|
||||
/**
|
||||
* Owns the state of an in-progress timeline drag session.
|
||||
*
|
||||
* Exists because browsers restrict `DataTransfer.getData()` to the `drop`
|
||||
* event for security — during `dragover`/`dragenter` only `types` is
|
||||
* readable. The drop target needs the payload (element type, target
|
||||
* element types, source duration) while the pointer is hovering, so we
|
||||
* keep a live copy here and hand it out via {@link getActive}.
|
||||
*/
|
||||
export class TimelineDragSource {
|
||||
private active: TimelineDragData | null = null;
|
||||
|
||||
begin({
|
||||
dataTransfer,
|
||||
dragData,
|
||||
}: {
|
||||
dataTransfer: DataTransfer;
|
||||
dragData: TimelineDragData;
|
||||
}): void {
|
||||
dataTransfer.setData(TIMELINE_DRAG_MIME, JSON.stringify(dragData));
|
||||
dataTransfer.effectAllowed = "copy";
|
||||
this.active = dragData;
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.active = null;
|
||||
}
|
||||
|
||||
getActive(): TimelineDragData | null {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active !== null;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,63 +15,63 @@ import type {
|
|||
export function computeGroupResize({
|
||||
members,
|
||||
side,
|
||||
deltaTime,
|
||||
deltaTicks,
|
||||
fps,
|
||||
}: ComputeGroupResizeArgs): GroupResizeResult {
|
||||
const minDuration = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
const minimumDeltaTime = Math.max(
|
||||
const minimumDeltaTicks = Math.max(
|
||||
...members.map((member) =>
|
||||
getMinimumAllowedDeltaTime({
|
||||
getMinimumAllowedDeltaTicks({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const maximumDeltaTime = Math.min(
|
||||
const maximumDeltaTicks = Math.min(
|
||||
...members.map((member) =>
|
||||
getMaximumAllowedDeltaTime({
|
||||
getMaximumAllowedDeltaTicks({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const clampedDeltaTime =
|
||||
minimumDeltaTime > maximumDeltaTime
|
||||
? minimumDeltaTime
|
||||
: Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
|
||||
const clampedDeltaTicks =
|
||||
minimumDeltaTicks > maximumDeltaTicks
|
||||
? minimumDeltaTicks
|
||||
: Math.min(maximumDeltaTicks, Math.max(minimumDeltaTicks, deltaTicks));
|
||||
|
||||
// Snap the drag delta to a frame exactly once, then derive every patch
|
||||
// field from that single snapped value. This keeps the invariant
|
||||
// `trimStart + duration*rate + trimEnd == sourceDuration` exact: the same
|
||||
// delta is added on one side of the element and removed from the other,
|
||||
// so the rounding cancels by construction. Per-field rounding (the old
|
||||
// approach) couldn't preserve this because the individual rounds don't
|
||||
// compose when `sourceDuration` isn't frame-aligned.
|
||||
const snappedDeltaTime =
|
||||
roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime;
|
||||
// so the rounding cancels by construction. Rounding each field
|
||||
// independently would break this — the individual rounds don't compose
|
||||
// when `sourceDuration` isn't frame-aligned.
|
||||
const snappedDeltaTicks =
|
||||
roundToFrame({ time: clampedDeltaTicks, rate: fps }) ?? clampedDeltaTicks;
|
||||
// Re-clamp after rounding. Bounds derived from other elements are
|
||||
// frame-aligned, so this is normally a no-op; at the source-extent limit
|
||||
// the bound may not be frame-aligned, and honouring the bound takes
|
||||
// precedence over frame alignment (you can't extend past real content).
|
||||
const finalDeltaTime =
|
||||
minimumDeltaTime > maximumDeltaTime
|
||||
? minimumDeltaTime
|
||||
const finalDeltaTicks =
|
||||
minimumDeltaTicks > maximumDeltaTicks
|
||||
? minimumDeltaTicks
|
||||
: Math.min(
|
||||
maximumDeltaTime,
|
||||
Math.max(minimumDeltaTime, snappedDeltaTime),
|
||||
maximumDeltaTicks,
|
||||
Math.max(minimumDeltaTicks, snappedDeltaTicks),
|
||||
);
|
||||
|
||||
return {
|
||||
deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
|
||||
deltaTicks: Object.is(finalDeltaTicks, -0) ? 0 : finalDeltaTicks,
|
||||
updates: members.map((member) =>
|
||||
buildResizeUpdate({
|
||||
member,
|
||||
side,
|
||||
deltaTime: finalDeltaTime,
|
||||
deltaTicks: finalDeltaTicks,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
|
@ -80,15 +80,15 @@ export function computeGroupResize({
|
|||
function buildResizeUpdate({
|
||||
member,
|
||||
side,
|
||||
deltaTime,
|
||||
deltaTicks,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
side: ResizeSide;
|
||||
deltaTime: number;
|
||||
deltaTicks: number;
|
||||
}): GroupResizeUpdate {
|
||||
const sourceDelta = getSourceDeltaForClipDelta({
|
||||
member,
|
||||
clipDelta: deltaTime,
|
||||
clipDelta: deltaTicks,
|
||||
});
|
||||
|
||||
if (side === "left") {
|
||||
|
|
@ -98,8 +98,8 @@ function buildResizeUpdate({
|
|||
patch: {
|
||||
trimStart: Math.max(0, member.trimStart + sourceDelta),
|
||||
trimEnd: member.trimEnd,
|
||||
startTime: member.startTime + deltaTime,
|
||||
duration: member.duration - deltaTime,
|
||||
startTime: member.startTime + deltaTicks,
|
||||
duration: member.duration - deltaTicks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -111,12 +111,12 @@ function buildResizeUpdate({
|
|||
trimStart: member.trimStart,
|
||||
trimEnd: Math.max(0, member.trimEnd - sourceDelta),
|
||||
startTime: member.startTime,
|
||||
duration: member.duration + deltaTime,
|
||||
duration: member.duration + deltaTicks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getMinimumAllowedDeltaTime({
|
||||
function getMinimumAllowedDeltaTicks({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
|
|
@ -148,7 +148,7 @@ function getMinimumAllowedDeltaTime({
|
|||
return Math.max(leftNeighborFloor, -maximumSourceExtension);
|
||||
}
|
||||
|
||||
function getMaximumAllowedDeltaTime({
|
||||
function getMaximumAllowedDeltaTicks({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ export interface GroupResizeUpdate extends ElementRef {
|
|||
}
|
||||
|
||||
export interface GroupResizeResult {
|
||||
deltaTime: number;
|
||||
deltaTicks: number;
|
||||
updates: GroupResizeUpdate[];
|
||||
}
|
||||
|
||||
export interface ComputeGroupResizeArgs {
|
||||
members: GroupResizeMember[];
|
||||
side: ResizeSide;
|
||||
deltaTime: number;
|
||||
deltaTicks: number;
|
||||
fps: FrameRate;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,17 @@
|
|||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEffect, useReducer, useRef, type RefObject } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import {
|
||||
buildMoveGroup,
|
||||
resolveGroupMove,
|
||||
snapGroupEdges,
|
||||
type GroupMoveResult,
|
||||
type MoveGroup,
|
||||
} from "@/timeline/group-move";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import { computeDropTarget } from "@/timeline/components/drop-target";
|
||||
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
import type {
|
||||
DropTarget,
|
||||
ElementRef,
|
||||
ElementDragState,
|
||||
SceneTracks,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
} from "@/timeline";
|
||||
import {
|
||||
ElementInteractionController,
|
||||
type ElementInteractionDeps,
|
||||
type ElementInteractionDepsRef,
|
||||
} from "@/timeline/controllers/element-interaction-controller";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
|
||||
interface UseElementInteractionProps {
|
||||
zoomLevel: number;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||
headerRef?: RefObject<HTMLElement | null>;
|
||||
|
|
@ -44,131 +19,8 @@ interface UseElementInteractionProps {
|
|||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
const MOUSE_BUTTON_RIGHT = 2;
|
||||
|
||||
const initialDragState: ElementDragState = {
|
||||
isDragging: false,
|
||||
elementId: null,
|
||||
dragElementIds: [],
|
||||
dragTimeOffsets: {},
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startMouseY: 0,
|
||||
startElementTime: 0,
|
||||
clickOffsetTime: 0,
|
||||
currentTime: 0,
|
||||
currentMouseY: 0,
|
||||
};
|
||||
|
||||
interface PendingDragState {
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
selectedElements: ElementRef[];
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startElementTime: number;
|
||||
clickOffsetTime: number;
|
||||
}
|
||||
|
||||
function getClickOffsetTime({
|
||||
clientX,
|
||||
elementRect,
|
||||
zoomLevel,
|
||||
}: {
|
||||
clientX: number;
|
||||
elementRect: DOMRect;
|
||||
zoomLevel: number;
|
||||
}): number {
|
||||
const clickOffsetX = clientX - elementRect.left;
|
||||
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
||||
return Math.round(seconds * TICKS_PER_SECOND);
|
||||
}
|
||||
|
||||
function getVerticalDragDirection({
|
||||
startMouseY,
|
||||
currentMouseY,
|
||||
}: {
|
||||
startMouseY: number;
|
||||
currentMouseY: number;
|
||||
}): "up" | "down" | null {
|
||||
if (currentMouseY < startMouseY) return "up";
|
||||
if (currentMouseY > startMouseY) return "down";
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDragDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
elementId,
|
||||
trackId,
|
||||
tracks,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
zoomLevel,
|
||||
snappedTime,
|
||||
verticalDragDirection,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
tracks: SceneTracks;
|
||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||
headerRef?: RefObject<HTMLElement | null>;
|
||||
zoomLevel: number;
|
||||
snappedTime: number;
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
}): DropTarget | null {
|
||||
const containerRect = tracksContainerRef.current?.getBoundingClientRect();
|
||||
const scrollContainer = tracksScrollRef.current;
|
||||
if (!containerRect || !scrollContainer) return null;
|
||||
|
||||
const sourceTrack = [...tracks.overlay, tracks.main, ...tracks.audio].find(
|
||||
({ id }) => id === trackId,
|
||||
);
|
||||
const movingElement = sourceTrack?.elements.find(
|
||||
({ id }) => id === elementId,
|
||||
);
|
||||
if (!movingElement) return null;
|
||||
|
||||
const elementDuration = movingElement.duration;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const scrollTop = scrollContainer.scrollTop;
|
||||
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
||||
const headerHeight = headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const mouseX = clientX - scrollContainerRect.left + scrollLeft;
|
||||
const mouseY = clientY - scrollContainerRect.top + scrollTop - headerHeight;
|
||||
|
||||
return computeDropTarget({
|
||||
elementType: movingElement.type,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks,
|
||||
playheadTime: snappedTime,
|
||||
isExternalDrop: false,
|
||||
elementDuration,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
startTimeOverride: snappedTime,
|
||||
excludeElementId: movingElement.id,
|
||||
verticalDragDirection,
|
||||
});
|
||||
}
|
||||
|
||||
interface StartDragParams
|
||||
extends Omit<
|
||||
ElementDragState,
|
||||
"isDragging" | "currentTime" | "currentMouseY"
|
||||
> {
|
||||
initialCurrentTime: number;
|
||||
initialCurrentMouseY: number;
|
||||
}
|
||||
|
||||
export function useElementInteraction({
|
||||
zoomLevel,
|
||||
timelineRef,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
|
|
@ -177,584 +29,65 @@ export function useElementInteraction({
|
|||
}: UseElementInteractionProps) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||
const {
|
||||
selectedElements,
|
||||
isElementSelected,
|
||||
selectElement,
|
||||
handleElementClick: handleSelectionClick,
|
||||
} = useElementSelection();
|
||||
const selection = useElementSelection();
|
||||
|
||||
const [dragState, setDragState] =
|
||||
useState<ElementDragState>(initialDragState);
|
||||
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
const pendingDragRef = useRef<PendingDragState | null>(null);
|
||||
const moveGroupRef = useRef<MoveGroup | null>(null);
|
||||
const newTrackIdsRef = useRef<string[]>([]);
|
||||
const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
|
||||
const lastMouseXRef = useRef(0);
|
||||
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const startDrag = useCallback(
|
||||
({
|
||||
elementId,
|
||||
dragElementIds,
|
||||
dragTimeOffsets,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startMouseY,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
initialCurrentTime,
|
||||
initialCurrentMouseY,
|
||||
}: StartDragParams) => {
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
elementId,
|
||||
dragElementIds,
|
||||
dragTimeOffsets,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startMouseY,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
currentTime: initialCurrentTime,
|
||||
currentMouseY: initialCurrentMouseY,
|
||||
});
|
||||
const deps: ElementInteractionDeps = {
|
||||
viewport: {
|
||||
getZoomLevel: () => zoomLevel,
|
||||
getTracksScrollEl: () => tracksScrollRef.current,
|
||||
getTracksContainerEl: () => tracksContainerRef.current,
|
||||
getHeaderEl: () => headerRef?.current ?? null,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
moveGroupRef.current = null;
|
||||
newTrackIdsRef.current = [];
|
||||
groupMoveResultRef.current = null;
|
||||
setDragState(initialDragState);
|
||||
setDragDropTarget(null);
|
||||
}, []);
|
||||
|
||||
const cancelCurrentDrag = useCallback(() => {
|
||||
pendingDragRef.current = null;
|
||||
mouseDownLocationRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
}, [endDrag, onSnapPointChange]);
|
||||
|
||||
const resolveGroupDragMove = useCallback(
|
||||
({
|
||||
group,
|
||||
snappedTime,
|
||||
dropTarget,
|
||||
}: {
|
||||
group: MoveGroup;
|
||||
snappedTime: number;
|
||||
dropTarget: DropTarget | null;
|
||||
}): GroupMoveResult | null => {
|
||||
if (!dropTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dropTarget.isNewTrack) {
|
||||
return resolveGroupMove({
|
||||
group,
|
||||
tracks: sceneTracks,
|
||||
anchorStartTime: snappedTime,
|
||||
target: {
|
||||
kind: "newTracks",
|
||||
anchorInsertIndex: dropTarget.trackIndex,
|
||||
newTrackIds: newTrackIdsRef.current,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const orderedTracks = [
|
||||
...sceneTracks.overlay,
|
||||
sceneTracks.main,
|
||||
...sceneTracks.audio,
|
||||
];
|
||||
const targetTrack = orderedTracks[dropTarget.trackIndex];
|
||||
if (!targetTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingTrackResult = resolveGroupMove({
|
||||
group,
|
||||
tracks: sceneTracks,
|
||||
anchorStartTime: snappedTime,
|
||||
target: {
|
||||
kind: "existingTrack",
|
||||
anchorTargetTrackId: targetTrack.id,
|
||||
},
|
||||
});
|
||||
if (existingTrackResult) {
|
||||
return existingTrackResult;
|
||||
}
|
||||
|
||||
return resolveGroupMove({
|
||||
group,
|
||||
tracks: sceneTracks,
|
||||
anchorStartTime: snappedTime,
|
||||
target: {
|
||||
kind: "newTracks",
|
||||
anchorInsertIndex: dropTarget.trackIndex,
|
||||
newTrackIds: newTrackIdsRef.current,
|
||||
},
|
||||
});
|
||||
input: {
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
},
|
||||
[sceneTracks],
|
||||
);
|
||||
scene: {
|
||||
getTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getActiveFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
},
|
||||
selection: {
|
||||
getSelected: () => selection.selectedElements,
|
||||
isSelected: selection.isElementSelected,
|
||||
select: selection.selectElement,
|
||||
handleClick: selection.handleElementClick,
|
||||
clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(),
|
||||
},
|
||||
playback: {
|
||||
getCurrentTime: () => editor.playback.getCurrentTime(),
|
||||
},
|
||||
timeline: {
|
||||
moveElements: (args) => editor.timeline.moveElements(args),
|
||||
},
|
||||
snap: {
|
||||
isEnabled: () => snappingEnabled,
|
||||
onChange: onSnapPointChange,
|
||||
},
|
||||
};
|
||||
|
||||
const depsRef = useRef<ElementInteractionDeps>(deps);
|
||||
depsRef.current = deps;
|
||||
|
||||
const controllerRef = useRef<ElementInteractionController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new ElementInteractionController({
|
||||
depsRef: depsRef as ElementInteractionDepsRef,
|
||||
});
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
if (!controller.isActive) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller.isActive, controller]);
|
||||
|
||||
return registerCanceller({ fn: cancelCurrentDrag });
|
||||
}, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
|
||||
|
||||
const getDragSnapResult = useCallback(
|
||||
({
|
||||
frameSnappedTime,
|
||||
group,
|
||||
}: {
|
||||
frameSnappedTime: number;
|
||||
group: MoveGroup | null;
|
||||
}) => {
|
||||
if (!group || !snappingEnabled || isShiftHeldRef.current) {
|
||||
return { snappedTime: frameSnappedTime, snapPoint: null };
|
||||
}
|
||||
|
||||
const groupSnap = snapGroupEdges({
|
||||
group,
|
||||
anchorStartTime: frameSnappedTime,
|
||||
tracks: sceneTracks,
|
||||
playheadTime: editor.playback.getCurrentTime(),
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
return {
|
||||
snappedTime: groupSnap.snappedAnchorStartTime,
|
||||
snapPoint: groupSnap.snapPoint,
|
||||
};
|
||||
},
|
||||
[snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
|
||||
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
||||
let startedDragThisEvent = false;
|
||||
const timeline = timelineRef.current;
|
||||
const scrollContainer = tracksScrollRef.current;
|
||||
if (!timeline || !scrollContainer) return;
|
||||
lastMouseXRef.current = clientX;
|
||||
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
|
||||
const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
|
||||
if (
|
||||
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
||||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const adjustedTime = Math.max(
|
||||
0,
|
||||
mouseTime - pendingDragRef.current.clickOffsetTime,
|
||||
);
|
||||
const snappedTime =
|
||||
roundToFrame({
|
||||
time: adjustedTime,
|
||||
rate: activeProject.settings.fps,
|
||||
}) ?? adjustedTime;
|
||||
const moveGroup = buildMoveGroup({
|
||||
anchorRef: {
|
||||
trackId: pendingDragRef.current.trackId,
|
||||
elementId: pendingDragRef.current.elementId,
|
||||
},
|
||||
selectedElements: pendingDragRef.current.selectedElements,
|
||||
tracks: sceneTracks,
|
||||
});
|
||||
if (!moveGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveGroupRef.current = moveGroup;
|
||||
newTrackIdsRef.current = moveGroup.members.map(() => generateUUID());
|
||||
const dragTimeOffsets: Record<string, number> = {};
|
||||
for (const member of moveGroup.members) {
|
||||
dragTimeOffsets[member.elementId] = member.timeOffset;
|
||||
}
|
||||
const {
|
||||
snappedTime: initialSnappedTime,
|
||||
snapPoint: initialSnapPoint,
|
||||
} = getDragSnapResult({
|
||||
frameSnappedTime: snappedTime,
|
||||
group: moveGroup,
|
||||
});
|
||||
const verticalDragDirection = getVerticalDragDirection({
|
||||
startMouseY: pendingDragRef.current.startMouseY,
|
||||
currentMouseY: clientY,
|
||||
});
|
||||
const anchorDropTarget = getDragDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
elementId: pendingDragRef.current.elementId,
|
||||
trackId: pendingDragRef.current.trackId,
|
||||
tracks: sceneTracks,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
zoomLevel,
|
||||
snappedTime: initialSnappedTime,
|
||||
verticalDragDirection,
|
||||
});
|
||||
const nextGroupMoveResult =
|
||||
anchorDropTarget != null
|
||||
? resolveGroupDragMove({
|
||||
group: moveGroup,
|
||||
snappedTime: initialSnappedTime,
|
||||
dropTarget: anchorDropTarget,
|
||||
})
|
||||
: null;
|
||||
groupMoveResultRef.current = nextGroupMoveResult;
|
||||
setDragDropTarget(
|
||||
anchorDropTarget &&
|
||||
(anchorDropTarget.isNewTrack || !nextGroupMoveResult)
|
||||
? {
|
||||
...anchorDropTarget,
|
||||
isNewTrack: true,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
startDrag({
|
||||
...pendingDragRef.current,
|
||||
dragElementIds: moveGroup.members.map((member) => member.elementId),
|
||||
dragTimeOffsets,
|
||||
initialCurrentTime: initialSnappedTime,
|
||||
initialCurrentMouseY: clientY,
|
||||
});
|
||||
onSnapPointChange?.(initialSnapPoint);
|
||||
startedDragThisEvent = true;
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (startedDragThisEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const alreadySelected = isElementSelected({
|
||||
trackId: dragState.trackId,
|
||||
elementId: dragState.elementId,
|
||||
});
|
||||
if (!alreadySelected) {
|
||||
selectElement({
|
||||
trackId: dragState.trackId,
|
||||
elementId: dragState.elementId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
if (!activeProject) return;
|
||||
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
const mouseTime = getMouseTimeFromClientX({
|
||||
clientX,
|
||||
containerRect: scrollContainer.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
});
|
||||
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
|
||||
const fps = activeProject.settings.fps;
|
||||
const frameSnappedTime =
|
||||
roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime;
|
||||
|
||||
const moveGroup = moveGroupRef.current;
|
||||
const { snappedTime, snapPoint } = getDragSnapResult({
|
||||
frameSnappedTime,
|
||||
group: moveGroup,
|
||||
});
|
||||
setDragState((previousDragState) => ({
|
||||
...previousDragState,
|
||||
currentTime: snappedTime,
|
||||
currentMouseY: clientY,
|
||||
}));
|
||||
onSnapPointChange?.(snapPoint);
|
||||
|
||||
if (dragState.elementId && dragState.trackId) {
|
||||
const verticalDragDirection = getVerticalDragDirection({
|
||||
startMouseY: dragState.startMouseY,
|
||||
currentMouseY: clientY,
|
||||
});
|
||||
const anchorDropTarget = getDragDropTarget({
|
||||
clientX,
|
||||
clientY,
|
||||
elementId: dragState.elementId,
|
||||
trackId: dragState.trackId,
|
||||
tracks: sceneTracks,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
zoomLevel,
|
||||
snappedTime,
|
||||
verticalDragDirection,
|
||||
});
|
||||
const nextGroupMoveResult =
|
||||
moveGroup && anchorDropTarget
|
||||
? resolveGroupDragMove({
|
||||
group: moveGroup,
|
||||
snappedTime,
|
||||
dropTarget: anchorDropTarget,
|
||||
})
|
||||
: null;
|
||||
groupMoveResultRef.current = nextGroupMoveResult;
|
||||
setDragDropTarget(
|
||||
anchorDropTarget &&
|
||||
(anchorDropTarget.isNewTrack || !nextGroupMoveResult)
|
||||
? {
|
||||
...anchorDropTarget,
|
||||
isNewTrack: true,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
return () => document.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.clickOffsetTime,
|
||||
dragState.elementId,
|
||||
dragState.startMouseY,
|
||||
dragState.trackId,
|
||||
zoomLevel,
|
||||
isElementSelected,
|
||||
selectElement,
|
||||
editor.project,
|
||||
timelineRef,
|
||||
tracksScrollRef,
|
||||
tracksContainerRef,
|
||||
headerRef,
|
||||
isPendingDrag,
|
||||
startDrag,
|
||||
getDragSnapResult,
|
||||
resolveGroupDragMove,
|
||||
sceneTracks,
|
||||
onSnapPointChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = ({ clientX, clientY }: MouseEvent) => {
|
||||
if (!dragState.elementId || !dragState.trackId) return;
|
||||
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(clientY - mouseDownLocationRef.current.y);
|
||||
if (
|
||||
deltaX <= TIMELINE_DRAG_THRESHOLD_PX &&
|
||||
deltaY <= TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
mouseDownLocationRef.current = null;
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const moveGroup = moveGroupRef.current;
|
||||
if (!moveGroup) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupMoveResult = groupMoveResultRef.current;
|
||||
if (!groupMoveResult) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const didMove = groupMoveResult.moves.some((move) => {
|
||||
const currentMember = moveGroup.members.find(
|
||||
(member) => member.elementId === move.elementId,
|
||||
);
|
||||
const originalStartTime =
|
||||
dragState.startElementTime + (currentMember?.timeOffset ?? 0);
|
||||
return (
|
||||
currentMember?.trackId !== move.targetTrackId ||
|
||||
originalStartTime !== move.newStartTime
|
||||
);
|
||||
});
|
||||
if (!didMove && groupMoveResult.createTracks.length === 0) {
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.moveElements({
|
||||
moves: groupMoveResult.moves,
|
||||
createTracks: groupMoveResult.createTracks,
|
||||
});
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.elementId,
|
||||
dragState.startElementTime,
|
||||
dragState.trackId,
|
||||
endDrag,
|
||||
onSnapPointChange,
|
||||
editor.timeline,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingDrag) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [isPendingDrag, onSnapPointChange]);
|
||||
|
||||
const handleElementMouseDown = useCallback(
|
||||
({
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
|
||||
|
||||
// right-click: don't stop propagation so ContextMenu can open
|
||||
if (isRightClick) {
|
||||
const alreadySelected = isElementSelected({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
if (!alreadySelected) {
|
||||
handleSelectionClick({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
isMultiKey: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
|
||||
|
||||
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
|
||||
|
||||
if (isMultiSelect) {
|
||||
handleSelectionClick({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
isMultiKey: true,
|
||||
});
|
||||
}
|
||||
|
||||
const clickOffsetTime = getClickOffsetTime({
|
||||
clientX: event.clientX,
|
||||
elementRect: event.currentTarget.getBoundingClientRect(),
|
||||
zoomLevel,
|
||||
});
|
||||
const elementRef = {
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
};
|
||||
const pendingSelectedElements = isElementSelected(elementRef)
|
||||
? selectedElements
|
||||
: [elementRef];
|
||||
pendingDragRef.current = {
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
selectedElements: pendingSelectedElements,
|
||||
startMouseX: event.clientX,
|
||||
startMouseY: event.clientY,
|
||||
startElementTime: element.startTime,
|
||||
clickOffsetTime,
|
||||
};
|
||||
setIsPendingDrag(true);
|
||||
},
|
||||
[zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
|
||||
);
|
||||
|
||||
const handleElementClick = useCallback(
|
||||
({
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
|
||||
if (mouseDownLocationRef.current) {
|
||||
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
|
||||
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
|
||||
if (
|
||||
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
||||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
||||
) {
|
||||
mouseDownLocationRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// modifier keys already handled in mousedown
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
||||
|
||||
const alreadySelected = isElementSelected({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
});
|
||||
if (!alreadySelected || selectedElements.length > 1) {
|
||||
selectElement({ trackId: track.id, elementId: element.id });
|
||||
return;
|
||||
}
|
||||
|
||||
editor.selection.clearKeyframeSelection();
|
||||
},
|
||||
[editor.selection, isElementSelected, selectElement, selectedElements],
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
dragDropTarget,
|
||||
handleElementMouseDown,
|
||||
handleElementClick,
|
||||
lastMouseXRef,
|
||||
dragView: controller.view,
|
||||
handleElementMouseDown: controller.onElementMouseDown,
|
||||
handleElementClick: controller.onElementClick,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,15 @@
|
|||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { getKeyframeById } from "@/animation";
|
||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||
import { roundToFrame, snappedSeekTime } from "opencut-wasm";
|
||||
import { timelineTimeToSnappedPixels } from "@/timeline";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||
import { RetimeKeyframeCommand } from "@/commands/timeline/element/keyframes/retime-keyframe";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import type { SelectedKeyframeRef } from "@/animation/types";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import type { Command } from "@/commands/base-command";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
export interface KeyframeDragState {
|
||||
isDragging: boolean;
|
||||
draggingKeyframeIds: Set<string>;
|
||||
deltaTime: number;
|
||||
}
|
||||
import {
|
||||
KeyframeDragController,
|
||||
type KeyframeDragConfig,
|
||||
type KeyframeDragState,
|
||||
} from "@/timeline/controllers/keyframe-drag-controller";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
|
||||
const initialDragState: KeyframeDragState = {
|
||||
isDragging: false,
|
||||
draggingKeyframeIds: new Set(),
|
||||
deltaTime: 0,
|
||||
};
|
||||
|
||||
interface PendingKeyframeDrag {
|
||||
keyframeRefs: SelectedKeyframeRef[];
|
||||
startMouseX: number;
|
||||
}
|
||||
export type { KeyframeDragState };
|
||||
|
||||
export function useKeyframeDrag({
|
||||
zoomLevel,
|
||||
|
|
@ -54,275 +29,44 @@ export function useKeyframeDrag({
|
|||
selectKeyframeRange,
|
||||
} = useKeyframeSelection();
|
||||
|
||||
const [dragState, setDragState] =
|
||||
useState<KeyframeDragState>(initialDragState);
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
|
||||
const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
|
||||
const mouseDownXRef = useRef<number | null>(null);
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
const fps = activeProject.settings.fps;
|
||||
|
||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState(initialDragState);
|
||||
}, []);
|
||||
|
||||
const cancelDrag = useCallback(() => {
|
||||
pendingDragRef.current = null;
|
||||
mouseDownXRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
endDrag();
|
||||
}, [endDrag]);
|
||||
|
||||
const commitDrag = useCallback(
|
||||
({
|
||||
keyframeRefs,
|
||||
deltaTime,
|
||||
}: {
|
||||
keyframeRefs: SelectedKeyframeRef[];
|
||||
deltaTime: number;
|
||||
}) => {
|
||||
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
||||
const keyframe = getKeyframeById({
|
||||
animations: element.animations,
|
||||
propertyPath: keyframeRef.propertyPath,
|
||||
keyframeId: keyframeRef.keyframeId,
|
||||
});
|
||||
if (!keyframe) return [];
|
||||
const nextTime = Math.max(
|
||||
0,
|
||||
Math.min(element.duration, keyframe.time + deltaTime),
|
||||
);
|
||||
return [
|
||||
new RetimeKeyframeCommand({
|
||||
trackId: keyframeRef.trackId,
|
||||
elementId: keyframeRef.elementId,
|
||||
propertyPath: keyframeRef.propertyPath,
|
||||
keyframeId: keyframeRef.keyframeId,
|
||||
nextTime,
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
if (commands.length === 1) {
|
||||
editor.command.execute({ command: commands[0] });
|
||||
} else if (commands.length > 1) {
|
||||
editor.command.execute({ command: new BatchCommand(commands) });
|
||||
}
|
||||
},
|
||||
[editor.command, element],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
|
||||
return registerCanceller({ fn: cancelDrag });
|
||||
}, [dragState.isDragging, isPendingDrag, cancelDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
|
||||
const handleMouseMove = ({ clientX }: MouseEvent) => {
|
||||
if (isPendingDrag && pendingDragRef.current) {
|
||||
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
|
||||
if (deltaX <= TIMELINE_DRAG_THRESHOLD_PX) return;
|
||||
|
||||
const pending = pendingDragRef.current;
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
draggingKeyframeIds: new Set(
|
||||
pending.keyframeRefs.map((keyframe) => keyframe.keyframeId),
|
||||
),
|
||||
deltaTime: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const startX = mouseDownXRef.current ?? clientX;
|
||||
const rawDelta = Math.round(
|
||||
((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND,
|
||||
);
|
||||
const snappedDelta =
|
||||
roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta;
|
||||
|
||||
setDragState((previous) => ({ ...previous, deltaTime: snappedDelta }));
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
return () => document.removeEventListener("mousemove", handleMouseMove);
|
||||
}, [dragState.isDragging, isPendingDrag, pixelsPerSecond, fps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging) return;
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const draggingRefs = selectedKeyframes.filter(
|
||||
(keyframe) =>
|
||||
keyframe.elementId === element.id &&
|
||||
dragState.draggingKeyframeIds.has(keyframe.keyframeId),
|
||||
);
|
||||
|
||||
if (draggingRefs.length > 0 && dragState.deltaTime !== 0) {
|
||||
commitDrag({
|
||||
keyframeRefs: draggingRefs,
|
||||
deltaTime: dragState.deltaTime,
|
||||
});
|
||||
}
|
||||
|
||||
endDrag();
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [
|
||||
dragState.isDragging,
|
||||
dragState.draggingKeyframeIds,
|
||||
dragState.deltaTime,
|
||||
const config: KeyframeDragConfig = {
|
||||
zoomLevel,
|
||||
element,
|
||||
displayedStartTime,
|
||||
getFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
selectedKeyframes,
|
||||
element.id,
|
||||
commitDrag,
|
||||
endDrag,
|
||||
]);
|
||||
isKeyframeSelected,
|
||||
setKeyframeSelection,
|
||||
toggleKeyframeSelection,
|
||||
selectKeyframeRange,
|
||||
executeCommand: (command) => editor.command.execute({ command }),
|
||||
seek: (args) => editor.playback.seek(args),
|
||||
getTotalDuration: () => editor.timeline.getTotalDuration(),
|
||||
};
|
||||
|
||||
const configRef = useRef<KeyframeDragConfig>(config);
|
||||
configRef.current = config;
|
||||
|
||||
const controllerRef = useRef<KeyframeDragController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new KeyframeDragController({ configRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPendingDrag) return;
|
||||
if (!controller.isActive) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller.isActive, controller]);
|
||||
|
||||
const handleMouseUp = () => {
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [isPendingDrag]);
|
||||
|
||||
const handleKeyframeMouseDown = useCallback(
|
||||
({
|
||||
event,
|
||||
keyframes,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
mouseDownXRef.current = event.clientX;
|
||||
|
||||
const anySelected = keyframes.some((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
|
||||
const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
|
||||
if (!anySelected && !isModifierKey) {
|
||||
setKeyframeSelection({ keyframes });
|
||||
}
|
||||
|
||||
const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
|
||||
|
||||
pendingDragRef.current = {
|
||||
keyframeRefs: keyframeRefsToTrack,
|
||||
startMouseX: event.clientX,
|
||||
};
|
||||
setIsPendingDrag(true);
|
||||
},
|
||||
[isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
|
||||
);
|
||||
|
||||
const handleKeyframeClick = useCallback(
|
||||
({
|
||||
event,
|
||||
keyframes,
|
||||
orderedKeyframes,
|
||||
indicatorTime,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
indicatorTime: number;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const wasDrag =
|
||||
mouseDownXRef.current !== null &&
|
||||
Math.abs(event.clientX - mouseDownXRef.current) >
|
||||
TIMELINE_DRAG_THRESHOLD_PX;
|
||||
mouseDownXRef.current = null;
|
||||
|
||||
if (wasDrag) return;
|
||||
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const seekTime =
|
||||
snappedSeekTime({
|
||||
time: displayedStartTime + indicatorTime,
|
||||
duration,
|
||||
rate: fps,
|
||||
}) ?? displayedStartTime + indicatorTime;
|
||||
editor.playback.seek({ time: seekTime });
|
||||
|
||||
if (event.shiftKey) {
|
||||
selectKeyframeRange({
|
||||
orderedKeyframes,
|
||||
targetKeyframes: keyframes,
|
||||
isAdditive: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toggleKeyframeSelection({
|
||||
keyframes,
|
||||
isMultiKey: event.metaKey || event.ctrlKey,
|
||||
});
|
||||
},
|
||||
[
|
||||
toggleKeyframeSelection,
|
||||
selectKeyframeRange,
|
||||
editor,
|
||||
displayedStartTime,
|
||||
fps,
|
||||
],
|
||||
);
|
||||
|
||||
const getVisualOffsetPx = useCallback(
|
||||
({
|
||||
indicatorTime,
|
||||
indicatorOffsetPx,
|
||||
isBeingDragged,
|
||||
displayedStartTime,
|
||||
elementLeft,
|
||||
}: {
|
||||
indicatorTime: number;
|
||||
indicatorOffsetPx: number;
|
||||
isBeingDragged: boolean;
|
||||
displayedStartTime: number;
|
||||
elementLeft: number;
|
||||
}): number => {
|
||||
if (!isBeingDragged) return indicatorOffsetPx;
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(element.duration, indicatorTime + dragState.deltaTime),
|
||||
);
|
||||
return (
|
||||
timelineTimeToSnappedPixels({
|
||||
time: displayedStartTime + clampedTime,
|
||||
zoomLevel,
|
||||
}) - elementLeft
|
||||
);
|
||||
},
|
||||
[dragState.deltaTime, element.duration, zoomLevel],
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
keyframeDragState: dragState,
|
||||
handleKeyframeMouseDown,
|
||||
handleKeyframeClick,
|
||||
getVisualOffsetPx,
|
||||
keyframeDragState: controller.keyframeDragState,
|
||||
handleKeyframeMouseDown: controller.onKeyframeMouseDown,
|
||||
handleKeyframeClick: controller.onKeyframeClick,
|
||||
getVisualOffsetPx: controller.getVisualOffsetPx,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,9 @@
|
|||
import { useState, useCallback, type RefObject } from "react";
|
||||
import { useEffect, useReducer, useRef, type RefObject } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { processMediaAssets } from "@/media/processing";
|
||||
import { toast } from "sonner";
|
||||
import { showMediaUploadToast } from "@/media/upload-toast";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import {
|
||||
buildTextElement,
|
||||
buildGraphicElement,
|
||||
buildStickerElement,
|
||||
buildElementFromMedia,
|
||||
buildEffectElement,
|
||||
} from "@/timeline/element-utils";
|
||||
import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import { computeDropTarget } from "@/timeline/components/drop-target";
|
||||
import { getDragData, hasDragData } from "@/timeline/drag-data";
|
||||
import type { TrackType, DropTarget, ElementType } from "@/timeline";
|
||||
import type {
|
||||
MediaDragData,
|
||||
GraphicDragData,
|
||||
StickerDragData,
|
||||
EffectDragData,
|
||||
} from "@/timeline/drag";
|
||||
DragDropController,
|
||||
type DragDropConfig,
|
||||
} from "@/timeline/controllers/drag-drop-controller";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
|
|
@ -40,609 +19,47 @@ export function useTimelineDragDrop({
|
|||
zoomLevel,
|
||||
}: UseTimelineDragDropProps) {
|
||||
const editor = useEditor();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [dragElementType, setElementType] = useState<ElementType | null>(null);
|
||||
|
||||
const getSnappedTime = useCallback(
|
||||
({ time }: { time: number }) => {
|
||||
const projectFps = editor.project.getActive().settings.fps;
|
||||
return roundToFrame({ time, rate: projectFps }) ?? time;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
const config: DragDropConfig = {
|
||||
zoomLevel,
|
||||
getContainerEl: () => containerRef.current,
|
||||
getHeaderEl: () => headerRef?.current ?? null,
|
||||
getTracksScrollEl: () => tracksScrollRef?.current ?? null,
|
||||
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
getActiveProjectId: () =>
|
||||
editor.project.getActiveOrNull()?.metadata.id ?? null,
|
||||
getSceneTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
|
||||
getMediaAssets: () => editor.media.getAssets(),
|
||||
dragSource: editor.timeline.dragSource,
|
||||
addMediaAsset: (args) => editor.media.addMediaAsset(args),
|
||||
executeCommand: (command) => editor.command.execute({ command }),
|
||||
insertElement: (args) => editor.timeline.insertElement(args),
|
||||
addClipEffect: (args) => editor.timeline.addClipEffect(args),
|
||||
};
|
||||
|
||||
const getElementType = useCallback(
|
||||
({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
|
||||
const dragData = getDragData({ dataTransfer });
|
||||
if (!dragData) return null;
|
||||
const configRef = useRef<DragDropConfig>(config);
|
||||
configRef.current = config;
|
||||
|
||||
if (dragData.type === "text") return "text";
|
||||
if (dragData.type === "graphic") return "graphic";
|
||||
if (dragData.type === "sticker") return "sticker";
|
||||
if (dragData.type === "effect") return "effect";
|
||||
if (dragData.type === "media") {
|
||||
return dragData.mediaType;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const controllerRef = useRef<DragDropController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new DragDropController({ configRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const getElementDuration = useCallback(
|
||||
({
|
||||
elementType,
|
||||
mediaId,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
mediaId?: string;
|
||||
}): number => {
|
||||
if (
|
||||
elementType === "text" ||
|
||||
elementType === "graphic" ||
|
||||
elementType === "sticker" ||
|
||||
elementType === "effect"
|
||||
) {
|
||||
return DEFAULT_NEW_ELEMENT_DURATION;
|
||||
}
|
||||
if (mediaId) {
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const media = mediaAssets.find((m) => m.id === mediaId);
|
||||
return media?.duration != null
|
||||
? Math.round(media.duration * TICKS_PER_SECOND)
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
}
|
||||
return DEFAULT_NEW_ELEMENT_DURATION;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const scrollContainer = tracksScrollRef?.current;
|
||||
const referenceRect =
|
||||
scrollContainer?.getBoundingClientRect() ??
|
||||
containerRef.current?.getBoundingClientRect();
|
||||
if (!referenceRect) return;
|
||||
|
||||
const headerHeight =
|
||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
const hasFiles = e.dataTransfer.types.includes("Files");
|
||||
const isExternal =
|
||||
hasFiles && !hasDragData({ dataTransfer: e.dataTransfer });
|
||||
|
||||
const elementType = getElementType({ dataTransfer: e.dataTransfer });
|
||||
|
||||
if (!elementType && hasFiles && isExternal) {
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!elementType) return;
|
||||
|
||||
setElementType(elementType);
|
||||
|
||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
||||
const duration = getElementDuration({
|
||||
elementType,
|
||||
mediaId: dragData?.type === "media" ? dragData.id : undefined,
|
||||
});
|
||||
|
||||
const mouseX = e.clientX - referenceRect.left + scrollLeft;
|
||||
const mouseY = e.clientY - referenceRect.top + scrollTop - headerHeight;
|
||||
|
||||
const targetElementTypes =
|
||||
dragData?.type === "effect"
|
||||
? (dragData as EffectDragData).targetElementTypes
|
||||
: dragData?.type === "media"
|
||||
? (dragData as MediaDragData).targetElementTypes
|
||||
: undefined;
|
||||
|
||||
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const target = computeDropTarget({
|
||||
elementType,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks: sceneTracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: isExternal,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
targetElementTypes,
|
||||
});
|
||||
|
||||
target.xPosition = getSnappedTime({ time: target.xPosition });
|
||||
|
||||
setDropTarget(target);
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
},
|
||||
[
|
||||
containerRef,
|
||||
headerRef,
|
||||
tracksScrollRef,
|
||||
zoomLevel,
|
||||
getElementType,
|
||||
getElementDuration,
|
||||
getSnappedTime,
|
||||
editor,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const rect = containerRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const { clientX, clientY } = e;
|
||||
if (
|
||||
clientX < rect.left ||
|
||||
clientX > rect.right ||
|
||||
clientY < rect.top ||
|
||||
clientY > rect.bottom
|
||||
) {
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[containerRef],
|
||||
);
|
||||
|
||||
const executeTextDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: { name?: string; content?: string };
|
||||
}) => {
|
||||
const element = buildTextElement({
|
||||
raw: {
|
||||
name: dragData.name ?? "",
|
||||
content: dragData.content ?? "",
|
||||
},
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand("text", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = [
|
||||
...editor.scenes.getActiveScene().tracks.overlay,
|
||||
editor.scenes.getActiveScene().tracks.main,
|
||||
...editor.scenes.getActiveScene().tracks.audio,
|
||||
];
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const executeStickerDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: StickerDragData;
|
||||
}) => {
|
||||
const element = buildStickerElement({
|
||||
stickerId: dragData.stickerId,
|
||||
name: dragData.name,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = [
|
||||
...editor.scenes.getActiveScene().tracks.overlay,
|
||||
editor.scenes.getActiveScene().tracks.main,
|
||||
...editor.scenes.getActiveScene().tracks.audio,
|
||||
];
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const executeGraphicDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: GraphicDragData;
|
||||
}) => {
|
||||
const element = buildGraphicElement({
|
||||
definitionId: dragData.definitionId,
|
||||
name: dragData.name,
|
||||
startTime: target.xPosition,
|
||||
params: dragData.params,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand("graphic", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = [
|
||||
...editor.scenes.getActiveScene().tracks.overlay,
|
||||
editor.scenes.getActiveScene().tracks.main,
|
||||
...editor.scenes.getActiveScene().tracks.audio,
|
||||
];
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const executeMediaDrop = useCallback(
|
||||
({ target, dragData }: { target: DropTarget; dragData: MediaDragData }) => {
|
||||
if (target.targetElement) {
|
||||
toast.info("Replace media source is coming soon!");
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const mediaAsset = mediaAssets.find((m) => m.id === dragData.id);
|
||||
if (!mediaAsset) return;
|
||||
|
||||
const trackType: TrackType =
|
||||
dragData.mediaType === "audio" ? "audio" : "video";
|
||||
|
||||
const duration =
|
||||
mediaAsset.duration != null
|
||||
? Math.round(mediaAsset.duration * TICKS_PER_SECOND)
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: mediaAsset.id,
|
||||
mediaType: mediaAsset.type,
|
||||
name: mediaAsset.name,
|
||||
duration,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = [
|
||||
...editor.scenes.getActiveScene().tracks.overlay,
|
||||
editor.scenes.getActiveScene().tracks.main,
|
||||
...editor.scenes.getActiveScene().tracks.audio,
|
||||
];
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track) return;
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId: track.id },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const executeEffectDrop = useCallback(
|
||||
({
|
||||
target,
|
||||
dragData,
|
||||
}: {
|
||||
target: DropTarget;
|
||||
dragData: EffectDragData;
|
||||
}) => {
|
||||
if (target.targetElement) {
|
||||
editor.timeline.addClipEffect({
|
||||
trackId: target.targetElement.trackId,
|
||||
elementId: target.targetElement.elementId,
|
||||
effectType: dragData.effectType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tracks = [
|
||||
...editor.scenes.getActiveScene().tracks.overlay,
|
||||
editor.scenes.getActiveScene().tracks.main,
|
||||
...editor.scenes.getActiveScene().tracks.audio,
|
||||
];
|
||||
const effectTrack = tracks.find((t) => t.type === "effect");
|
||||
let trackId: string;
|
||||
|
||||
if (effectTrack) {
|
||||
trackId = effectTrack.id;
|
||||
} else if (target.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand("effect", target.trackIndex);
|
||||
const insertCmd = new InsertElementCommand({
|
||||
element: buildEffectElement({
|
||||
effectType: dragData.effectType,
|
||||
startTime: target.xPosition,
|
||||
}),
|
||||
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||
});
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([addTrackCmd, insertCmd]),
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
const track = tracks[target.trackIndex];
|
||||
if (!track || track.type !== "effect") return;
|
||||
trackId = track.id;
|
||||
}
|
||||
|
||||
const element = buildEffectElement({
|
||||
effectType: dragData.effectType,
|
||||
startTime: target.xPosition,
|
||||
});
|
||||
|
||||
editor.timeline.insertElement({
|
||||
placement: { mode: "explicit", trackId },
|
||||
element,
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const executeFileDrop = useCallback(
|
||||
async ({
|
||||
files,
|
||||
mouseX,
|
||||
mouseY,
|
||||
}: {
|
||||
files: File[];
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
}) => {
|
||||
const activeProject = editor.project.getActiveOrNull();
|
||||
if (!activeProject) return;
|
||||
|
||||
await showMediaUploadToast({
|
||||
filesCount: files.length,
|
||||
promise: async () => {
|
||||
const processedAssets = await processMediaAssets({ files });
|
||||
const projectId = activeProject.metadata.id;
|
||||
|
||||
for (const asset of processedAssets) {
|
||||
const createdAsset = await editor.media.addMediaAsset({
|
||||
projectId,
|
||||
asset,
|
||||
});
|
||||
if (!createdAsset) continue;
|
||||
|
||||
const duration =
|
||||
createdAsset.duration != null
|
||||
? Math.round(createdAsset.duration * TICKS_PER_SECOND)
|
||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
||||
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const reuseMainTrackId =
|
||||
createdAsset.type !== "audio" &&
|
||||
sceneTracks.overlay.length === 0 &&
|
||||
sceneTracks.audio.length === 0 &&
|
||||
sceneTracks.main.elements.length === 0
|
||||
? sceneTracks.main.id
|
||||
: null;
|
||||
const dropTarget = reuseMainTrackId
|
||||
? null
|
||||
: computeDropTarget({
|
||||
elementType: createdAsset.type,
|
||||
mouseX,
|
||||
mouseY,
|
||||
tracks: sceneTracks,
|
||||
playheadTime: currentTime,
|
||||
isExternalDrop: true,
|
||||
elementDuration: duration,
|
||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const trackType: TrackType =
|
||||
createdAsset.type === "audio" ? "audio" : "video";
|
||||
|
||||
const startTime = dropTarget?.xPosition ?? currentTime;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: createdAsset.id,
|
||||
mediaType: createdAsset.type,
|
||||
name: createdAsset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
|
||||
if (reuseMainTrackId) {
|
||||
editor.command.execute({
|
||||
command: new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId: reuseMainTrackId },
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
if (!dropTarget) continue;
|
||||
if (dropTarget.isNewTrack) {
|
||||
const addTrackCmd = new AddTrackCommand(
|
||||
trackType,
|
||||
dropTarget.trackIndex,
|
||||
);
|
||||
editor.command.execute({
|
||||
command: new BatchCommand([
|
||||
addTrackCmd,
|
||||
new InsertElementCommand({
|
||||
element,
|
||||
placement: {
|
||||
mode: "explicit",
|
||||
trackId: addTrackCmd.getTrackId(),
|
||||
},
|
||||
}),
|
||||
]),
|
||||
});
|
||||
} else {
|
||||
const trackId = [
|
||||
...sceneTracks.overlay,
|
||||
sceneTracks.main,
|
||||
...sceneTracks.audio,
|
||||
][dropTarget.trackIndex]?.id;
|
||||
if (!trackId) continue;
|
||||
editor.command.execute({
|
||||
command: new InsertElementCommand({
|
||||
element,
|
||||
placement: { mode: "explicit", trackId },
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uploadedCount: processedAssets.length,
|
||||
assetNames: processedAssets.map((asset) => asset.name),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
[editor, zoomLevel],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
||||
|
||||
if (!hasAsset && !hasFiles) return;
|
||||
|
||||
const currentTarget = dropTarget;
|
||||
setIsDragOver(false);
|
||||
setDropTarget(null);
|
||||
setElementType(null);
|
||||
|
||||
try {
|
||||
if (hasAsset) {
|
||||
if (!currentTarget) return;
|
||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
||||
if (!dragData) return;
|
||||
|
||||
if (dragData.type === "text") {
|
||||
executeTextDrop({ target: currentTarget, dragData });
|
||||
} else if (dragData.type === "graphic") {
|
||||
executeGraphicDrop({
|
||||
target: currentTarget,
|
||||
dragData: dragData as GraphicDragData,
|
||||
});
|
||||
} else if (dragData.type === "sticker") {
|
||||
executeStickerDrop({ target: currentTarget, dragData });
|
||||
} else if (dragData.type === "effect") {
|
||||
executeEffectDrop({
|
||||
target: currentTarget,
|
||||
dragData: dragData as EffectDragData,
|
||||
});
|
||||
} else {
|
||||
executeMediaDrop({ target: currentTarget, dragData });
|
||||
}
|
||||
} else if (hasFiles) {
|
||||
const scrollContainer = tracksScrollRef?.current;
|
||||
const referenceRect =
|
||||
scrollContainer?.getBoundingClientRect() ??
|
||||
containerRef.current?.getBoundingClientRect();
|
||||
if (!referenceRect) return;
|
||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
const mouseX = e.clientX - referenceRect.left + scrollLeft;
|
||||
const headerHeight =
|
||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
||||
const mouseY =
|
||||
e.clientY - referenceRect.top + scrollTop - headerHeight;
|
||||
await executeFileDrop({
|
||||
files: Array.from(e.dataTransfer.files),
|
||||
mouseX,
|
||||
mouseY,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to process drop:", err);
|
||||
}
|
||||
},
|
||||
[
|
||||
dropTarget,
|
||||
executeTextDrop,
|
||||
executeGraphicDrop,
|
||||
executeStickerDrop,
|
||||
executeMediaDrop,
|
||||
executeEffectDrop,
|
||||
executeFileDrop,
|
||||
containerRef,
|
||||
headerRef,
|
||||
tracksScrollRef,
|
||||
],
|
||||
);
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dropTarget,
|
||||
dragElementType,
|
||||
isDragOver: controller.isDragOver,
|
||||
dropTarget: controller.dropTarget,
|
||||
dragElementType: controller.dragElementType,
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
onDragEnter: controller.onDragEnter,
|
||||
onDragOver: controller.onDragOver,
|
||||
onDragLeave: controller.onDragLeave,
|
||||
onDrop: controller.onDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,12 @@
|
|||
import { snappedSeekTime } from "opencut-wasm";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
||||
import { timelineTimeToPixels } from "@/timeline";
|
||||
import {
|
||||
buildTimelineSnapPoints,
|
||||
getTimelineSnapThresholdInTicks,
|
||||
resolveTimelineSnap,
|
||||
} from "@/timeline/snapping";
|
||||
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import {
|
||||
getCenteredLineLeft,
|
||||
timelineTimeToPixels,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/timeline";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
PlayheadController,
|
||||
type PlayheadConfig,
|
||||
} from "@/timeline/controllers/playhead-controller";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
zoomLevel: number;
|
||||
|
|
@ -35,267 +24,75 @@ export function useTimelinePlayhead({
|
|||
playheadRef,
|
||||
}: UseTimelinePlayheadProps) {
|
||||
const editor = useEditor();
|
||||
const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
|
||||
const activeProject = editor.project.getActive();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
// isScrubbing drives useEdgeAutoScroll — the controller sets it on the editor,
|
||||
// so this reactive read naturally reflects whether scrubbing is active.
|
||||
const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
|
||||
|
||||
const zoomLevelRef = useRef(zoomLevel);
|
||||
const durationRef = useRef(duration);
|
||||
const isScrubbingRef = useRef(isScrubbing);
|
||||
const isPlayingRef = useRef(false);
|
||||
const config: PlayheadConfig = {
|
||||
zoomLevel,
|
||||
duration: editor.timeline.getTotalDuration(),
|
||||
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
getIsPlaying: () => editor.playback.getIsPlaying(),
|
||||
getRulerEl: () => rulerRef.current,
|
||||
getRulerScrollEl: () => rulerScrollRef.current,
|
||||
getTracksScrollEl: () => tracksScrollRef.current,
|
||||
getPlayheadEl: () => playheadRef?.current ?? null,
|
||||
getSceneTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getSceneBookmarks: () => editor.scenes.getActiveScene()?.bookmarks ?? [],
|
||||
seek: (time) => editor.playback.seek({ time }),
|
||||
setScrubbing: (scrubbing) =>
|
||||
editor.playback.setScrubbing({ isScrubbing: scrubbing }),
|
||||
setTimelineViewState: (viewState) =>
|
||||
editor.project.setTimelineViewState({ viewState }),
|
||||
};
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
useEffect(() => {
|
||||
zoomLevelRef.current = zoomLevel;
|
||||
durationRef.current = duration;
|
||||
isScrubbingRef.current = isScrubbing;
|
||||
isPlayingRef.current = editor.playback.getIsPlaying();
|
||||
}, [zoomLevel, duration, isScrubbing, editor.playback]);
|
||||
|
||||
const seek = useCallback(
|
||||
({ time }: { time: number }) => editor.playback.seek({ time }),
|
||||
[editor.playback],
|
||||
);
|
||||
|
||||
const scrubTimeRef = useRef<number | null>(null);
|
||||
const isDraggingRulerRef = useRef(false);
|
||||
const hasDraggedRulerRef = useRef(false);
|
||||
const lastMouseXRef = useRef<number>(0);
|
||||
|
||||
const handleScrub = useCallback(
|
||||
({
|
||||
event,
|
||||
snappingEnabled = true,
|
||||
}: {
|
||||
event: MouseEvent | React.MouseEvent;
|
||||
snappingEnabled?: boolean;
|
||||
}) => {
|
||||
const ruler = rulerRef.current;
|
||||
if (!ruler) return;
|
||||
const rulerRect = ruler.getBoundingClientRect();
|
||||
const relativeMouseX = event.clientX - rulerRect.left;
|
||||
|
||||
const timelineContentWidth = timelineTimeToPixels({
|
||||
time: duration,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const clampedMouseX = Math.max(
|
||||
0,
|
||||
Math.min(timelineContentWidth, relativeMouseX),
|
||||
);
|
||||
|
||||
const rawTimeSeconds = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration / TICKS_PER_SECOND,
|
||||
clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
||||
|
||||
const rate = activeProject.settings.fps;
|
||||
const frameTime =
|
||||
snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime;
|
||||
|
||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
||||
const time = (() => {
|
||||
if (!shouldSnap) return frameTime;
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
|
||||
const snapPoints = buildTimelineSnapPoints({
|
||||
sources: [
|
||||
() => getElementEdgeSnapPoints({ tracks }),
|
||||
() => getBookmarkSnapPoints({ bookmarks }),
|
||||
() => getAnimationKeyframeSnapPointsForTimeline({ tracks }),
|
||||
],
|
||||
});
|
||||
const snapResult = resolveTimelineSnap({
|
||||
targetTime: frameTime,
|
||||
snapPoints,
|
||||
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
|
||||
});
|
||||
return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
|
||||
})();
|
||||
|
||||
scrubTimeRef.current = time;
|
||||
seek({ time });
|
||||
|
||||
lastMouseXRef.current = event.clientX;
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
seek,
|
||||
rulerRef,
|
||||
activeProject.settings.fps,
|
||||
isShiftHeldRef,
|
||||
editor.scenes,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePlayheadMouseDown = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
editor.playback.setScrubbing({ isScrubbing: true });
|
||||
handleScrub({ event });
|
||||
},
|
||||
[handleScrub, editor.playback],
|
||||
);
|
||||
|
||||
const handleRulerMouseDown = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
if (event.button !== 0) return;
|
||||
if (playheadRef?.current?.contains(event.target as Node)) return;
|
||||
|
||||
event.preventDefault();
|
||||
isDraggingRulerRef.current = true;
|
||||
hasDraggedRulerRef.current = false;
|
||||
|
||||
editor.playback.setScrubbing({ isScrubbing: true });
|
||||
handleScrub({ event, snappingEnabled: false });
|
||||
},
|
||||
[handleScrub, playheadRef, editor.playback],
|
||||
);
|
||||
|
||||
const handlePlayheadMouseDownEvent = useCallback(
|
||||
(event: React.MouseEvent) => handlePlayheadMouseDown({ event }),
|
||||
[handlePlayheadMouseDown],
|
||||
);
|
||||
|
||||
const handleRulerMouseDownEvent = useCallback(
|
||||
(event: React.MouseEvent) => handleRulerMouseDown({ event }),
|
||||
[handleRulerMouseDown],
|
||||
);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: isScrubbing,
|
||||
getMouseClientX: () => lastMouseXRef.current,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isScrubbing) return;
|
||||
|
||||
const handleMouseMove = ({ event }: { event: MouseEvent }) => {
|
||||
handleScrub({ event });
|
||||
if (isDraggingRulerRef.current) {
|
||||
hasDraggedRulerRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
|
||||
editor.playback.setScrubbing({ isScrubbing: false });
|
||||
const finalTime = scrubTimeRef.current;
|
||||
if (finalTime !== null) {
|
||||
seek({ time: finalTime });
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
|
||||
playheadTime: finalTime,
|
||||
},
|
||||
});
|
||||
}
|
||||
scrubTimeRef.current = null;
|
||||
|
||||
if (isDraggingRulerRef.current) {
|
||||
isDraggingRulerRef.current = false;
|
||||
if (!hasDraggedRulerRef.current) {
|
||||
handleScrub({ event, snappingEnabled: false });
|
||||
}
|
||||
hasDraggedRulerRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => handleMouseMove({ event });
|
||||
const onMouseUp = (event: MouseEvent) => handleMouseUp({ event });
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", onMouseUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
|
||||
|
||||
const updatePlayheadLeft = useCallback(
|
||||
(time: number) => {
|
||||
const playheadEl = playheadRef?.current;
|
||||
if (!playheadEl) return;
|
||||
const centerPosition = timelineTimeToSnappedPixels({
|
||||
time,
|
||||
zoomLevel: zoomLevelRef.current,
|
||||
});
|
||||
const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
|
||||
const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0;
|
||||
playheadEl.style.left = `${leftPosition - scrollLeft}px`;
|
||||
},
|
||||
[playheadRef, rulerScrollRef],
|
||||
);
|
||||
const ctrlRef = useRef<PlayheadController | null>(null);
|
||||
if (!ctrlRef.current) {
|
||||
ctrlRef.current = new PlayheadController({ configRef });
|
||||
}
|
||||
const ctrl = ctrlRef.current;
|
||||
|
||||
// Scroll → keep playhead position in sync with scroll offset.
|
||||
useEffect(() => {
|
||||
const scrollEl = rulerScrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
const handler = () =>
|
||||
ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
|
||||
scrollEl.addEventListener("scroll", handler, { passive: true });
|
||||
return () => scrollEl.removeEventListener("scroll", handler);
|
||||
}, [ctrl, editor.playback, rulerScrollRef]);
|
||||
|
||||
const handleScroll = () => {
|
||||
updatePlayheadLeft(editor.playback.getCurrentTime());
|
||||
};
|
||||
|
||||
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => scrollEl.removeEventListener("scroll", handleScroll);
|
||||
}, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
|
||||
|
||||
// Playback events → update playhead position and auto-scroll during playback.
|
||||
useEffect(() => {
|
||||
const handlePlaybackUpdate = (e: Event) => {
|
||||
const time = (e as CustomEvent<{ time: number }>).detail.time;
|
||||
updatePlayheadLeft(time);
|
||||
|
||||
if (!isPlayingRef.current || isScrubbingRef.current) return;
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const playheadPixels = timelineTimeToPixels({
|
||||
time,
|
||||
zoomLevel: zoomLevelRef.current,
|
||||
});
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMinimum = 0;
|
||||
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
|
||||
|
||||
const needsScroll =
|
||||
playheadPixels < rulerViewport.scrollLeft ||
|
||||
playheadPixels > rulerViewport.scrollLeft + viewportWidth;
|
||||
|
||||
if (needsScroll) {
|
||||
const desiredScroll = Math.max(
|
||||
scrollMinimum,
|
||||
Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
|
||||
);
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
};
|
||||
|
||||
const initialTime = editor.playback.getCurrentTime();
|
||||
handlePlaybackUpdate({
|
||||
detail: { time: initialTime },
|
||||
} as CustomEvent<{ time: number }>);
|
||||
|
||||
window.addEventListener("playback-update", handlePlaybackUpdate);
|
||||
window.addEventListener("playback-seek", handlePlaybackUpdate);
|
||||
const handler = (time: number) => ctrl.handlePlaybackUpdate(time);
|
||||
ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
|
||||
const unsubscribeUpdate = editor.playback.onUpdate(handler);
|
||||
const unsubscribeSeek = editor.playback.onSeek(handler);
|
||||
return () => {
|
||||
window.removeEventListener("playback-update", handlePlaybackUpdate);
|
||||
window.removeEventListener("playback-seek", handlePlaybackUpdate);
|
||||
unsubscribeUpdate();
|
||||
unsubscribeSeek();
|
||||
};
|
||||
}, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
|
||||
}, [ctrl, editor.playback]);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: isScrubbing,
|
||||
getMouseClientX: () => ctrl.getLastMouseClientX(),
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: timelineTimeToPixels({
|
||||
time: editor.timeline.getTotalDuration(),
|
||||
zoomLevel,
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => () => ctrl.destroy(), [ctrl]);
|
||||
|
||||
return {
|
||||
handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
|
||||
handleRulerMouseDown: handleRulerMouseDownEvent,
|
||||
handlePlayheadMouseDown: ctrl.onPlayheadMouseDown,
|
||||
handleRulerMouseDown: ctrl.onRulerMouseDown,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,332 +1,81 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { EditorCore } from "@/core";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { useTimelineStore } from "@/timeline/timeline-store";
|
||||
import {
|
||||
computeGroupResize,
|
||||
type GroupResizeMember,
|
||||
type GroupResizeResult,
|
||||
type ResizeSide,
|
||||
} from "@/timeline/group-resize";
|
||||
import {
|
||||
buildTimelineSnapPoints,
|
||||
getTimelineSnapThresholdInTicks,
|
||||
resolveTimelineSnap,
|
||||
type SnapPoint,
|
||||
} from "@/timeline/snapping";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
|
||||
import { isRetimableElement } from "@/timeline";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import {
|
||||
ResizeController,
|
||||
type ResizeConfig,
|
||||
} from "@/timeline/controllers/resize-controller";
|
||||
import type { ResizeSide } from "@/timeline/group-resize";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
|
||||
interface ResizeInteractionState {
|
||||
side: ResizeSide;
|
||||
startX: number;
|
||||
members: GroupResizeMember[];
|
||||
export type { ResizeSide };
|
||||
|
||||
interface UseTimelineResizeProps {
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
export function useTimelineResize({
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}) {
|
||||
}: UseTimelineResizeProps) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||
const { selectedElements } = useElementSelection();
|
||||
const [resizeState, setResizeState] = useState<ResizeInteractionState | null>(
|
||||
null,
|
||||
);
|
||||
const latestResultRef = useRef<GroupResizeResult | null>(null);
|
||||
|
||||
const cancelResize = useCallback(() => {
|
||||
editor.timeline.discardPreview();
|
||||
setResizeState(null);
|
||||
latestResultRef.current = null;
|
||||
onSnapPointChange?.(null);
|
||||
}, [editor.timeline, onSnapPointChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
return registerCanceller({ fn: cancelResize });
|
||||
}, [resizeState, cancelResize]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
({
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
side,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
side: ResizeSide;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const elementRef = {
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
};
|
||||
const activeSelection = selectedElements.some(
|
||||
(selectedElement) =>
|
||||
selectedElement.trackId === track.id &&
|
||||
selectedElement.elementId === element.id,
|
||||
)
|
||||
? selectedElements
|
||||
: [elementRef];
|
||||
const members = buildResizeMembers({
|
||||
tracks: editor.scenes.getActiveScene().tracks,
|
||||
selectedElements: activeSelection,
|
||||
});
|
||||
if (members.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.discardPreview();
|
||||
latestResultRef.current = null;
|
||||
setResizeState({
|
||||
side,
|
||||
startX: event.clientX,
|
||||
members,
|
||||
});
|
||||
},
|
||||
[selectedElements, editor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = ({ clientX }: MouseEvent) => {
|
||||
const deltaX = clientX - resizeState.startX;
|
||||
const rawDeltaTime = Math.round(
|
||||
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) *
|
||||
TICKS_PER_SECOND,
|
||||
);
|
||||
const snappedDeltaTime = getSnappedResizeDelta({
|
||||
editor,
|
||||
resizeState,
|
||||
rawDeltaTime,
|
||||
zoomLevel,
|
||||
shouldSnap: snappingEnabled && !isShiftHeldRef.current,
|
||||
onSnapPointChange,
|
||||
});
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const result = computeGroupResize({
|
||||
members: resizeState.members,
|
||||
side: resizeState.side,
|
||||
deltaTime: snappedDeltaTime.deltaTime,
|
||||
fps,
|
||||
});
|
||||
|
||||
latestResultRef.current = result;
|
||||
const config: ResizeConfig = {
|
||||
zoomLevel,
|
||||
snappingEnabled,
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
getSceneTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
|
||||
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
selectedElements,
|
||||
discardPreview: () => editor.timeline.discardPreview(),
|
||||
previewElements: (updates) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
||||
updates: updates.map(({ trackId, elementId, patch }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: patch,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const result = latestResultRef.current;
|
||||
editor.timeline.discardPreview();
|
||||
if (
|
||||
result &&
|
||||
hasResizeChanges({ members: resizeState.members, result })
|
||||
) {
|
||||
editor.timeline.updateElements({
|
||||
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
patch,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
setResizeState(null);
|
||||
latestResultRef.current = null;
|
||||
onSnapPointChange?.(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [
|
||||
resizeState,
|
||||
zoomLevel,
|
||||
snappingEnabled,
|
||||
isShiftHeldRef,
|
||||
editor,
|
||||
}),
|
||||
commitElements: (updates) =>
|
||||
editor.timeline.updateElements({
|
||||
updates: updates.map(({ trackId, elementId, patch }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
patch,
|
||||
})),
|
||||
}),
|
||||
onSnapPointChange,
|
||||
]);
|
||||
};
|
||||
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
const controllerRef = useRef<ResizeController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new ResizeController({ configRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller.isResizing) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller.isResizing, controller]);
|
||||
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
isResizing: resizeState !== null,
|
||||
handleResizeStart,
|
||||
isResizing: controller.isResizing,
|
||||
handleResizeStart: controller.onResizeStart,
|
||||
};
|
||||
}
|
||||
|
||||
function buildResizeMembers({
|
||||
tracks,
|
||||
selectedElements,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
selectedElements: Array<{ trackId: string; elementId: string }>;
|
||||
}): GroupResizeMember[] {
|
||||
const selectedElementIds = new Set(
|
||||
selectedElements.map((selectedElement) => selectedElement.elementId),
|
||||
);
|
||||
const trackMap = new Map(
|
||||
[...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
|
||||
track.id,
|
||||
track,
|
||||
]),
|
||||
);
|
||||
|
||||
return selectedElements.flatMap(({ trackId, elementId }) => {
|
||||
const track = trackMap.get(trackId);
|
||||
const element = track?.elements.find(
|
||||
(trackElement) => trackElement.id === elementId,
|
||||
);
|
||||
if (!track || !element) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const otherElements = track.elements.filter(
|
||||
(trackElement) => !selectedElementIds.has(trackElement.id),
|
||||
);
|
||||
const leftNeighborBound = otherElements
|
||||
.filter(
|
||||
(trackElement) =>
|
||||
trackElement.startTime + trackElement.duration <= element.startTime,
|
||||
)
|
||||
.reduce((bound, trackElement) => {
|
||||
return Math.max(bound, trackElement.startTime + trackElement.duration);
|
||||
}, -Infinity);
|
||||
const rightNeighborBound = otherElements
|
||||
.filter(
|
||||
(trackElement) =>
|
||||
trackElement.startTime >= element.startTime + element.duration,
|
||||
)
|
||||
.reduce((bound, trackElement) => {
|
||||
return Math.min(bound, trackElement.startTime);
|
||||
}, Infinity);
|
||||
|
||||
return [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
sourceDuration: element.sourceDuration,
|
||||
retime: isRetimableElement(element) ? element.retime : undefined,
|
||||
leftNeighborBound,
|
||||
rightNeighborBound,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function getSnappedResizeDelta({
|
||||
editor,
|
||||
resizeState,
|
||||
rawDeltaTime,
|
||||
zoomLevel,
|
||||
shouldSnap,
|
||||
onSnapPointChange,
|
||||
}: {
|
||||
editor: EditorCore;
|
||||
resizeState: ResizeInteractionState;
|
||||
rawDeltaTime: number;
|
||||
zoomLevel: number;
|
||||
shouldSnap: boolean;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}): { deltaTime: number } {
|
||||
if (!shouldSnap) {
|
||||
onSnapPointChange?.(null);
|
||||
return { deltaTime: rawDeltaTime };
|
||||
}
|
||||
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const excludeElementIds = new Set(
|
||||
resizeState.members.map((member) => member.elementId),
|
||||
);
|
||||
const snapPoints = buildTimelineSnapPoints({
|
||||
sources: [
|
||||
() => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
|
||||
() => getPlayheadSnapPoints({ playheadTime }),
|
||||
() =>
|
||||
getAnimationKeyframeSnapPointsForTimeline({
|
||||
tracks,
|
||||
excludeElementIds,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
|
||||
let closestSnapPoint: SnapPoint | null = null;
|
||||
let closestSnapDistance = Infinity;
|
||||
let deltaTime = rawDeltaTime;
|
||||
|
||||
for (const member of resizeState.members) {
|
||||
const baseEdgeTime =
|
||||
resizeState.side === "left"
|
||||
? member.startTime
|
||||
: member.startTime + member.duration;
|
||||
const snapResult = resolveTimelineSnap({
|
||||
targetTime: baseEdgeTime + rawDeltaTime,
|
||||
snapPoints,
|
||||
maxSnapDistance,
|
||||
});
|
||||
if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) {
|
||||
closestSnapDistance = snapResult.snapDistance;
|
||||
closestSnapPoint = snapResult.snapPoint;
|
||||
deltaTime = snapResult.snappedTime - baseEdgeTime;
|
||||
}
|
||||
}
|
||||
|
||||
onSnapPointChange?.(closestSnapPoint);
|
||||
return { deltaTime };
|
||||
}
|
||||
|
||||
function hasResizeChanges({
|
||||
members,
|
||||
result,
|
||||
}: {
|
||||
members: GroupResizeMember[];
|
||||
result: GroupResizeResult;
|
||||
}): boolean {
|
||||
return result.updates.some((update) => {
|
||||
const member = members.find(
|
||||
(candidateMember) => candidateMember.elementId === update.elementId,
|
||||
);
|
||||
return (
|
||||
member?.trimStart !== update.patch.trimStart ||
|
||||
member?.trimEnd !== update.patch.trimEnd ||
|
||||
member?.startTime !== update.patch.startTime ||
|
||||
member?.duration !== update.patch.duration
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { snappedSeekTime } from "opencut-wasm";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
SeekController,
|
||||
type SeekConfig,
|
||||
} from "@/timeline/controllers/seek-controller";
|
||||
|
||||
interface UseTimelineSeekProps {
|
||||
playheadRef: RefObject<HTMLDivElement | null>;
|
||||
|
|
@ -17,44 +17,6 @@ interface UseTimelineSeekProps {
|
|||
seek: (time: number) => void;
|
||||
}
|
||||
|
||||
function resetMouseTracking({
|
||||
mouseTrackingRef,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
}) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function setMouseTracking({
|
||||
mouseTrackingRef,
|
||||
event,
|
||||
}: {
|
||||
mouseTrackingRef: MutableRefObject<{
|
||||
isMouseDown: boolean;
|
||||
downX: number;
|
||||
downY: number;
|
||||
downTime: number;
|
||||
}>;
|
||||
event: React.MouseEvent;
|
||||
}) {
|
||||
mouseTrackingRef.current = {
|
||||
isMouseDown: true,
|
||||
downX: event.clientX,
|
||||
downY: event.clientY,
|
||||
downTime: event.timeStamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTimelineSeek({
|
||||
playheadRef,
|
||||
trackLabelsRef,
|
||||
|
|
@ -67,131 +29,36 @@ export function useTimelineSeek({
|
|||
seek,
|
||||
}: UseTimelineSeekProps) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const config: SeekConfig = {
|
||||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
getPlayheadEl: () => playheadRef.current,
|
||||
getTrackLabelsEl: () => trackLabelsRef.current,
|
||||
getRulerScrollEl: () => rulerScrollRef.current,
|
||||
getTracksScrollEl: () => tracksScrollRef.current,
|
||||
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
setTimelineViewState: (viewState) =>
|
||||
editor.project.setTimelineViewState({ viewState }),
|
||||
};
|
||||
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
const controllerRef = useRef<SeekController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new SeekController({ configRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const shouldProcessTimelineClick = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
const deltaX = Math.abs(event.clientX - downX);
|
||||
const deltaY = Math.abs(event.clientY - downY);
|
||||
const deltaTime = event.timeStamp - downTime;
|
||||
const isPlayhead = !!playheadRef.current?.contains(target);
|
||||
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
|
||||
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
|
||||
|
||||
if (!isMouseDown) return false;
|
||||
if (shouldBlockForDrag) return false;
|
||||
if (isSelecting) return false;
|
||||
if (isPlayhead) return false;
|
||||
if (isTrackLabels) {
|
||||
clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
source: "ruler" | "tracks";
|
||||
}) => {
|
||||
const scrollContainer =
|
||||
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
|
||||
const rawTimeSeconds = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration / TICKS_PER_SECOND,
|
||||
(mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND);
|
||||
|
||||
const rate = activeProject?.settings.fps;
|
||||
const time = rate
|
||||
? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime)
|
||||
: rawTime;
|
||||
seek(time);
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollContainer.scrollLeft,
|
||||
playheadTime: time,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
seek,
|
||||
editor,
|
||||
activeProject?.settings.fps.numerator,
|
||||
activeProject?.settings.fps.denominator,
|
||||
],
|
||||
);
|
||||
|
||||
const handleTracksClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "tracks" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
|
||||
const handleRulerClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "ruler" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
handleTracksMouseDown,
|
||||
handleTracksClick,
|
||||
handleRulerMouseDown,
|
||||
handleRulerClick,
|
||||
handleTracksMouseDown: controller.onTracksMouseDown,
|
||||
handleTracksClick: controller.onTracksClick,
|
||||
handleRulerMouseDown: controller.onRulerMouseDown,
|
||||
handleRulerClick: controller.onRulerClick,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import {
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction";
|
||||
import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale";
|
||||
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { zoomToSlider } from "@/timeline/zoom-utils";
|
||||
import { TIMELINE_ZOOM_MIN } from "@/timeline/scale";
|
||||
import {
|
||||
ZoomController,
|
||||
type ZoomConfig,
|
||||
} from "@/timeline/controllers/zoom-controller";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
|
|
@ -40,252 +40,53 @@ export function useTimelineZoom({
|
|||
rulerScrollRef,
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const editor = useEditor();
|
||||
const hasInitializedRef = useRef(false);
|
||||
const hasRestoredPlayheadRef = useRef(false);
|
||||
const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const config: ZoomConfig = {
|
||||
minZoom,
|
||||
getContainerEl: () => containerRef.current,
|
||||
getTracksScrollEl: () => tracksScrollRef.current,
|
||||
getRulerScrollEl: () => rulerScrollRef.current,
|
||||
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
|
||||
seek: (time) => editor.playback.seek({ time }),
|
||||
setTimelineViewState: (viewState) =>
|
||||
editor.project.setTimelineViewState({ viewState }),
|
||||
};
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
const [zoomLevel, setZoomLevelRaw] = useState(() => {
|
||||
if (initialZoom !== undefined) {
|
||||
hasInitializedRef.current = true;
|
||||
return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom));
|
||||
}
|
||||
return minZoom;
|
||||
});
|
||||
const previousZoomRef = useRef(zoomLevel);
|
||||
const hasRestoredScrollRef = useRef(false);
|
||||
const preZoomScrollLeftRef = useRef(0);
|
||||
const prePlayheadAnchorScrollLeftRef = useRef(0);
|
||||
const isInPlayheadAnchorModeRef = useRef(false);
|
||||
const controllerRef = useRef<ZoomController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new ZoomController({ configRef, initialZoom });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
const zoomLevel = controller.zoomLevel;
|
||||
|
||||
const setZoomLevel = useCallback(
|
||||
(updater: number | ((prev: number) => number)) => {
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (scrollElement) {
|
||||
preZoomScrollLeftRef.current = scrollElement.scrollLeft;
|
||||
}
|
||||
setZoomLevelRaw(updater);
|
||||
},
|
||||
[tracksScrollRef],
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: ReactWheelEvent) => {
|
||||
const isZoomGesture = event.ctrlKey || event.metaKey;
|
||||
const isHorizontalScrollGesture =
|
||||
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
||||
|
||||
if (isHorizontalScrollGesture) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pinch-zoom (ctrl/meta + wheel)
|
||||
if (isZoomGesture) {
|
||||
const normalizedDelta =
|
||||
event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
||||
const cappedDelta =
|
||||
Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
|
||||
const zoomFactor = Math.exp(-cappedDelta / 300);
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
|
||||
);
|
||||
return nextZoom;
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
[minZoom, setZoomLevel],
|
||||
);
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialZoom !== undefined && !hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)));
|
||||
return;
|
||||
}
|
||||
setZoomLevel((prev) => {
|
||||
if (prev < minZoom) {
|
||||
return minZoom;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [minZoom, initialZoom, setZoomLevel]);
|
||||
|
||||
const wrappedSetZoomLevel = useCallback(
|
||||
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom =
|
||||
typeof zoomLevelOrUpdater === "function"
|
||||
? zoomLevelOrUpdater(prev)
|
||||
: zoomLevelOrUpdater;
|
||||
const clampedZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_ZOOM_MAX, nextZoom),
|
||||
);
|
||||
return clampedZoom;
|
||||
});
|
||||
},
|
||||
[minZoom, setZoomLevel],
|
||||
);
|
||||
controller.reconcileInitialAndMinZoom(minZoom, initialZoom);
|
||||
}, [controller, minZoom, initialZoom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previousZoom = previousZoomRef.current;
|
||||
if (previousZoom === zoomLevel) return;
|
||||
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (!scrollElement) {
|
||||
previousZoomRef.current = zoomLevel;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentScrollLeft = preZoomScrollLeftRef.current;
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
|
||||
const previousSliderPercent = zoomToSlider({
|
||||
zoomLevel: previousZoom,
|
||||
minZoom,
|
||||
});
|
||||
const isCrossingThresholdUp =
|
||||
previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
const isCrossingThresholdDown =
|
||||
previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
|
||||
const syncScroll = (scrollLeft: number) => {
|
||||
scrollElement.scrollLeft = scrollLeft;
|
||||
if (rulerScrollRef.current) {
|
||||
rulerScrollRef.current.scrollLeft = scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
const clampScrollLeft = (scrollLeft: number) => {
|
||||
const maxScrollLeft =
|
||||
scrollElement.scrollWidth - scrollElement.clientWidth;
|
||||
return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
|
||||
};
|
||||
|
||||
if (isCrossingThresholdUp) {
|
||||
prePlayheadAnchorScrollLeftRef.current = currentScrollLeft;
|
||||
isInPlayheadAnchorModeRef.current = true;
|
||||
}
|
||||
|
||||
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||
const playheadPixelsBefore = timelineTimeToPixels({
|
||||
time: playheadTime,
|
||||
zoomLevel: previousZoom,
|
||||
});
|
||||
const playheadPixelsAfter = timelineTimeToPixels({
|
||||
time: playheadTime,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
||||
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
||||
|
||||
syncScroll(clampScrollLeft(newScrollLeft));
|
||||
} else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
|
||||
syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
|
||||
isInPlayheadAnchorModeRef.current = false;
|
||||
}
|
||||
|
||||
previousZoomRef.current = zoomLevel;
|
||||
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollElement.scrollLeft,
|
||||
playheadTime,
|
||||
},
|
||||
});
|
||||
}, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
|
||||
const saveScrollPosition = useCallback(() => {
|
||||
if (scrollSaveTimeoutRef.current) {
|
||||
clearTimeout(scrollSaveTimeoutRef.current);
|
||||
}
|
||||
scrollSaveTimeoutRef.current = setTimeout(() => {
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (scrollElement) {
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollElement.scrollLeft,
|
||||
playheadTime: editor.playback.getCurrentTime(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
}, [zoomLevel, editor]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
|
||||
useEffect(() => {
|
||||
if (initialScrollLeft === undefined) return;
|
||||
if (hasRestoredScrollRef.current) return;
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const restoreScroll = () => {
|
||||
scrollElement.scrollLeft = initialScrollLeft;
|
||||
if (rulerScrollRef.current) {
|
||||
rulerScrollRef.current.scrollLeft = initialScrollLeft;
|
||||
}
|
||||
hasRestoredScrollRef.current = true;
|
||||
};
|
||||
|
||||
if (scrollElement.scrollWidth > 0) {
|
||||
restoreScroll();
|
||||
} else {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (scrollElement.scrollWidth > 0) {
|
||||
restoreScroll();
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(scrollElement);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [initialScrollLeft]);
|
||||
controller.applyZoomLayout(zoomLevel);
|
||||
}, [controller, zoomLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
|
||||
hasRestoredPlayheadRef.current = true;
|
||||
editor.playback.seek({ time: initialPlayheadTime });
|
||||
}
|
||||
}, [initialPlayheadTime, editor]);
|
||||
return controller.restoreInitialScrollIfNeeded(initialScrollLeft);
|
||||
}, [controller, initialScrollLeft]);
|
||||
|
||||
// prevent browser zoom in the timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (event: WheelEvent) => {
|
||||
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
|
||||
const isInContainer = containerRef.current?.contains(
|
||||
event.target as Node,
|
||||
);
|
||||
// only check isInContainer, not isInTimeline state - the state check
|
||||
// causes race conditions where the closure captures stale state
|
||||
if (isZoomKeyPressed && isInContainer) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime);
|
||||
}, [controller, initialPlayheadTime]);
|
||||
|
||||
document.addEventListener("wheel", preventZoom, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
useEffect(() => controller.bindPreventBrowserZoom(), [controller]);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom, { capture: true });
|
||||
};
|
||||
}, [containerRef]);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel: wrappedSetZoomLevel,
|
||||
handleWheel,
|
||||
saveScrollPosition,
|
||||
setZoomLevel: controller.setZoomLevel,
|
||||
handleWheel: controller.handleWheel,
|
||||
saveScrollPosition: controller.saveScrollPosition,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,314 +1,317 @@
|
|||
import type { ElementAnimations } from "@/animation/types";
|
||||
import type { Effect } from "@/effects/types";
|
||||
import type { Mask } from "@/masks/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { BlendMode, Transform } from "@/rendering";
|
||||
|
||||
export type ElementRef = {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
};
|
||||
|
||||
export interface Bookmark {
|
||||
time: number;
|
||||
note?: string;
|
||||
color?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface TScene {
|
||||
id: string;
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
tracks: SceneTracks;
|
||||
bookmarks: Bookmark[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
|
||||
|
||||
interface BaseTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VideoTrack extends BaseTrack {
|
||||
type: "video";
|
||||
elements: (VideoElement | ImageElement)[];
|
||||
muted: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface TextTrack extends BaseTrack {
|
||||
type: "text";
|
||||
elements: TextElement[];
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface AudioTrack extends BaseTrack {
|
||||
type: "audio";
|
||||
elements: AudioElement[];
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface GraphicTrack extends BaseTrack {
|
||||
type: "graphic";
|
||||
elements: (StickerElement | GraphicElement)[];
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface EffectTrack extends BaseTrack {
|
||||
type: "effect";
|
||||
elements: EffectElement[];
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export type TimelineTrack =
|
||||
| VideoTrack
|
||||
| TextTrack
|
||||
| AudioTrack
|
||||
| GraphicTrack
|
||||
| EffectTrack;
|
||||
|
||||
export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
|
||||
|
||||
export interface SceneTracks {
|
||||
overlay: OverlayTrack[];
|
||||
main: VideoTrack;
|
||||
audio: AudioTrack[];
|
||||
}
|
||||
|
||||
export interface RetimeConfig {
|
||||
rate: number;
|
||||
maintainPitch?: boolean;
|
||||
}
|
||||
|
||||
interface BaseAudioElement extends BaseTimelineElement {
|
||||
type: "audio";
|
||||
volume: number;
|
||||
muted?: boolean;
|
||||
buffer?: AudioBuffer;
|
||||
retime?: RetimeConfig;
|
||||
}
|
||||
|
||||
export interface UploadAudioElement extends BaseAudioElement {
|
||||
sourceType: "upload";
|
||||
mediaId: string;
|
||||
}
|
||||
|
||||
export interface LibraryAudioElement extends BaseAudioElement {
|
||||
sourceType: "library";
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
export type AudioElement = UploadAudioElement | LibraryAudioElement;
|
||||
|
||||
interface BaseTimelineElement {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
sourceDuration?: number;
|
||||
animations?: ElementAnimations;
|
||||
}
|
||||
|
||||
export interface VideoElement extends BaseTimelineElement {
|
||||
type: "video";
|
||||
mediaId: string;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
isSourceAudioEnabled?: boolean;
|
||||
hidden?: boolean;
|
||||
retime?: RetimeConfig;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
masks?: Mask[];
|
||||
}
|
||||
|
||||
export interface ImageElement extends BaseTimelineElement {
|
||||
type: "image";
|
||||
mediaId: string;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
masks?: Mask[];
|
||||
}
|
||||
|
||||
export interface TextBackground {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
cornerRadius?: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
export interface TextElement extends BaseTimelineElement {
|
||||
type: "text";
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
background: TextBackground;
|
||||
textAlign: "left" | "center" | "right";
|
||||
fontWeight: "normal" | "bold";
|
||||
fontStyle: "normal" | "italic";
|
||||
textDecoration: "none" | "underline" | "line-through";
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
}
|
||||
|
||||
export interface StickerElement extends BaseTimelineElement {
|
||||
type: "sticker";
|
||||
stickerId: string;
|
||||
/** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
|
||||
intrinsicWidth?: number;
|
||||
intrinsicHeight?: number;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
}
|
||||
|
||||
export interface GraphicElement extends BaseTimelineElement {
|
||||
type: "graphic";
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
masks?: Mask[];
|
||||
}
|
||||
|
||||
export interface EffectElement extends BaseTimelineElement {
|
||||
type: "effect";
|
||||
effectType: string;
|
||||
params: ParamValues;
|
||||
}
|
||||
|
||||
export type ElementUpdatePatch =
|
||||
| { transform: Transform }
|
||||
| { opacity: number }
|
||||
| { volume: number };
|
||||
|
||||
export type TimelineElement =
|
||||
| AudioElement
|
||||
| VideoElement
|
||||
| ImageElement
|
||||
| TextElement
|
||||
| StickerElement
|
||||
| GraphicElement
|
||||
| EffectElement;
|
||||
|
||||
export type ElementType = TimelineElement["type"];
|
||||
|
||||
function elementTypes<T extends ElementType[]>(...types: T): T {
|
||||
return types;
|
||||
}
|
||||
|
||||
export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
|
||||
|
||||
export type MaskableElement = Extract<
|
||||
TimelineElement,
|
||||
{ type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
|
||||
>;
|
||||
|
||||
export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
|
||||
|
||||
export type RetimableElement = Extract<
|
||||
TimelineElement,
|
||||
{ type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
|
||||
>;
|
||||
|
||||
export const VISUAL_ELEMENT_TYPES = elementTypes(
|
||||
"video",
|
||||
"image",
|
||||
"text",
|
||||
"sticker",
|
||||
"graphic",
|
||||
);
|
||||
|
||||
export type VisualElement = Extract<
|
||||
TimelineElement,
|
||||
{ type: (typeof VISUAL_ELEMENT_TYPES)[number] }
|
||||
>;
|
||||
|
||||
export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
|
||||
export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
|
||||
export type CreateAudioElement =
|
||||
| CreateUploadAudioElement
|
||||
| CreateLibraryAudioElement;
|
||||
export type CreateVideoElement = Omit<VideoElement, "id">;
|
||||
export type CreateImageElement = Omit<ImageElement, "id">;
|
||||
export type CreateTextElement = Omit<TextElement, "id">;
|
||||
export type CreateStickerElement = Omit<StickerElement, "id">;
|
||||
export type CreateGraphicElement = Omit<GraphicElement, "id">;
|
||||
export type CreateEffectElement = Omit<EffectElement, "id">;
|
||||
export type CreateTimelineElement =
|
||||
| CreateAudioElement
|
||||
| CreateVideoElement
|
||||
| CreateImageElement
|
||||
| CreateTextElement
|
||||
| CreateStickerElement
|
||||
| CreateGraphicElement
|
||||
| CreateEffectElement;
|
||||
|
||||
export interface ElementDragState {
|
||||
isDragging: boolean;
|
||||
elementId: string | null;
|
||||
dragElementIds: string[];
|
||||
dragTimeOffsets: Record<string, number>;
|
||||
trackId: string | null;
|
||||
startMouseX: number;
|
||||
startMouseY: number;
|
||||
startElementTime: number;
|
||||
clickOffsetTime: number;
|
||||
currentTime: number;
|
||||
currentMouseY: number;
|
||||
}
|
||||
|
||||
export interface DropTarget {
|
||||
trackIndex: number;
|
||||
isNewTrack: boolean;
|
||||
insertPosition: "above" | "below" | null;
|
||||
xPosition: number;
|
||||
targetElement: { elementId: string; trackId: string } | null;
|
||||
}
|
||||
|
||||
export interface ComputeDropTargetParams {
|
||||
elementType: ElementType;
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
tracks: SceneTracks;
|
||||
playheadTime: number;
|
||||
isExternalDrop: boolean;
|
||||
elementDuration: number;
|
||||
pixelsPerSecond: number;
|
||||
zoomLevel: number;
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
startTimeOverride?: number;
|
||||
excludeElementId?: string;
|
||||
targetElementTypes?: string[];
|
||||
}
|
||||
|
||||
export interface ClipboardItem {
|
||||
trackId: string;
|
||||
trackType: TrackType;
|
||||
element: CreateTimelineElement;
|
||||
}
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import type { Effect } from "@/effects/types";
|
||||
import type { Mask } from "@/masks/types";
|
||||
import type { ParamValues } from "@/params";
|
||||
import type { BlendMode, Transform } from "@/rendering";
|
||||
|
||||
export type ElementRef = {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
};
|
||||
|
||||
export interface Bookmark {
|
||||
time: number;
|
||||
note?: string;
|
||||
color?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface TScene {
|
||||
id: string;
|
||||
name: string;
|
||||
isMain: boolean;
|
||||
tracks: SceneTracks;
|
||||
bookmarks: Bookmark[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type TrackType = "video" | "text" | "audio" | "graphic" | "effect";
|
||||
|
||||
interface BaseTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface VideoTrack extends BaseTrack {
|
||||
type: "video";
|
||||
elements: (VideoElement | ImageElement)[];
|
||||
muted: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface TextTrack extends BaseTrack {
|
||||
type: "text";
|
||||
elements: TextElement[];
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface AudioTrack extends BaseTrack {
|
||||
type: "audio";
|
||||
elements: AudioElement[];
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface GraphicTrack extends BaseTrack {
|
||||
type: "graphic";
|
||||
elements: (StickerElement | GraphicElement)[];
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface EffectTrack extends BaseTrack {
|
||||
type: "effect";
|
||||
elements: EffectElement[];
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export type TimelineTrack =
|
||||
| VideoTrack
|
||||
| TextTrack
|
||||
| AudioTrack
|
||||
| GraphicTrack
|
||||
| EffectTrack;
|
||||
|
||||
export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack;
|
||||
|
||||
export interface SceneTracks {
|
||||
overlay: OverlayTrack[];
|
||||
main: VideoTrack;
|
||||
audio: AudioTrack[];
|
||||
}
|
||||
|
||||
export interface RetimeConfig {
|
||||
rate: number;
|
||||
maintainPitch?: boolean;
|
||||
}
|
||||
|
||||
interface BaseAudioElement extends BaseTimelineElement {
|
||||
type: "audio";
|
||||
volume: number;
|
||||
muted?: boolean;
|
||||
buffer?: AudioBuffer;
|
||||
retime?: RetimeConfig;
|
||||
}
|
||||
|
||||
export interface UploadAudioElement extends BaseAudioElement {
|
||||
sourceType: "upload";
|
||||
mediaId: string;
|
||||
}
|
||||
|
||||
export interface LibraryAudioElement extends BaseAudioElement {
|
||||
sourceType: "library";
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
export type AudioElement = UploadAudioElement | LibraryAudioElement;
|
||||
|
||||
interface BaseTimelineElement {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: number;
|
||||
startTime: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
sourceDuration?: number;
|
||||
animations?: ElementAnimations;
|
||||
}
|
||||
|
||||
export interface VideoElement extends BaseTimelineElement {
|
||||
type: "video";
|
||||
mediaId: string;
|
||||
volume?: number;
|
||||
muted?: boolean;
|
||||
isSourceAudioEnabled?: boolean;
|
||||
hidden?: boolean;
|
||||
retime?: RetimeConfig;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
masks?: Mask[];
|
||||
}
|
||||
|
||||
export interface ImageElement extends BaseTimelineElement {
|
||||
type: "image";
|
||||
mediaId: string;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
masks?: Mask[];
|
||||
}
|
||||
|
||||
export interface TextBackground {
|
||||
enabled: boolean;
|
||||
color: string;
|
||||
cornerRadius?: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
}
|
||||
|
||||
export interface TextElement extends BaseTimelineElement {
|
||||
type: "text";
|
||||
content: string;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
color: string;
|
||||
background: TextBackground;
|
||||
textAlign: "left" | "center" | "right";
|
||||
fontWeight: "normal" | "bold";
|
||||
fontStyle: "normal" | "italic";
|
||||
textDecoration: "none" | "underline" | "line-through";
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
}
|
||||
|
||||
export interface StickerElement extends BaseTimelineElement {
|
||||
type: "sticker";
|
||||
stickerId: string;
|
||||
/** Natural dimensions of the sticker asset, stored at insert time. Used by renderer and preview bounds to avoid split-brain geometry. */
|
||||
intrinsicWidth?: number;
|
||||
intrinsicHeight?: number;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
}
|
||||
|
||||
export interface GraphicElement extends BaseTimelineElement {
|
||||
type: "graphic";
|
||||
definitionId: string;
|
||||
params: ParamValues;
|
||||
hidden?: boolean;
|
||||
transform: Transform;
|
||||
opacity: number;
|
||||
blendMode?: BlendMode;
|
||||
effects?: Effect[];
|
||||
masks?: Mask[];
|
||||
}
|
||||
|
||||
export interface EffectElement extends BaseTimelineElement {
|
||||
type: "effect";
|
||||
effectType: string;
|
||||
params: ParamValues;
|
||||
}
|
||||
|
||||
export type ElementUpdatePatch =
|
||||
| { transform: Transform }
|
||||
| { opacity: number }
|
||||
| { volume: number };
|
||||
|
||||
export type TimelineElement =
|
||||
| AudioElement
|
||||
| VideoElement
|
||||
| ImageElement
|
||||
| TextElement
|
||||
| StickerElement
|
||||
| GraphicElement
|
||||
| EffectElement;
|
||||
|
||||
export type ElementType = TimelineElement["type"];
|
||||
|
||||
function elementTypes<T extends ElementType[]>(...types: T): T {
|
||||
return types;
|
||||
}
|
||||
|
||||
export const MASKABLE_ELEMENT_TYPES = elementTypes("video", "image", "graphic");
|
||||
|
||||
export type MaskableElement = Extract<
|
||||
TimelineElement,
|
||||
{ type: (typeof MASKABLE_ELEMENT_TYPES)[number] }
|
||||
>;
|
||||
|
||||
export const RETIMABLE_ELEMENT_TYPES = elementTypes("video", "audio");
|
||||
|
||||
export type RetimableElement = Extract<
|
||||
TimelineElement,
|
||||
{ type: (typeof RETIMABLE_ELEMENT_TYPES)[number] }
|
||||
>;
|
||||
|
||||
export const VISUAL_ELEMENT_TYPES = elementTypes(
|
||||
"video",
|
||||
"image",
|
||||
"text",
|
||||
"sticker",
|
||||
"graphic",
|
||||
);
|
||||
|
||||
export type VisualElement = Extract<
|
||||
TimelineElement,
|
||||
{ type: (typeof VISUAL_ELEMENT_TYPES)[number] }
|
||||
>;
|
||||
|
||||
export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">;
|
||||
export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">;
|
||||
export type CreateAudioElement =
|
||||
| CreateUploadAudioElement
|
||||
| CreateLibraryAudioElement;
|
||||
export type CreateVideoElement = Omit<VideoElement, "id">;
|
||||
export type CreateImageElement = Omit<ImageElement, "id">;
|
||||
export type CreateTextElement = Omit<TextElement, "id">;
|
||||
export type CreateStickerElement = Omit<StickerElement, "id">;
|
||||
export type CreateGraphicElement = Omit<GraphicElement, "id">;
|
||||
export type CreateEffectElement = Omit<EffectElement, "id">;
|
||||
export type CreateTimelineElement =
|
||||
| CreateAudioElement
|
||||
| CreateVideoElement
|
||||
| CreateImageElement
|
||||
| CreateTextElement
|
||||
| CreateStickerElement
|
||||
| CreateGraphicElement
|
||||
| CreateEffectElement;
|
||||
|
||||
export type ElementDragView =
|
||||
| { readonly kind: "idle" }
|
||||
| {
|
||||
readonly kind: "dragging";
|
||||
readonly anchorElementId: string;
|
||||
readonly trackId: string;
|
||||
readonly memberTimeOffsets: ReadonlyMap<string, number>;
|
||||
readonly startMouseX: number;
|
||||
readonly startMouseY: number;
|
||||
readonly startElementTime: number;
|
||||
readonly clickOffsetTime: number;
|
||||
readonly currentTime: number;
|
||||
readonly currentMouseX: number;
|
||||
readonly currentMouseY: number;
|
||||
readonly dropTarget: DropTarget | null;
|
||||
};
|
||||
|
||||
export interface DropTarget {
|
||||
trackIndex: number;
|
||||
isNewTrack: boolean;
|
||||
insertPosition: "above" | "below" | null;
|
||||
xPosition: number;
|
||||
targetElement: { elementId: string; trackId: string } | null;
|
||||
}
|
||||
|
||||
export interface ComputeDropTargetParams {
|
||||
elementType: ElementType;
|
||||
mouseX: number;
|
||||
mouseY: number;
|
||||
tracks: SceneTracks;
|
||||
playheadTime: number;
|
||||
isExternalDrop: boolean;
|
||||
elementDuration: number;
|
||||
pixelsPerSecond: number;
|
||||
zoomLevel: number;
|
||||
verticalDragDirection?: "up" | "down" | null;
|
||||
startTimeOverride?: number;
|
||||
excludeElementId?: string;
|
||||
targetElementTypes?: string[];
|
||||
}
|
||||
|
||||
export interface ClipboardItem {
|
||||
trackId: string;
|
||||
trackType: TrackType;
|
||||
element: CreateTimelineElement;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -348,7 +348,6 @@ impl Compositor {
|
|||
) -> Result<(), CompositorError> {
|
||||
let frame = options.frame;
|
||||
self.texture_pool.recycle_frame();
|
||||
context.configure_surface(options.surface, frame.width, frame.height)?;
|
||||
let surface_texture = context.acquire_surface_texture(options.surface)?;
|
||||
let surface_view = surface_texture
|
||||
.texture
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
|
|
@ -17,6 +20,12 @@ impl wgpu::rwh::HasDisplayHandle for WebDisplay {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
struct CachedCanvasSurface {
|
||||
surface: wgpu::Surface<'static>,
|
||||
size: (u32, u32),
|
||||
}
|
||||
|
||||
const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl");
|
||||
|
||||
const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [
|
||||
|
|
@ -44,6 +53,8 @@ pub struct GpuContext {
|
|||
/// fallback path. Used by render_texture_via_gl_canvas to output frames on WebGL.
|
||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
gl_canvas: Option<web_sys::HtmlCanvasElement>,
|
||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
gl_surface: RefCell<Option<CachedCanvasSurface>>,
|
||||
}
|
||||
|
||||
impl GpuContext {
|
||||
|
|
@ -170,6 +181,8 @@ impl GpuContext {
|
|||
supports_external_texture_copies,
|
||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
gl_canvas,
|
||||
#[cfg(all(feature = "wasm", target_arch = "wasm32"))]
|
||||
gl_surface: RefCell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -184,17 +197,15 @@ impl GpuContext {
|
|||
),
|
||||
GpuError,
|
||||
> {
|
||||
// Temporary fix: force the wasm renderer onto the WebGL backend even when
|
||||
// WebGPU is available while a WebGPU bug is being investigated.
|
||||
// let instance = wgpu::util::new_instance_with_webgpu_detection(
|
||||
// wgpu::InstanceDescriptor::new_without_display_handle(),
|
||||
// )
|
||||
// .await;
|
||||
let instance = wgpu::util::new_instance_with_webgpu_detection(
|
||||
wgpu::InstanceDescriptor::new_without_display_handle(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// match Self::try_request_device(&instance, None).await {
|
||||
// Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)),
|
||||
// Err(_) => {}
|
||||
// }
|
||||
match Self::try_request_device(&instance, None).await {
|
||||
Ok((adapter, device, queue)) => return Ok((instance, adapter, device, queue, None)),
|
||||
Err(_) => {}
|
||||
}
|
||||
let (gl_instance, adapter, device, queue, canvas) = Self::try_gl_fallback().await?;
|
||||
Ok((gl_instance, adapter, device, queue, Some(canvas)))
|
||||
}
|
||||
|
|
@ -369,6 +380,14 @@ impl GpuContext {
|
|||
height: u32,
|
||||
) -> Result<(), GpuError> {
|
||||
self.configure_surface(surface, width, height)?;
|
||||
self.present_texture_to_surface(texture, surface)
|
||||
}
|
||||
|
||||
pub fn present_texture_to_surface(
|
||||
&self,
|
||||
texture: &wgpu::Texture,
|
||||
surface: &wgpu::Surface<'_>,
|
||||
) -> Result<(), GpuError> {
|
||||
let surface_texture = self.acquire_surface_texture(surface)?;
|
||||
let target_view = surface_texture
|
||||
.texture
|
||||
|
|
@ -390,16 +409,38 @@ impl GpuContext {
|
|||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(), GpuError> {
|
||||
let Some(config) = surface.get_default_config(&self.adapter, width, height) else {
|
||||
return Err(GpuError::UnsupportedSurfaceFormat);
|
||||
};
|
||||
if config.format != self.texture_format {
|
||||
return Err(GpuError::UnsupportedSurfaceFormat);
|
||||
}
|
||||
let config = self.build_surface_configuration(surface, width, height)?;
|
||||
surface.configure(&self.device, &config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_surface_configuration(
|
||||
&self,
|
||||
surface: &wgpu::Surface<'_>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<wgpu::SurfaceConfiguration, GpuError> {
|
||||
let caps = surface.get_capabilities(&self.adapter);
|
||||
if !caps.formats.contains(&self.texture_format) {
|
||||
return Err(GpuError::UnsupportedSurfaceFormat);
|
||||
}
|
||||
|
||||
Ok(wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format: self.texture_format,
|
||||
width,
|
||||
height,
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
alpha_mode: caps
|
||||
.alpha_modes
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(wgpu::CompositeAlphaMode::Auto),
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn acquire_surface_texture(
|
||||
&self,
|
||||
surface: &wgpu::Surface<'_>,
|
||||
|
|
@ -593,57 +634,29 @@ impl GpuContext {
|
|||
gl_canvas.set_width(width);
|
||||
gl_canvas.set_height(height);
|
||||
|
||||
let surface = self
|
||||
.instance
|
||||
.create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?;
|
||||
|
||||
let caps = surface.get_capabilities(&self.adapter);
|
||||
let surface_format = if caps.formats.contains(&self.texture_format) {
|
||||
self.texture_format
|
||||
} else if !caps.formats.is_empty() {
|
||||
caps.formats[0]
|
||||
} else {
|
||||
return Err(GpuError::UnsupportedSurfaceFormat);
|
||||
let mut cached_surface = self.gl_surface.borrow_mut();
|
||||
let cached_surface = match cached_surface.as_mut() {
|
||||
Some(cached_surface) => cached_surface,
|
||||
None => {
|
||||
let surface = self
|
||||
.instance
|
||||
.create_surface(wgpu::SurfaceTarget::Canvas(gl_canvas.clone()))?;
|
||||
cached_surface.replace(CachedCanvasSurface {
|
||||
surface,
|
||||
size: (0, 0),
|
||||
});
|
||||
cached_surface
|
||||
.as_mut()
|
||||
.expect("gl_surface cache should exist after initialization")
|
||||
}
|
||||
};
|
||||
|
||||
if surface_format != self.texture_format {
|
||||
return Err(GpuError::UnsupportedSurfaceFormat);
|
||||
if cached_surface.size != (width, height) {
|
||||
self.configure_surface(&cached_surface.surface, width, height)?;
|
||||
cached_surface.size = (width, height);
|
||||
}
|
||||
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format: surface_format,
|
||||
width,
|
||||
height,
|
||||
present_mode: wgpu::PresentMode::Fifo,
|
||||
alpha_mode: caps
|
||||
.alpha_modes
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(wgpu::CompositeAlphaMode::Auto),
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
surface.configure(&self.device, &config);
|
||||
|
||||
let surface_texture = self.acquire_surface_texture(&surface)?;
|
||||
let surface_view = surface_texture
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("gpu-gl-canvas-blit-encoder"),
|
||||
});
|
||||
self.encode_texture_blit_to_view(
|
||||
&mut encoder,
|
||||
texture,
|
||||
&surface_view,
|
||||
"gpu-gl-canvas-blit",
|
||||
);
|
||||
self.queue.submit([encoder.finish()]);
|
||||
surface_texture.present();
|
||||
self.present_texture_to_surface(texture, &cached_surface.surface)?;
|
||||
|
||||
Ok(gl_canvas)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ use crate::perf;
|
|||
struct CompositorRuntime {
|
||||
canvas: web_sys::HtmlCanvasElement,
|
||||
compositor: Compositor,
|
||||
surface: wgpu::Surface<'static>,
|
||||
surface_size: (u32, u32),
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
|
|
@ -44,9 +46,23 @@ pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
|
|||
canvas.set_height(height);
|
||||
|
||||
let compositor = Compositor::new(&gpu_runtime.context);
|
||||
let surface = gpu_runtime
|
||||
.context
|
||||
.instance()
|
||||
.create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
gpu_runtime
|
||||
.context
|
||||
.configure_surface(&surface, width, height)
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
|
||||
COMPOSITOR_RUNTIME.with(|runtime| {
|
||||
runtime.replace(Some(CompositorRuntime { canvas, compositor }));
|
||||
runtime.replace(Some(CompositorRuntime {
|
||||
canvas,
|
||||
compositor,
|
||||
surface,
|
||||
surface_size: (width, height),
|
||||
}));
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
|
@ -55,16 +71,25 @@ pub fn init_compositor(width: u32, height: u32) -> Result<(), JsValue> {
|
|||
|
||||
#[wasm_bindgen(js_name = resizeCompositor)]
|
||||
pub fn resize_compositor(width: u32, height: u32) -> Result<(), JsValue> {
|
||||
COMPOSITOR_RUNTIME.with(|runtime| {
|
||||
let mut borrow = runtime.borrow_mut();
|
||||
let Some(runtime) = borrow.as_mut() else {
|
||||
return Err(JsValue::from_str(
|
||||
"Compositor is not initialized. Call initCompositor() first.",
|
||||
));
|
||||
};
|
||||
runtime.canvas.set_width(width);
|
||||
runtime.canvas.set_height(height);
|
||||
Ok(())
|
||||
with_gpu_runtime(|gpu_runtime| {
|
||||
COMPOSITOR_RUNTIME.with(|runtime| {
|
||||
let mut borrow = runtime.borrow_mut();
|
||||
let Some(runtime) = borrow.as_mut() else {
|
||||
return Err(JsValue::from_str(
|
||||
"Compositor is not initialized. Call initCompositor() first.",
|
||||
));
|
||||
};
|
||||
runtime.canvas.set_width(width);
|
||||
runtime.canvas.set_height(height);
|
||||
if runtime.surface_size != (width, height) {
|
||||
gpu_runtime
|
||||
.context
|
||||
.configure_surface(&runtime.surface, width, height)
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
runtime.surface_size = (width, height);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -144,15 +169,19 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
|
|||
));
|
||||
};
|
||||
|
||||
if gpu_runtime.context.supports_surface_rendering() {
|
||||
if runtime.surface_size != (frame.width, frame.height) {
|
||||
runtime.canvas.set_width(frame.width);
|
||||
runtime.canvas.set_height(frame.height);
|
||||
let t_surface = perf::now_ms();
|
||||
let surface = gpu_runtime
|
||||
gpu_runtime
|
||||
.context
|
||||
.instance()
|
||||
.create_surface(wgpu::SurfaceTarget::Canvas(runtime.canvas.clone()))
|
||||
.configure_surface(&runtime.surface, frame.width, frame.height)
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
perf::record("wasm.surfaceCreate", perf::now_ms() - t_surface);
|
||||
perf::record("wasm.surfaceConfigure", perf::now_ms() - t_surface);
|
||||
runtime.surface_size = (frame.width, frame.height);
|
||||
}
|
||||
|
||||
if gpu_runtime.context.supports_surface_rendering() {
|
||||
let t_render = perf::now_ms();
|
||||
let result = runtime
|
||||
.compositor
|
||||
|
|
@ -160,17 +189,15 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
|
|||
&gpu_runtime.context,
|
||||
RenderFrameOptions {
|
||||
frame: &frame,
|
||||
surface: &surface,
|
||||
surface: &runtime.surface,
|
||||
},
|
||||
)
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()));
|
||||
perf::record("wasm.renderFrameToSurface", perf::now_ms() - t_render);
|
||||
result
|
||||
} else {
|
||||
// WebGL surface-renders to the canvas its GL context was created
|
||||
// on; `init_compositor` already pointed `runtime.canvas` at that
|
||||
// same canvas, so presenting writes directly to the canvas the
|
||||
// UI mounts. No intermediate copy.
|
||||
// WebGL still needs a separate composition pass, but the output
|
||||
// surface is now persistent just like the WebGPU path.
|
||||
let t_composite = perf::now_ms();
|
||||
let texture = runtime
|
||||
.compositor
|
||||
|
|
@ -181,9 +208,9 @@ pub fn render_frame(options: JsValue) -> Result<(), JsValue> {
|
|||
let t_present = perf::now_ms();
|
||||
gpu_runtime
|
||||
.context
|
||||
.render_texture_to_gl_canvas_surface(&texture, frame.width, frame.height)
|
||||
.present_texture_to_surface(&texture, &runtime.surface)
|
||||
.map_err(|error| JsValue::from_str(&error.to_string()))?;
|
||||
perf::record("wasm.presentToGlCanvas", perf::now_ms() - t_present);
|
||||
perf::record("wasm.presentToSurface", perf::now_ms() - t_present);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue