summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/app/brand/page.tsx536
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx479
-rw-r--r--apps/web/src/core/managers/audio-manager.ts23
-rw-r--r--apps/web/src/core/managers/playback-manager.ts44
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts12
-rw-r--r--apps/web/src/media/use-file-upload.ts14
-rw-r--r--apps/web/src/preview/components/toolbar.tsx25
-rw-r--r--apps/web/src/preview/controllers/preview-interaction-controller.ts583
-rw-r--r--apps/web/src/preview/controllers/transform-handle-controller.ts753
-rw-r--r--apps/web/src/preview/hooks/use-preview-interaction.ts491
-rw-r--r--apps/web/src/preview/hooks/use-transform-handles.ts639
-rw-r--r--apps/web/src/services/video-cache/service.ts31
-rw-r--r--apps/web/src/timeline/components/index.tsx69
-rw-r--r--apps/web/src/timeline/components/timeline-element.tsx23
-rw-r--r--apps/web/src/timeline/components/timeline-track.tsx26
-rw-r--r--apps/web/src/timeline/controllers/drag-drop-controller.ts577
-rw-r--r--apps/web/src/timeline/controllers/element-interaction-controller.ts691
-rw-r--r--apps/web/src/timeline/controllers/keyframe-drag-controller.ts342
-rw-r--r--apps/web/src/timeline/controllers/playhead-controller.ts314
-rw-r--r--apps/web/src/timeline/controllers/resize-controller.ts329
-rw-r--r--apps/web/src/timeline/controllers/seek-controller.ts210
-rw-r--r--apps/web/src/timeline/controllers/zoom-controller.ts285
-rw-r--r--apps/web/src/timeline/creation.ts17
-rw-r--r--apps/web/src/timeline/drag-data.ts48
-rw-r--r--apps/web/src/timeline/drag-source.ts40
-rw-r--r--apps/web/src/timeline/group-resize/compute-resize.ts58
-rw-r--r--apps/web/src/timeline/group-resize/types.ts4
-rw-r--r--apps/web/src/timeline/hooks/element/use-element-interaction.ts775
-rw-r--r--apps/web/src/timeline/hooks/element/use-keyframe-drag.ts334
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-drag-drop.ts659
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-playhead.ts327
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-resize.ts353
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-seek.ts195
-rw-r--r--apps/web/src/timeline/hooks/use-timeline-zoom.ts277
-rw-r--r--apps/web/src/timeline/types.ts631
35 files changed, 5547 insertions, 4667 deletions
diff --git a/apps/web/src/app/brand/page.tsx b/apps/web/src/app/brand/page.tsx
index a7085154..94e3f3e1 100644
--- a/apps/web/src/app/brand/page.tsx
+++ b/apps/web/src/app/brand/page.tsx
@@ -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>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
index 2f8bc64a..7cf89fbe 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -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;
+}
diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts
index 545565be..d0716aad 100644
--- a/apps/web/src/core/managers/audio-manager.ts
+++ b/apps/web/src/core/managers/audio-manager.ts
@@ -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 {
diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts
index 1ea09826..a243dc19 100644
--- a/apps/web/src/core/managers/playback-manager.ts
+++ b/apps/web/src/core/managers/playback-manager.ts
@@ -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 },
- }),
- );
}
}
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index dbc3332b..6f54c5aa 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -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) {
diff --git a/apps/web/src/media/use-file-upload.ts b/apps/web/src/media/use-file-upload.ts
index 1ee30ad9..c8c62073 100644
--- a/apps/web/src/media/use-file-upload.ts
+++ b/apps/web/src/media/use-file-upload.ts
@@ -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;
diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx
index b44e42ef..783dd239 100644
--- a/apps/web/src/preview/components/toolbar.tsx
+++ b/apps/web/src/preview/components/toolbar.tsx
@@ -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>
);
diff --git a/apps/web/src/preview/controllers/preview-interaction-controller.ts b/apps/web/src/preview/controllers/preview-interaction-controller.ts
new file mode 100644
index 00000000..00573662
--- /dev/null
+++ b/apps/web/src/preview/controllers/preview-interaction-controller.ts
@@ -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,
+ },
+ },
+ },
+ })),
+ );
+ }
+}
diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts
new file mode 100644
index 00000000..e9d251e3
--- /dev/null
+++ b/apps/web/src/preview/controllers/transform-handle-controller.ts
@@ -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,
+ },
+ },
+ },
+ ]);
+ }
+}
diff --git a/apps/web/src/preview/hooks/use-preview-interaction.ts b/apps/web/src/preview/hooks/use-preview-interaction.ts
index 78212e74..d093dcf2 100644
--- a/apps/web/src/preview/hooks/use-preview-interaction.ts
+++ b/apps/web/src/preview/hooks/use-preview-interaction.ts
@@ -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,
},
- [],
- );
-
- const commitTextEdit = useCallback(() => {
- const current = editingTextRef.current;
- if (!current) return;
- editingTextRef.current = null;
- editor.timeline.commitPreview();
- setEditingText(null);
- }, [editor.timeline]);
-
- 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]);
-
- 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,
- });
+ input: {
+ isShiftHeld: () => isShiftHeldRef.current,
},
- [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);
+ scene: {
+ getTracks: () => editor.scenes.getActiveScene().tracks,
+ getCurrentTime: () => editor.playback.getCurrentTime(),
+ getMediaAssets: () => editor.media.getAssets(),
+ getCanvasSize: () => editor.project.getActive().settings.canvasSize,
},
- [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 });
+ selection: {
+ getSelected: () => editor.selection.getSelectedElements(),
+ setSelected: (elements) =>
+ editor.selection.setSelectedElements({ elements: [...elements] }),
+ clearSelection: () => editor.selection.clearSelection(),
},
- [editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
- );
+ 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 handlePointerUp = useCallback(
- ({ type }: React.PointerEvent) => {
- const dragState = dragStateRef.current;
- if (dragState) {
- if (type === "pointercancel") {
- editor.timeline.discardPreview();
- } else {
- editor.timeline.commitPreview();
- }
+ const depsRef = useRef<PreviewInteractionDeps>(deps);
+ depsRef.current = deps;
- dragStateRef.current = null;
- pendingGestureRef.current = null;
- setIsDragging(false);
- onSnapLinesChange?.([]);
- releaseCapturedPointer(dragState);
- return;
- }
+ const controllerRef = useRef<PreviewInteractionController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new PreviewInteractionController({
+ depsRef: depsRef as PreviewInteractionDepsRef,
+ });
+ }
+ const controller = controllerRef.current;
- const pendingGesture = pendingGestureRef.current;
- if (!pendingGesture) return;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(
+ () => controller.subscribe({ listener: rerender }),
+ [controller],
+ );
- if (type !== "pointercancel") {
- const clickTarget = pendingGesture.topmostHit;
- if (!clickTarget) {
- editor.selection.clearSelection();
- } else {
- editor.selection.setSelectedElements({
- elements: [
- {
- trackId: clickTarget.trackId,
- elementId: clickTarget.elementId,
- },
- ],
- });
- }
- }
+ useEffect(() => {
+ if (!controller.isDragging) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isDragging, controller]);
- 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,
};
}
diff --git a/apps/web/src/preview/hooks/use-transform-handles.ts b/apps/web/src/preview/hooks/use-transform-handles.ts
index 030e026c..f4b7e306 100644
--- a/apps/web/src/preview/hooks/use-transform-handles.ts
+++ b/apps/web/src/preview/hooks/use-transform-handles.ts
@@ -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 elementsWithBounds = getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
- });
-
- const selectedWithBounds: ElementWithBounds | null =
- selectedElements.length === 1
- ? (elementsWithBounds.find(
- (entry) =>
- entry.trackId === selectedElements[0].trackId &&
- entry.elementId === selectedElements[0].elementId,
- ) ?? null)
- : null;
-
- 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;
- }, []);
-
- useEffect(() => {
- if (!activeHandle) return;
-
- return registerCanceller({
- fn: () => {
- editor.timeline.discardPreview();
- clearActiveHandleState();
- releaseCapturedPointer();
- },
- });
- }, [
- activeHandle,
- clearActiveHandleState,
- editor.timeline,
- releaseCapturedPointer,
- ]);
-
- 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,
- };
+ const deps: TransformHandleDeps = {
+ viewport,
+ input: {
+ isShiftHeld: () => isShiftHeldRef.current,
},
- [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,
- };
+ scene: {
+ getSelectedElements: () => selectedElements,
+ getTracks: () => tracks,
+ getCurrentTime: () => currentTime,
+ getMediaAssets: () => mediaAssets,
+ getCanvasSize: () => canvasSize,
},
- [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,
- };
+ timeline: {
+ previewElements: (updates) =>
+ editor.timeline.previewElements({ updates }),
+ commitPreview: () => editor.timeline.commitPreview(),
+ discardPreview: () => editor.timeline.discardPreview(),
},
- [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 }),
- });
+ preview: {
+ onSnapLinesChange,
+ },
+ };
- const relevantSnap =
- edge === "right" || edge === "left" ? xSnap : ySnap;
- onSnapLinesChange?.(relevantSnap.activeLines);
+ const depsRef = useRef<TransformHandleDeps>(deps);
+ depsRef.current = deps;
- 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;
- }
+ const controllerRef = useRef<TransformHandleController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new TransformHandleController({ depsRef });
+ }
+ const controller = controllerRef.current;
- if (rotationStateRef.current && activeHandle === "rotation") {
- const {
- trackId,
- elementId,
- initialTransform,
- initialAngle,
- initialBoundsCx,
- initialBoundsCy,
- } = rotationStateRef.current;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- 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 });
+ useEffect(() => {
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller, controller.isActive]);
- editor.timeline.previewElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: { ...initialTransform, rotate: snappedRotation },
- },
- },
- ],
- });
- }
- },
- [
- activeHandle,
- canvasSize,
- editor,
- isShiftHeldRef,
- onSnapLinesChange,
- viewport,
- ],
- );
+ useEffect(() => () => controller.destroy(), [controller]);
- 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,
};
}
diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts
index 31467065..cad4fc31 100644
--- a/apps/web/src/services/video-cache/service.ts
+++ b/apps/web/src/services/video-cache/service.ts
@@ -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 {
diff --git a/apps/web/src/timeline/components/index.tsx b/apps/web/src/timeline/components/index.tsx
index e6a9df49..88fb93b7 100644
--- a/apps/web/src/timeline/components/index.tsx
+++ b/apps/web/src/timeline/components/index.tsx
@@ -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}
diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx
index 74c34607..35785332 100644
--- a/apps/web/src/timeline/components/timeline-element.tsx
+++ b/apps/web/src/timeline/components/timeline-element.tsx
@@ -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,
}}
diff --git a/apps/web/src/timeline/components/timeline-track.tsx b/apps/web/src/timeline/components/timeline-track.tsx
index 6c40637e..7608e6df 100644
--- a/apps/web/src/timeline/components/timeline-track.tsx
+++ b/apps/web/src/timeline/components/timeline-track.tsx
@@ -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}
/>
);
diff --git a/apps/web/src/timeline/controllers/drag-drop-controller.ts b/apps/web/src/timeline/controllers/drag-drop-controller.ts
new file mode 100644
index 00000000..cd03300e
--- /dev/null
+++ b/apps/web/src/timeline/controllers/drag-drop-controller.ts
@@ -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),
+ };
+ },
+ });
+ }
+}
diff --git a/apps/web/src/timeline/controllers/element-interaction-controller.ts b/apps/web/src/timeline/controllers/element-interaction-controller.ts
new file mode 100644
index 00000000..cc19daaa
--- /dev/null
+++ b/apps/web/src/timeline/controllers/element-interaction-controller.ts
@@ -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();
+ };
+}
diff --git a/apps/web/src/timeline/controllers/keyframe-drag-controller.ts b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
new file mode 100644
index 00000000..51c60dee
--- /dev/null
+++ b/apps/web/src/timeline/controllers/keyframe-drag-controller.ts
@@ -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();
+ }
+}
diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts
new file mode 100644
index 00000000..5260c046
--- /dev/null
+++ b/apps/web/src/timeline/controllers/playhead-controller.ts
@@ -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();
+ }
+}
diff --git a/apps/web/src/timeline/controllers/resize-controller.ts b/apps/web/src/timeline/controllers/resize-controller.ts
new file mode 100644
index 00000000..8677dd15
--- /dev/null
+++ b/apps/web/src/timeline/controllers/resize-controller.ts
@@ -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();
+ }
+}
diff --git a/apps/web/src/timeline/controllers/seek-controller.ts b/apps/web/src/timeline/controllers/seek-controller.ts
new file mode 100644
index 00000000..f32c855e
--- /dev/null
+++ b/apps/web/src/timeline/controllers/seek-controller.ts
@@ -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,
+ });
+ }
+}
diff --git a/apps/web/src/timeline/controllers/zoom-controller.ts b/apps/web/src/timeline/controllers/zoom-controller.ts
new file mode 100644
index 00000000..8e0af4fe
--- /dev/null
+++ b/apps/web/src/timeline/controllers/zoom-controller.ts
@@ -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();
+ }
+ }
+}
diff --git a/apps/web/src/timeline/creation.ts b/apps/web/src/timeline/creation.ts
index 2ac7f246..2dec3186 100644
--- a/apps/web/src/timeline/creation.ts
+++ b/apps/web/src/timeline/creation.ts
@@ -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);
+}
diff --git a/apps/web/src/timeline/drag-data.ts b/apps/web/src/timeline/drag-data.ts
deleted file mode 100644
index 1fc9ce9a..00000000
--- a/apps/web/src/timeline/drag-data.ts
+++ /dev/null
@@ -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;
-}
diff --git a/apps/web/src/timeline/drag-source.ts b/apps/web/src/timeline/drag-source.ts
new file mode 100644
index 00000000..503c547b
--- /dev/null
+++ b/apps/web/src/timeline/drag-source.ts
@@ -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;
+ }
+}
diff --git a/apps/web/src/timeline/group-resize/compute-resize.ts b/apps/web/src/timeline/group-resize/compute-resize.ts
index dd542967..d22b6891 100644
--- a/apps/web/src/timeline/group-resize/compute-resize.ts
+++ b/apps/web/src/timeline/group-resize/compute-resize.ts
@@ -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,
diff --git a/apps/web/src/timeline/group-resize/types.ts b/apps/web/src/timeline/group-resize/types.ts
index b07e6fe6..511358ba 100644
--- a/apps/web/src/timeline/group-resize/types.ts
+++ b/apps/web/src/timeline/group-resize/types.ts
@@ -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;
}
diff --git a/apps/web/src/timeline/hooks/element/use-element-interaction.ts b/apps/web/src/timeline/hooks/element/use-element-interaction.ts
index 621259b9..26a21f85 100644
--- a/apps/web/src/timeline/hooks/element/use-element-interaction.ts
+++ b/apps/web/src/timeline/hooks/element/use-element-interaction.ts
@@ -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 { registerCanceller } from "@/editor/cancel-interaction";
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";
+ ElementInteractionController,
+ type ElementInteractionDeps,
+ type ElementInteractionDepsRef,
+} from "@/timeline/controllers/element-interaction-controller";
import type { SnapPoint } from "@/timeline/snapping";
-import { registerCanceller } from "@/editor/cancel-interaction";
-import type {
- DropTarget,
- ElementRef,
- ElementDragState,
- SceneTracks,
- TimelineElement,
- TimelineTrack,
-} from "@/timeline";
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],
- );
-
- useEffect(() => {
- if (!dragState.isDragging && !isPendingDrag) return;
-
- 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,
- };
+ scene: {
+ getTracks: () => editor.scenes.getActiveScene().tracks,
+ getActiveFps: () => editor.project.getActive()?.settings.fps ?? null,
},
- [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);
+ 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),
},
- [zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
- );
+ snap: {
+ isEnabled: () => snappingEnabled,
+ onChange: onSnapPointChange,
+ },
+ };
- const handleElementClick = useCallback(
- ({
- event,
- element,
- track,
- }: {
- event: ReactMouseEvent;
- element: TimelineElement;
- track: TimelineTrack;
- }) => {
- event.stopPropagation();
+ const depsRef = useRef<ElementInteractionDeps>(deps);
+ depsRef.current = deps;
- 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;
- }
- }
+ const controllerRef = useRef<ElementInteractionController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ElementInteractionController({
+ depsRef: depsRef as ElementInteractionDepsRef,
+ });
+ }
+ const controller = controllerRef.current;
- // modifier keys already handled in mousedown
- if (event.metaKey || event.ctrlKey || event.shiftKey) return;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- const alreadySelected = isElementSelected({
- trackId: track.id,
- elementId: element.id,
- });
- if (!alreadySelected || selectedElements.length > 1) {
- selectElement({ trackId: track.id, elementId: element.id });
- return;
- }
+ useEffect(() => {
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isActive, controller]);
- 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,
};
}
diff --git a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
index 92b6f0d8..5a1ff1f0 100644
--- a/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
+++ b/apps/web/src/timeline/hooks/element/use-keyframe-drag.ts
@@ -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;
-}
-
-const initialDragState: KeyframeDragState = {
- isDragging: false,
- draggingKeyframeIds: new Set(),
- deltaTime: 0,
-};
+import {
+ KeyframeDragController,
+ type KeyframeDragConfig,
+ type KeyframeDragState,
+} from "@/timeline/controllers/keyframe-drag-controller";
+import type { TimelineElement } from "@/timeline";
-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,
- ]);
-
- useEffect(() => {
- if (!isPendingDrag) return;
-
- 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;
+ isKeyframeSelected,
+ setKeyframeSelection,
+ toggleKeyframeSelection,
+ selectKeyframeRange,
+ executeCommand: (command) => editor.command.execute({ command }),
+ seek: (args) => editor.playback.seek(args),
+ getTotalDuration: () => editor.timeline.getTotalDuration(),
+ };
- if (wasDrag) return;
+ const configRef = useRef<KeyframeDragConfig>(config);
+ configRef.current = config;
- const duration = editor.timeline.getTotalDuration();
- const seekTime =
- snappedSeekTime({
- time: displayedStartTime + indicatorTime,
- duration,
- rate: fps,
- }) ?? displayedStartTime + indicatorTime;
- editor.playback.seek({ time: seekTime });
+ const controllerRef = useRef<KeyframeDragController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new KeyframeDragController({ configRef });
+ }
+ const controller = controllerRef.current;
- if (event.shiftKey) {
- selectKeyframeRange({
- orderedKeyframes,
- targetKeyframes: keyframes,
- isAdditive: event.metaKey || event.ctrlKey,
- });
- return;
- }
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- toggleKeyframeSelection({
- keyframes,
- isMultiKey: event.metaKey || event.ctrlKey,
- });
- },
- [
- toggleKeyframeSelection,
- selectKeyframeRange,
- editor,
- displayedStartTime,
- fps,
- ],
- );
+ useEffect(() => {
+ if (!controller.isActive) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isActive, controller]);
- 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,
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
index 775b7337..4ec2b80b 100644
--- a/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-drag-drop.ts
@@ -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 getElementType = useCallback(
- ({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
- const dragData = getDragData({ dataTransfer });
- if (!dragData) return null;
-
- 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 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 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 currentTarget = dropTarget;
- setIsDragOver(false);
- setDropTarget(null);
- setElementType(null);
+ const configRef = useRef<DragDropConfig>(config);
+ configRef.current = config;
- try {
- if (hasAsset) {
- if (!currentTarget) return;
- const dragData = getDragData({ dataTransfer: e.dataTransfer });
- if (!dragData) return;
+ const controllerRef = useRef<DragDropController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new DragDropController({ configRef });
+ }
+ const controller = controllerRef.current;
- 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,
},
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-playhead.ts b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
index 7d9cf447..12bfe76a 100644
--- a/apps/web/src/timeline/hooks/use-timeline-playhead.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-playhead.ts
@@ -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);
-
- 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 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;
- 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 handler = (time: number) => ctrl.handlePlaybackUpdate(time);
+ ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
+ const unsubscribeUpdate = editor.playback.onUpdate(handler);
+ const unsubscribeSeek = editor.playback.onSeek(handler);
+ return () => {
+ unsubscribeUpdate();
+ unsubscribeSeek();
};
+ }, [ctrl, editor.playback]);
- const initialTime = editor.playback.getCurrentTime();
- handlePlaybackUpdate({
- detail: { time: initialTime },
- } as CustomEvent<{ time: number }>);
+ useEdgeAutoScroll({
+ isActive: isScrubbing,
+ getMouseClientX: () => ctrl.getLastMouseClientX(),
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: timelineTimeToPixels({
+ time: editor.timeline.getTotalDuration(),
+ zoomLevel,
+ }),
+ });
- window.addEventListener("playback-update", handlePlaybackUpdate);
- window.addEventListener("playback-seek", handlePlaybackUpdate);
- return () => {
- window.removeEventListener("playback-update", handlePlaybackUpdate);
- window.removeEventListener("playback-seek", handlePlaybackUpdate);
- };
- }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
+ useEffect(() => () => ctrl.destroy(), [ctrl]);
return {
- handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
- handleRulerMouseDown: handleRulerMouseDownEvent,
+ handlePlayheadMouseDown: ctrl.onPlayheadMouseDown,
+ handleRulerMouseDown: ctrl.onRulerMouseDown,
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-resize.ts b/apps/web/src/timeline/hooks/use-timeline-resize.ts
index fb8da291..46363fb6 100644
--- a/apps/web/src/timeline/hooks/use-timeline-resize.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-resize.ts
@@ -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";
+
+export type { ResizeSide };
-interface ResizeInteractionState {
- side: ResizeSide;
- startX: number;
- members: GroupResizeMember[];
+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,
- ]);
-
- return {
- isResizing: resizeState !== null,
- handleResizeStart,
};
-}
-
-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);
+ const configRef = useRef(config);
+ configRef.current = config;
- 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 controllerRef = useRef<ResizeController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ResizeController({ configRef });
}
+ const controller = controllerRef.current;
- 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;
+ const [, rerender] = useReducer((n: number) => n + 1, 0);
+ useEffect(() => controller.subscribe(rerender), [controller]);
- 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;
- }
- }
+ useEffect(() => {
+ if (!controller.isResizing) return;
+ return registerCanceller({ fn: () => controller.cancel() });
+ }, [controller.isResizing, controller]);
- onSnapPointChange?.(closestSnapPoint);
- return { deltaTime };
-}
+ useEffect(() => () => controller.destroy(), [controller]);
-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
- );
- });
+ return {
+ isResizing: controller.isResizing,
+ handleResizeStart: controller.onResizeStart,
+ };
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-seek.ts b/apps/web/src/timeline/hooks/use-timeline-seek.ts
index 889177c9..56f68ed6 100644
--- a/apps/web/src/timeline/hooks/use-timeline-seek.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-seek.ts
@@ -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 mouseTrackingRef = useRef({
- isMouseDown: false,
- downX: 0,
- downY: 0,
- downTime: 0,
- });
-
- const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
- if (event.button !== 0) return;
- setMouseTracking({ mouseTrackingRef, event });
- }, []);
-
- 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 });
+ 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 }),
+ };
- if (shouldProcess) {
- clearSelectedElements();
- handleTimelineSeek({ event, source: "tracks" });
- }
- },
- [shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
- );
+ const configRef = useRef(config);
+ configRef.current = config;
- const handleRulerClick = useCallback(
- (event: React.MouseEvent) => {
- const shouldProcess = shouldProcessTimelineClick({ event });
- resetMouseTracking({ mouseTrackingRef });
+ const controllerRef = useRef<SeekController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new SeekController({ configRef });
+ }
+ const controller = controllerRef.current;
- 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,
};
}
diff --git a/apps/web/src/timeline/hooks/use-timeline-zoom.ts b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
index 7929d495..a8825a37 100644
--- a/apps/web/src/timeline/hooks/use-timeline-zoom.ts
+++ b/apps/web/src/timeline/hooks/use-timeline-zoom.ts
@@ -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 [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 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);
+ 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;
- if (isHorizontalScrollGesture) {
- return;
- }
+ const controllerRef = useRef<ZoomController | null>(null);
+ if (!controllerRef.current) {
+ controllerRef.current = new ZoomController({ configRef, initialZoom });
+ }
+ const controller = controllerRef.current;
+ const zoomLevel = controller.zoomLevel;
- // 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,
};
}
diff --git a/apps/web/src/timeline/types.ts b/apps/web/src/timeline/types.ts
index cd543cdf..338da9d9 100644
--- a/apps/web/src/timeline/types.ts
+++ b/apps/web/src/timeline/types.ts
@@ -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;
+}