refactor: thinner hooks
This commit is contained in:
parent
e6c6193638
commit
56ca09692a
|
|
@ -1,6 +1,7 @@
|
||||||
import { getElementKeyframes } from "@/animation";
|
import { getElementKeyframes } from "@/animation";
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
import type { SnapPoint } from "@/timeline/snapping";
|
import type { SnapPoint } from "@/timeline/snapping";
|
||||||
|
import { addMediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function getAnimationKeyframeSnapPointsForTimeline({
|
export function getAnimationKeyframeSnapPointsForTimeline({
|
||||||
tracks,
|
tracks,
|
||||||
|
|
@ -22,7 +23,7 @@ export function getAnimationKeyframeSnapPointsForTimeline({
|
||||||
animations: element.animations,
|
animations: element.animations,
|
||||||
})) {
|
})) {
|
||||||
snapPoints.push({
|
snapPoints.push({
|
||||||
time: element.startTime + keyframe.time,
|
time: addMediaTime({ a: element.startTime, b: keyframe.time }),
|
||||||
type: "keyframe",
|
type: "keyframe",
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
|
|
|
||||||
|
|
@ -1,268 +1,268 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Check, Copy, Download } from "lucide-react";
|
import { Check, Copy, Download } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { BasePage } from "@/app/base-page";
|
import { BasePage } from "@/app/base-page";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
|
|
||||||
function downloadAsset(src: string) {
|
function downloadAsset(src: string) {
|
||||||
const filename = src.split("/").pop() ?? "asset.svg";
|
const filename = src.split("/").pop() ?? "asset.svg";
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = src;
|
a.href = src;
|
||||||
a.download = filename;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyAsset(src: string) {
|
async function copyAsset(src: string) {
|
||||||
const res = await fetch(src);
|
const res = await fetch(src);
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||||
|
|
||||||
type AssetTheme = "dark" | "light" | "icon";
|
type AssetTheme = "dark" | "light" | "icon";
|
||||||
|
|
||||||
interface AssetVariant {
|
interface AssetVariant {
|
||||||
src: string;
|
src: string;
|
||||||
theme: AssetTheme;
|
theme: AssetTheme;
|
||||||
label: string;
|
label: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssetSection {
|
interface AssetSection {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
cols: "1" | "2";
|
cols: "1" | "2";
|
||||||
assets: AssetVariant[];
|
assets: AssetVariant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ASSET_SECTIONS: AssetSection[] = [
|
const ASSET_SECTIONS: AssetSection[] = [
|
||||||
{
|
{
|
||||||
title: "Symbol",
|
title: "Symbol",
|
||||||
description:
|
description:
|
||||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||||
cols: "2",
|
cols: "2",
|
||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/symbol.svg",
|
src: "/logos/opencut/symbol.svg",
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
label: "Symbol",
|
label: "Symbol",
|
||||||
width: 400,
|
width: 400,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/symbol-light.svg",
|
src: "/logos/opencut/symbol-light.svg",
|
||||||
theme: "light",
|
theme: "light",
|
||||||
label: "Symbol",
|
label: "Symbol",
|
||||||
width: 400,
|
width: 400,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Lockup",
|
title: "Lockup",
|
||||||
description:
|
description:
|
||||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||||
cols: "2",
|
cols: "2",
|
||||||
assets: [
|
assets: [
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/logo.svg",
|
src: "/logos/opencut/logo.svg",
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
label: "Logo",
|
label: "Logo",
|
||||||
width: 1809,
|
width: 1809,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/logo-light.svg",
|
src: "/logos/opencut/logo-light.svg",
|
||||||
theme: "light",
|
theme: "light",
|
||||||
label: "Logo",
|
label: "Logo",
|
||||||
width: 1809,
|
width: 1809,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/text.svg",
|
src: "/logos/opencut/text.svg",
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
label: "Text",
|
label: "Text",
|
||||||
width: 1760,
|
width: 1760,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: "/logos/opencut/text-light.svg",
|
src: "/logos/opencut/text-light.svg",
|
||||||
theme: "light",
|
theme: "light",
|
||||||
label: "Text",
|
label: "Text",
|
||||||
width: 1760,
|
width: 1760,
|
||||||
height: 400,
|
height: 400,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BrandPage() {
|
export default function BrandPage() {
|
||||||
return (
|
return (
|
||||||
<BasePage
|
<BasePage
|
||||||
maxWidth="6xl"
|
maxWidth="6xl"
|
||||||
title="Brand"
|
title="Brand"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
Download OpenCut brand assets for use in your projects.{" "}
|
Download OpenCut brand assets for use in your projects.{" "}
|
||||||
<Link
|
<Link
|
||||||
href="#guidelines"
|
href="#guidelines"
|
||||||
className="underline underline-offset-4"
|
className="underline underline-offset-4"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
document
|
document
|
||||||
.getElementById("guidelines")
|
.getElementById("guidelines")
|
||||||
?.scrollIntoView({ behavior: "smooth" })
|
?.scrollIntoView({ behavior: "smooth" })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Read the brand guidelines.
|
Read the brand guidelines.
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="mx-auto gap-2"
|
className="mx-auto gap-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
ALL_ASSETS().forEach((asset, i) => {
|
ALL_ASSETS().forEach((asset, i) => {
|
||||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Download />
|
<Download />
|
||||||
Download all
|
Download all
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-10">
|
||||||
{ASSET_SECTIONS.map((section) => (
|
{ASSET_SECTIONS.map((section) => (
|
||||||
<div key={section.title} className="flex flex-col gap-4">
|
<div key={section.title} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{section.description}
|
{section.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-3",
|
"grid gap-3",
|
||||||
section.cols === "2"
|
section.cols === "2"
|
||||||
? "grid-cols-1 sm:grid-cols-2"
|
? "grid-cols-1 sm:grid-cols-2"
|
||||||
: "grid-cols-1",
|
: "grid-cols-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{section.assets.map((variant) => (
|
{section.assets.map((variant) => (
|
||||||
<AssetCard key={variant.src} variant={variant} />
|
<AssetCard key={variant.src} variant={variant} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2 className="font-semibold text-lg">Usage</h2>
|
<h2 className="font-semibold text-lg">Usage</h2>
|
||||||
<p className="text-muted-foreground text-base leading-relaxed">
|
<p className="text-muted-foreground text-base leading-relaxed">
|
||||||
OpenCut is open source — the code is free to use under its license.
|
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
|
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
|
OpenCut, that your project integrates with OpenCut, or that it was
|
||||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||||
we made or endorse your product, or use the marks commercially
|
we made or endorse your product, or use the marks commercially
|
||||||
without asking first. For anything unclear, reach out at{" "}
|
without asking first. For anything unclear, reach out at{" "}
|
||||||
<Link
|
<Link
|
||||||
href="mailto:brand@opencut.app"
|
href="mailto:brand@opencut.app"
|
||||||
className="underline underline-offset-4"
|
className="underline underline-offset-4"
|
||||||
>
|
>
|
||||||
brand@opencut.app
|
brand@opencut.app
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
<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">
|
<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.",
|
"Using OpenCut in the name of your product, service, or domain.",
|
||||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||||
"Using the logo or name on merchandise or commercial marketing.",
|
"Using the logo or name on merchandise or commercial marketing.",
|
||||||
"Modifying the marks.",
|
"Modifying the marks.",
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<li key={item} className="flex gap-2">
|
<li key={item} className="flex gap-2">
|
||||||
<span className="mt-0.5 shrink-0">-</span>
|
<span className="mt-0.5 shrink-0">-</span>
|
||||||
<span>{item}</span>
|
<span>{item}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BasePage>
|
</BasePage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||||
light: {
|
light: {
|
||||||
backgroundImage:
|
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%)",
|
"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",
|
backgroundSize: "18px 18px",
|
||||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||||
backgroundColor: "#000",
|
backgroundColor: "#000",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
backgroundImage:
|
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%)",
|
"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",
|
backgroundSize: "18px 18px",
|
||||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||||
backgroundColor: "#f5f5f5",
|
backgroundColor: "#f5f5f5",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
async function handleCopy() {
|
async function handleCopy() {
|
||||||
await copyAsset(variant.src);
|
await copyAsset(variant.src);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="group relative overflow-hidden"
|
className="group relative overflow-hidden"
|
||||||
style={
|
style={
|
||||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||||
<Image
|
<Image
|
||||||
src={variant.src}
|
src={variant.src}
|
||||||
alt={variant.label}
|
alt={variant.label}
|
||||||
width={variant.width}
|
width={variant.width}
|
||||||
height={variant.height}
|
height={variant.height}
|
||||||
className="max-h-16 w-auto select-none object-contain"
|
className="max-h-16 w-auto select-none object-contain"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
unoptimized
|
unoptimized
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
{copied ? <Check /> : <Copy />}
|
{copied ? <Check /> : <Copy />}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm";
|
import {
|
||||||
|
formatTimecode,
|
||||||
|
type FrameRate,
|
||||||
|
type TimeCodeFormat,
|
||||||
|
} from "opencut-wasm";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import type { MediaTime } from "@/wasm";
|
import {
|
||||||
|
parseMediaTimecode,
|
||||||
|
snapSeekMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
} from "@/wasm";
|
||||||
|
|
||||||
interface EditableTimecodeProps {
|
interface EditableTimecodeProps {
|
||||||
time: MediaTime;
|
time: MediaTime;
|
||||||
|
|
@ -47,19 +55,20 @@ export function EditableTimecode({
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyEdit = () => {
|
const applyEdit = () => {
|
||||||
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
|
const parsedTime = parseMediaTimecode({
|
||||||
|
timeCode: inputValue,
|
||||||
|
format,
|
||||||
|
fps,
|
||||||
|
});
|
||||||
|
|
||||||
if (parsedTime == null) {
|
if (parsedTime == null) {
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clampedTime = (
|
const clampedTime = duration
|
||||||
duration
|
? snapSeekMediaTime({ time: parsedTime, duration, fps })
|
||||||
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ??
|
: parsedTime;
|
||||||
parsedTime)
|
|
||||||
: parsedTime
|
|
||||||
) as MediaTime;
|
|
||||||
|
|
||||||
onTimeChange?.({ time: clampedTime });
|
onTimeChange?.({ time: clampedTime });
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import {
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { clearDragData, setDragData } from "@/timeline/drag-data";
|
|
||||||
import type { TimelineDragData } from "@/timeline/drag";
|
import type { TimelineDragData } from "@/timeline/drag";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import type { MediaTime } from "@/wasm";
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
@ -74,21 +73,23 @@ export function DraggableItem({
|
||||||
};
|
};
|
||||||
}, [isDragging]);
|
}, [isDragging]);
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent) => {
|
const handleDragStart = (event: React.DragEvent) => {
|
||||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
event.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||||
|
|
||||||
setDragData({ dataTransfer: e.dataTransfer, dragData });
|
editor.timeline.dragSource.begin({
|
||||||
e.dataTransfer.effectAllowed = "copy";
|
dataTransfer: event.dataTransfer,
|
||||||
|
dragData,
|
||||||
|
});
|
||||||
|
|
||||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
setDragPosition({ x: event.clientX, y: event.clientY });
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
|
|
||||||
onDragStart?.({ e });
|
onDragStart?.({ e: event });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
clearDragData();
|
editor.timeline.dragSource.end();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import type { EditorCore } from "@/core";
|
import type { EditorCore } from "@/core";
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
import { TICKS_PER_SECOND } from "@/wasm";
|
||||||
import {
|
import { clampRetimeRate, shouldMaintainPitch } from "@/retime/rate";
|
||||||
clampRetimeRate,
|
|
||||||
shouldMaintainPitch,
|
|
||||||
} from "@/retime/rate";
|
|
||||||
import type { AudioClipSource } from "@/media/audio";
|
import type { AudioClipSource } from "@/media/audio";
|
||||||
import { createAudioContext, collectAudioClips } from "@/media/audio";
|
import { createAudioContext, collectAudioClips } from "@/media/audio";
|
||||||
import {
|
import {
|
||||||
|
|
@ -56,10 +53,8 @@ export class AudioManager {
|
||||||
this.editor.playback.subscribe(this.handlePlaybackChange),
|
this.editor.playback.subscribe(this.handlePlaybackChange),
|
||||||
this.editor.timeline.subscribe(this.handleTimelineChange),
|
this.editor.timeline.subscribe(this.handleTimelineChange),
|
||||||
this.editor.media.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 {
|
dispose(): void {
|
||||||
|
|
@ -68,9 +63,6 @@ export class AudioManager {
|
||||||
unsub();
|
unsub();
|
||||||
}
|
}
|
||||||
this.unsubscribers = [];
|
this.unsubscribers = [];
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.removeEventListener("playback-seek", this.handleSeek);
|
|
||||||
}
|
|
||||||
this.disposeSinks();
|
this.disposeSinks();
|
||||||
this.preparedClipBuffers.clear();
|
this.preparedClipBuffers.clear();
|
||||||
this.decodedBuffers.clear();
|
this.decodedBuffers.clear();
|
||||||
|
|
@ -102,17 +94,14 @@ export class AudioManager {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleSeek = (event: Event): void => {
|
private handleSeek = (time: number): void => {
|
||||||
const detail = (event as CustomEvent<{ time: number }>).detail;
|
|
||||||
if (!detail) return;
|
|
||||||
|
|
||||||
if (this.editor.playback.getIsScrubbing()) {
|
if (this.editor.playback.getIsScrubbing()) {
|
||||||
this.stopPlayback();
|
this.stopPlayback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.editor.playback.getIsPlaying()) {
|
if (this.editor.playback.getIsPlaying()) {
|
||||||
void this.startPlayback({ time: detail.time / TICKS_PER_SECOND });
|
void this.startPlayback({ time: time / TICKS_PER_SECOND });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,7 +115,9 @@ export class AudioManager {
|
||||||
|
|
||||||
if (!this.editor.playback.getIsPlaying()) return;
|
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 {
|
private ensureAudioContext(): AudioContext | null {
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import {
|
||||||
clampMediaTime,
|
clampMediaTime,
|
||||||
type MediaTime,
|
type MediaTime,
|
||||||
mediaTimeFromSeconds,
|
mediaTimeFromSeconds,
|
||||||
|
roundFrameTime,
|
||||||
ZERO_MEDIA_TIME,
|
ZERO_MEDIA_TIME,
|
||||||
} from "@/wasm";
|
} from "@/wasm";
|
||||||
import { roundToFrame } from "opencut-wasm";
|
|
||||||
|
|
||||||
export class PlaybackManager {
|
export class PlaybackManager {
|
||||||
private isPlaying = false;
|
private isPlaying = false;
|
||||||
|
|
@ -16,6 +16,8 @@ export class PlaybackManager {
|
||||||
private previousVolume = 1;
|
private previousVolume = 1;
|
||||||
private isScrubbing = false;
|
private isScrubbing = false;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
|
private updateListeners = new Set<(time: MediaTime) => void>();
|
||||||
|
private seekListeners = new Set<(time: MediaTime) => void>();
|
||||||
private playbackTimer: number | null = null;
|
private playbackTimer: number | null = null;
|
||||||
private playbackStartWallTime = 0;
|
private playbackStartWallTime = 0;
|
||||||
private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
|
private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
|
||||||
|
|
@ -139,6 +141,16 @@ export class PlaybackManager {
|
||||||
return () => this.listeners.delete(listener);
|
return () => this.listeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onUpdate(listener: (time: MediaTime) => void): () => void {
|
||||||
|
this.updateListeners.add(listener);
|
||||||
|
return () => this.updateListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSeek(listener: (time: MediaTime) => void): () => void {
|
||||||
|
this.seekListeners.add(listener);
|
||||||
|
return () => this.seekListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
private reconcileTimelineScope(): void {
|
private reconcileTimelineScope(): void {
|
||||||
const maxTime = this.editor.timeline.getTotalDuration();
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
const nextTime = this.clampTimeToTimeline(this.currentTime);
|
const nextTime = this.clampTimeToTimeline(this.currentTime);
|
||||||
|
|
@ -158,6 +170,7 @@ export class PlaybackManager {
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
if (timeChanged) {
|
if (timeChanged) {
|
||||||
|
this.notifySeek(this.currentTime);
|
||||||
this.dispatchSeekEvent(this.currentTime);
|
this.dispatchSeekEvent(this.currentTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +181,18 @@ export class PlaybackManager {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private notifyUpdate(time: MediaTime): void {
|
||||||
|
this.updateListeners.forEach((fn) => {
|
||||||
|
fn(time);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifySeek(time: MediaTime): void {
|
||||||
|
this.seekListeners.forEach((fn) => {
|
||||||
|
fn(time);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private startTimer(): void {
|
private startTimer(): void {
|
||||||
if (this.playbackTimer) {
|
if (this.playbackTimer) {
|
||||||
cancelAnimationFrame(this.playbackTimer);
|
cancelAnimationFrame(this.playbackTimer);
|
||||||
|
|
@ -195,20 +220,20 @@ export class PlaybackManager {
|
||||||
a: this.playbackStartTime,
|
a: this.playbackStartTime,
|
||||||
b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
|
b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
|
||||||
});
|
});
|
||||||
const newTime = (fps
|
const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime;
|
||||||
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
|
|
||||||
: rawTime) as MediaTime;
|
|
||||||
const maxTime = this.editor.timeline.getTotalDuration();
|
const maxTime = this.editor.timeline.getTotalDuration();
|
||||||
|
|
||||||
if (newTime >= maxTime) {
|
if (newTime >= maxTime) {
|
||||||
this.pause();
|
this.pause();
|
||||||
this.currentTime = maxTime;
|
this.currentTime = maxTime;
|
||||||
this.notify();
|
this.notify();
|
||||||
|
this.notifySeek(maxTime);
|
||||||
this.dispatchSeekEvent(maxTime);
|
this.dispatchSeekEvent(maxTime);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentTime = newTime;
|
this.currentTime = newTime;
|
||||||
|
this.notifyUpdate(newTime);
|
||||||
this.dispatchUpdateEvent(newTime);
|
this.dispatchUpdateEvent(newTime);
|
||||||
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@ import type {
|
||||||
RetimeConfig,
|
RetimeConfig,
|
||||||
} from "@/timeline";
|
} from "@/timeline";
|
||||||
import { calculateTotalDuration } from "@/timeline";
|
import { calculateTotalDuration } from "@/timeline";
|
||||||
|
import { TimelineDragSource } from "@/timeline/drag-source";
|
||||||
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
import { findTrackInSceneTracks } from "@/timeline/track-element-update";
|
||||||
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
import { lastFrameMediaTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
import {
|
import {
|
||||||
canElementBeHidden,
|
canElementBeHidden,
|
||||||
canElementHaveAudio,
|
canElementHaveAudio,
|
||||||
|
|
@ -27,7 +28,6 @@ import {
|
||||||
resolveAnimationTarget,
|
resolveAnimationTarget,
|
||||||
resolveAnimationPathValueAtTime,
|
resolveAnimationPathValueAtTime,
|
||||||
} from "@/animation";
|
} from "@/animation";
|
||||||
import { lastFrameTime } from "opencut-wasm";
|
|
||||||
import { BatchCommand } from "@/commands";
|
import { BatchCommand } from "@/commands";
|
||||||
import {
|
import {
|
||||||
AddTrackCommand,
|
AddTrackCommand,
|
||||||
|
|
@ -68,6 +68,7 @@ export class TimelineManager {
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private previewOverlay = new Map<string, Partial<TimelineElement>>();
|
private previewOverlay = new Map<string, Partial<TimelineElement>>();
|
||||||
private previewTracks: SceneTracks | null = null;
|
private previewTracks: SceneTracks | null = null;
|
||||||
|
public readonly dragSource = new TimelineDragSource();
|
||||||
|
|
||||||
constructor(private editor: EditorCore) {}
|
constructor(private editor: EditorCore) {}
|
||||||
|
|
||||||
|
|
@ -214,7 +215,7 @@ export class TimelineManager {
|
||||||
const duration = this.getTotalDuration();
|
const duration = this.getTotalDuration();
|
||||||
const fps = this.editor.project.getActive()?.settings.fps;
|
const fps = this.editor.project.getActive()?.settings.fps;
|
||||||
if (!fps || duration <= 0) return duration;
|
if (!fps || duration <= 0) return duration;
|
||||||
return (lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime;
|
return lastFrameMediaTime({ duration, fps });
|
||||||
}
|
}
|
||||||
|
|
||||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||||
|
|
@ -707,11 +708,11 @@ export class TimelineManager {
|
||||||
previewElements({
|
previewElements({
|
||||||
updates,
|
updates,
|
||||||
}: {
|
}: {
|
||||||
updates: Array<{
|
updates: readonly {
|
||||||
trackId: string;
|
trackId: string;
|
||||||
elementId: string;
|
elementId: string;
|
||||||
updates: Partial<TimelineElement>;
|
updates: Partial<TimelineElement>;
|
||||||
}>;
|
}[];
|
||||||
}): void {
|
}): void {
|
||||||
let changedOverlayCount = 0;
|
let changedOverlayCount = 0;
|
||||||
for (const { elementId, updates: elementUpdates } of updates) {
|
for (const { elementId, updates: elementUpdates } of updates) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { hasDragData } from "@/timeline/drag-data";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
|
||||||
interface UseFileUploadOptions {
|
interface UseFileUploadOptions {
|
||||||
accept?: string;
|
accept?: string;
|
||||||
|
|
@ -7,19 +7,23 @@ interface UseFileUploadOptions {
|
||||||
onFilesSelected?: (files: File[]) => void;
|
onFilesSelected?: (files: File[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
|
||||||
return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFileUpload({
|
export function useFileUpload({
|
||||||
accept,
|
accept,
|
||||||
multiple,
|
multiple,
|
||||||
onFilesSelected,
|
onFilesSelected,
|
||||||
}: UseFileUploadOptions = {}) {
|
}: UseFileUploadOptions = {}) {
|
||||||
|
const editor = useEditor();
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||||
|
return (
|
||||||
|
!editor.timeline.dragSource.isActive() &&
|
||||||
|
dataTransfer.types.includes("Files")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function openFilePicker() {
|
function openFilePicker() {
|
||||||
if (!inputRef.current) return;
|
if (!inputRef.current) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,15 +73,13 @@ function TimecodeDisplay() {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: Event) =>
|
const unsubscribeUpdate = editor.playback.onUpdate(setCurrentTime);
|
||||||
setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time);
|
const unsubscribeSeek = editor.playback.onSeek(setCurrentTime);
|
||||||
window.addEventListener("playback-update", handler);
|
|
||||||
window.addEventListener("playback-seek", handler);
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("playback-update", handler);
|
unsubscribeUpdate();
|
||||||
window.removeEventListener("playback-seek", handler);
|
unsubscribeSeek();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [editor.playback]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,583 @@
|
||||||
|
import type {
|
||||||
|
MouseEvent as ReactMouseEvent,
|
||||||
|
PointerEvent as ReactPointerEvent,
|
||||||
|
} from "react";
|
||||||
|
import type { MediaAsset } from "@/media/types";
|
||||||
|
import {
|
||||||
|
getVisibleElementsWithBounds,
|
||||||
|
type ElementWithBounds,
|
||||||
|
} from "@/preview/element-bounds";
|
||||||
|
import {
|
||||||
|
getHitElements,
|
||||||
|
hitTest,
|
||||||
|
resolvePreferredHit,
|
||||||
|
} from "@/preview/hit-test";
|
||||||
|
import {
|
||||||
|
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||||
|
snapPosition,
|
||||||
|
type SnapLine,
|
||||||
|
} from "@/preview/preview-snap";
|
||||||
|
import type { TCanvasSize } from "@/project/types";
|
||||||
|
import type { Transform } from "@/rendering";
|
||||||
|
import { isVisualElement } from "@/timeline/element-utils";
|
||||||
|
import type {
|
||||||
|
ElementRef,
|
||||||
|
SceneTracks,
|
||||||
|
TextElement,
|
||||||
|
TimelineElement,
|
||||||
|
TimelineTrack,
|
||||||
|
VisualElement,
|
||||||
|
} from "@/timeline";
|
||||||
|
|
||||||
|
const MIN_DRAG_DISTANCE = 0.5;
|
||||||
|
const PRIMARY_POINTER_BUTTON = 0;
|
||||||
|
|
||||||
|
type Point = { readonly x: number; readonly y: number };
|
||||||
|
|
||||||
|
interface CapturedPointerState {
|
||||||
|
readonly pointerId: number;
|
||||||
|
readonly captureTarget: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingGesture extends CapturedPointerState {
|
||||||
|
readonly kind: "pending";
|
||||||
|
readonly origin: Point;
|
||||||
|
readonly topmostHit: ElementWithBounds | null;
|
||||||
|
readonly selectedHit: ElementWithBounds | null;
|
||||||
|
readonly selectedElements: readonly ElementRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragElementSnapshot {
|
||||||
|
readonly trackId: string;
|
||||||
|
readonly elementId: string;
|
||||||
|
readonly initialTransform: Transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraggingGesture extends CapturedPointerState {
|
||||||
|
readonly kind: "dragging";
|
||||||
|
readonly origin: Point;
|
||||||
|
readonly bounds: {
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly rotation: number;
|
||||||
|
};
|
||||||
|
readonly elements: readonly DragElementSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type GestureSession =
|
||||||
|
| { readonly kind: "idle" }
|
||||||
|
| PendingGesture
|
||||||
|
| DraggingGesture;
|
||||||
|
|
||||||
|
const IDLE_GESTURE: GestureSession = { kind: "idle" };
|
||||||
|
|
||||||
|
export interface EditingTextState {
|
||||||
|
readonly trackId: string;
|
||||||
|
readonly elementId: string;
|
||||||
|
readonly element: TextElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewViewportAdapter {
|
||||||
|
screenToCanvas: ({
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
}: {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}) => Point | null;
|
||||||
|
screenPixelsToLogicalThreshold: ({
|
||||||
|
screenPixels,
|
||||||
|
}: {
|
||||||
|
screenPixels: number;
|
||||||
|
}) => Point;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputAdapter {
|
||||||
|
isShiftHeld: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneReader {
|
||||||
|
getTracks: () => SceneTracks;
|
||||||
|
getCurrentTime: () => number;
|
||||||
|
getMediaAssets: () => MediaAsset[];
|
||||||
|
getCanvasSize: () => TCanvasSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectionApi {
|
||||||
|
getSelected: () => readonly ElementRef[];
|
||||||
|
setSelected: (elements: readonly ElementRef[]) => void;
|
||||||
|
clearSelection: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelinePreviewUpdate {
|
||||||
|
readonly trackId: string;
|
||||||
|
readonly elementId: string;
|
||||||
|
readonly updates: Partial<TimelineElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineOps {
|
||||||
|
getElementsWithTracks: ({
|
||||||
|
elements,
|
||||||
|
}: {
|
||||||
|
elements: readonly ElementRef[];
|
||||||
|
}) => Array<{ track: TimelineTrack; element: TimelineElement }>;
|
||||||
|
previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
|
||||||
|
commitPreview: () => void;
|
||||||
|
discardPreview: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaybackApi {
|
||||||
|
getIsPlaying: () => boolean;
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewOptions {
|
||||||
|
isMaskMode: () => boolean;
|
||||||
|
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewInteractionDeps {
|
||||||
|
viewport: PreviewViewportAdapter;
|
||||||
|
input: InputAdapter;
|
||||||
|
scene: SceneReader;
|
||||||
|
selection: SelectionApi;
|
||||||
|
timeline: TimelineOps;
|
||||||
|
playback: PlaybackApi;
|
||||||
|
preview: PreviewOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewInteractionDepsRef {
|
||||||
|
readonly current: PreviewInteractionDeps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameElementRef({
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
}: {
|
||||||
|
left: ElementRef;
|
||||||
|
right: ElementRef;
|
||||||
|
}): boolean {
|
||||||
|
return left.trackId === right.trackId && left.elementId === right.elementId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDragSelection({
|
||||||
|
selectedElements,
|
||||||
|
dragTarget,
|
||||||
|
}: {
|
||||||
|
selectedElements: readonly ElementRef[];
|
||||||
|
dragTarget: ElementWithBounds;
|
||||||
|
}): ElementRef[] {
|
||||||
|
const dragTargetRef = {
|
||||||
|
trackId: dragTarget.trackId,
|
||||||
|
elementId: dragTarget.elementId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
!selectedElements.some((selectedElement) =>
|
||||||
|
isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return [dragTargetRef];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
dragTargetRef,
|
||||||
|
...selectedElements.filter(
|
||||||
|
(selectedElement) =>
|
||||||
|
!isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function movedPastDragThreshold({
|
||||||
|
current,
|
||||||
|
origin,
|
||||||
|
}: {
|
||||||
|
current: Point;
|
||||||
|
origin: Point;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
Math.abs(current.x - origin.x) > MIN_DRAG_DISTANCE ||
|
||||||
|
Math.abs(current.y - origin.y) > MIN_DRAG_DISTANCE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDragElementSnapshots({
|
||||||
|
elementsWithTracks,
|
||||||
|
}: {
|
||||||
|
elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>;
|
||||||
|
}): DragElementSnapshot[] {
|
||||||
|
const isVisualTrackedElement = (value: {
|
||||||
|
track: TimelineTrack;
|
||||||
|
element: TimelineElement;
|
||||||
|
}): value is { track: TimelineTrack; element: VisualElement } =>
|
||||||
|
isVisualElement(value.element);
|
||||||
|
|
||||||
|
return elementsWithTracks
|
||||||
|
.filter(isVisualTrackedElement)
|
||||||
|
.map(({ track, element }) => ({
|
||||||
|
trackId: track.id,
|
||||||
|
elementId: element.id,
|
||||||
|
initialTransform: element.transform,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PreviewInteractionController {
|
||||||
|
private readonly depsRef: PreviewInteractionDepsRef;
|
||||||
|
private readonly subscribers = new Set<() => void>();
|
||||||
|
|
||||||
|
private gesture: GestureSession = IDLE_GESTURE;
|
||||||
|
private editingTextState: EditingTextState | null = null;
|
||||||
|
private wasPlaying: boolean;
|
||||||
|
private unsubscribePlayback: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor({ depsRef }: { depsRef: PreviewInteractionDepsRef }) {
|
||||||
|
this.depsRef = depsRef;
|
||||||
|
this.wasPlaying = this.deps.playback.getIsPlaying();
|
||||||
|
|
||||||
|
this.onDoubleClick = this.onDoubleClick.bind(this);
|
||||||
|
this.onPointerDown = this.onPointerDown.bind(this);
|
||||||
|
this.onPointerMove = this.onPointerMove.bind(this);
|
||||||
|
this.onPointerUp = this.onPointerUp.bind(this);
|
||||||
|
this.commitTextEdit = this.commitTextEdit.bind(this);
|
||||||
|
this.handlePlaybackChange = this.handlePlaybackChange.bind(this);
|
||||||
|
|
||||||
|
this.unsubscribePlayback = this.deps.playback.subscribe(
|
||||||
|
this.handlePlaybackChange,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get deps(): PreviewInteractionDeps {
|
||||||
|
return this.depsRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isDragging(): boolean {
|
||||||
|
return this.gesture.kind === "dragging";
|
||||||
|
}
|
||||||
|
|
||||||
|
get editingText(): EditingTextState | null {
|
||||||
|
return this.editingTextState;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe({ listener }: { listener: () => void }): () => void {
|
||||||
|
this.subscribers.add(listener);
|
||||||
|
return () => this.subscribers.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.unsubscribePlayback?.();
|
||||||
|
this.unsubscribePlayback = null;
|
||||||
|
this.abortActiveGesture();
|
||||||
|
this.editingTextState = null;
|
||||||
|
this.subscribers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
if (this.gesture.kind === "idle") return;
|
||||||
|
this.abortActiveGesture();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private abortActiveGesture(): void {
|
||||||
|
if (this.gesture.kind === "idle") return;
|
||||||
|
|
||||||
|
if (this.gesture.kind === "dragging") {
|
||||||
|
this.deps.timeline.discardPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.releaseCapturedPointer({ pointerState: this.gesture });
|
||||||
|
this.gesture = IDLE_GESTURE;
|
||||||
|
this.clearSnapLines();
|
||||||
|
}
|
||||||
|
|
||||||
|
commitTextEdit(): void {
|
||||||
|
if (!this.editingTextState) return;
|
||||||
|
|
||||||
|
this.editingTextState = null;
|
||||||
|
this.deps.timeline.commitPreview();
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDoubleClick({ clientX, clientY }: ReactMouseEvent): void {
|
||||||
|
if (this.editingTextState || this.deps.preview.isMaskMode()) return;
|
||||||
|
|
||||||
|
const startPos = this.deps.viewport.screenToCanvas({
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
});
|
||||||
|
if (!startPos) return;
|
||||||
|
|
||||||
|
const hit = hitTest({
|
||||||
|
canvasX: startPos.x,
|
||||||
|
canvasY: startPos.y,
|
||||||
|
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hit || hit.element.type !== "text") return;
|
||||||
|
|
||||||
|
this.editingTextState = {
|
||||||
|
trackId: hit.trackId,
|
||||||
|
elementId: hit.elementId,
|
||||||
|
element: hit.element,
|
||||||
|
};
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerDown({
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
currentTarget,
|
||||||
|
pointerId,
|
||||||
|
button,
|
||||||
|
}: ReactPointerEvent): void {
|
||||||
|
if (this.editingTextState) return;
|
||||||
|
if (this.deps.preview.isMaskMode()) return;
|
||||||
|
if (button !== PRIMARY_POINTER_BUTTON) return;
|
||||||
|
|
||||||
|
const startPos = this.deps.viewport.screenToCanvas({
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
});
|
||||||
|
if (!startPos) return;
|
||||||
|
|
||||||
|
const hits = getHitElements({
|
||||||
|
canvasX: startPos.x,
|
||||||
|
canvasY: startPos.y,
|
||||||
|
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||||
|
});
|
||||||
|
const selectedElements = this.deps.selection.getSelected();
|
||||||
|
|
||||||
|
this.gesture = {
|
||||||
|
kind: "pending",
|
||||||
|
origin: startPos,
|
||||||
|
pointerId,
|
||||||
|
captureTarget: currentTarget as HTMLElement,
|
||||||
|
topmostHit: hits[0] ?? null,
|
||||||
|
selectedHit: resolvePreferredHit({
|
||||||
|
hits,
|
||||||
|
preferredElements: [...selectedElements],
|
||||||
|
}),
|
||||||
|
selectedElements,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentTarget.setPointerCapture(pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove({ clientX, clientY }: ReactPointerEvent): void {
|
||||||
|
const currentPos = this.deps.viewport.screenToCanvas({
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
});
|
||||||
|
if (!currentPos) return;
|
||||||
|
|
||||||
|
if (this.gesture.kind === "pending") {
|
||||||
|
const pending = this.gesture;
|
||||||
|
if (
|
||||||
|
!movedPastDragThreshold({
|
||||||
|
current: currentPos,
|
||||||
|
origin: pending.origin,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
this.clearSnapLines();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.beginDragFromPending({ pending });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gesture.kind !== "dragging") return;
|
||||||
|
|
||||||
|
this.updateDragPreview({
|
||||||
|
drag: this.gesture,
|
||||||
|
currentPos,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp({ type }: ReactPointerEvent): void {
|
||||||
|
if (this.gesture.kind === "dragging") {
|
||||||
|
const drag = this.gesture;
|
||||||
|
|
||||||
|
if (type === "pointercancel") {
|
||||||
|
this.deps.timeline.discardPreview();
|
||||||
|
} else {
|
||||||
|
this.deps.timeline.commitPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gesture = IDLE_GESTURE;
|
||||||
|
this.clearSnapLines();
|
||||||
|
this.releaseCapturedPointer({ pointerState: drag });
|
||||||
|
this.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.gesture.kind !== "pending") return;
|
||||||
|
|
||||||
|
const pending = this.gesture;
|
||||||
|
|
||||||
|
if (type !== "pointercancel") {
|
||||||
|
const clickTarget = pending.topmostHit;
|
||||||
|
if (!clickTarget) {
|
||||||
|
this.deps.selection.clearSelection();
|
||||||
|
} else {
|
||||||
|
this.deps.selection.setSelected([
|
||||||
|
{
|
||||||
|
trackId: clickTarget.trackId,
|
||||||
|
elementId: clickTarget.elementId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gesture = IDLE_GESTURE;
|
||||||
|
this.clearSnapLines();
|
||||||
|
this.releaseCapturedPointer({ pointerState: pending });
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const listener of this.subscribers) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSnapLines(): void {
|
||||||
|
this.deps.preview.onSnapLinesChange?.([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseCapturedPointer({
|
||||||
|
pointerState,
|
||||||
|
}: {
|
||||||
|
pointerState: CapturedPointerState | null;
|
||||||
|
}): void {
|
||||||
|
if (!pointerState) return;
|
||||||
|
|
||||||
|
if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVisibleElementsWithBounds(): ElementWithBounds[] {
|
||||||
|
return getVisibleElementsWithBounds({
|
||||||
|
tracks: this.deps.scene.getTracks(),
|
||||||
|
currentTime: this.deps.scene.getCurrentTime(),
|
||||||
|
canvasSize: this.deps.scene.getCanvasSize(),
|
||||||
|
mediaAssets: this.deps.scene.getMediaAssets(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePlaybackChange(): void {
|
||||||
|
const isPlaying = this.deps.playback.getIsPlaying();
|
||||||
|
if (isPlaying && !this.wasPlaying && this.editingTextState) {
|
||||||
|
this.commitTextEdit();
|
||||||
|
}
|
||||||
|
this.wasPlaying = isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginDragFromPending({ pending }: { pending: PendingGesture }): void {
|
||||||
|
const dragTarget = pending.selectedHit ?? pending.topmostHit;
|
||||||
|
if (!dragTarget) {
|
||||||
|
this.gesture = IDLE_GESTURE;
|
||||||
|
this.clearSnapLines();
|
||||||
|
this.releaseCapturedPointer({ pointerState: pending });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragSelection = buildDragSelection({
|
||||||
|
selectedElements: pending.selectedElements,
|
||||||
|
dragTarget,
|
||||||
|
});
|
||||||
|
const draggableElements = toDragElementSnapshots({
|
||||||
|
elementsWithTracks: this.deps.timeline.getElementsWithTracks({
|
||||||
|
elements: dragSelection,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (draggableElements.length === 0) {
|
||||||
|
this.gesture = IDLE_GESTURE;
|
||||||
|
this.clearSnapLines();
|
||||||
|
this.releaseCapturedPointer({ pointerState: pending });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending.selectedHit === null) {
|
||||||
|
this.deps.selection.setSelected([
|
||||||
|
{
|
||||||
|
trackId: dragTarget.trackId,
|
||||||
|
elementId: dragTarget.elementId,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.gesture = {
|
||||||
|
kind: "dragging",
|
||||||
|
origin: pending.origin,
|
||||||
|
pointerId: pending.pointerId,
|
||||||
|
captureTarget: pending.captureTarget,
|
||||||
|
bounds: {
|
||||||
|
width: dragTarget.bounds.width,
|
||||||
|
height: dragTarget.bounds.height,
|
||||||
|
rotation: dragTarget.bounds.rotation,
|
||||||
|
},
|
||||||
|
elements: draggableElements,
|
||||||
|
};
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDragPreview({
|
||||||
|
drag,
|
||||||
|
currentPos,
|
||||||
|
}: {
|
||||||
|
drag: DraggingGesture;
|
||||||
|
currentPos: Point;
|
||||||
|
}): void {
|
||||||
|
const firstElement = drag.elements[0];
|
||||||
|
if (!firstElement) return;
|
||||||
|
|
||||||
|
const deltaX = currentPos.x - drag.origin.x;
|
||||||
|
const deltaY = currentPos.y - drag.origin.y;
|
||||||
|
|
||||||
|
const proposedPosition = {
|
||||||
|
x: firstElement.initialTransform.position.x + deltaX,
|
||||||
|
y: firstElement.initialTransform.position.y + deltaY,
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldSnap = !this.deps.input.isShiftHeld();
|
||||||
|
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||||
|
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||||
|
});
|
||||||
|
const { snappedPosition, activeLines } = shouldSnap
|
||||||
|
? snapPosition({
|
||||||
|
proposedPosition,
|
||||||
|
canvasSize: this.deps.scene.getCanvasSize(),
|
||||||
|
elementSize: drag.bounds,
|
||||||
|
rotation: drag.bounds.rotation,
|
||||||
|
snapThreshold,
|
||||||
|
})
|
||||||
|
: {
|
||||||
|
snappedPosition: proposedPosition,
|
||||||
|
activeLines: [] as SnapLine[],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.deps.preview.onSnapLinesChange?.(activeLines);
|
||||||
|
|
||||||
|
const deltaSnappedX =
|
||||||
|
snappedPosition.x - firstElement.initialTransform.position.x;
|
||||||
|
const deltaSnappedY =
|
||||||
|
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||||
|
|
||||||
|
this.deps.timeline.previewElements(
|
||||||
|
drag.elements.map(({ trackId, elementId, initialTransform }) => ({
|
||||||
|
trackId,
|
||||||
|
elementId,
|
||||||
|
updates: {
|
||||||
|
transform: {
|
||||||
|
...initialTransform,
|
||||||
|
position: {
|
||||||
|
x: initialTransform.position.x + deltaSnappedX,
|
||||||
|
y: initialTransform.position.y + deltaSnappedY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,753 @@
|
||||||
|
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||||
|
import type { MediaAsset } from "@/media/types";
|
||||||
|
import {
|
||||||
|
getVisibleElementsWithBounds,
|
||||||
|
type Corner,
|
||||||
|
type Edge,
|
||||||
|
type ElementBounds,
|
||||||
|
type ElementWithBounds,
|
||||||
|
} from "@/preview/element-bounds";
|
||||||
|
import {
|
||||||
|
MIN_SCALE,
|
||||||
|
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||||
|
snapRotation,
|
||||||
|
snapScale,
|
||||||
|
snapScaleAxes,
|
||||||
|
type ScaleEdgePreference,
|
||||||
|
type SnapLine,
|
||||||
|
} from "@/preview/preview-snap";
|
||||||
|
import { isVisualElement } from "@/timeline/element-utils";
|
||||||
|
import {
|
||||||
|
getElementLocalTime,
|
||||||
|
hasKeyframesForPath,
|
||||||
|
resolveTransformAtTime,
|
||||||
|
setChannel,
|
||||||
|
} from "@/animation";
|
||||||
|
import type { ElementAnimations } from "@/animation/types";
|
||||||
|
import type { Transform } from "@/rendering";
|
||||||
|
import type {
|
||||||
|
ElementRef,
|
||||||
|
SceneTracks,
|
||||||
|
TimelineElement,
|
||||||
|
VisualElement,
|
||||||
|
} from "@/timeline";
|
||||||
|
|
||||||
|
type Point = { readonly x: number; readonly y: number };
|
||||||
|
type CanvasSize = { readonly width: number; readonly height: number };
|
||||||
|
type HandleType = Corner | Edge | "rotation";
|
||||||
|
|
||||||
|
interface CapturedPointerState {
|
||||||
|
readonly pointerId: number;
|
||||||
|
readonly captureTarget: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CornerScaleSession extends CapturedPointerState {
|
||||||
|
readonly kind: "corner-scale";
|
||||||
|
readonly corner: Corner;
|
||||||
|
readonly trackId: string;
|
||||||
|
readonly elementId: string;
|
||||||
|
readonly initialTransform: Transform;
|
||||||
|
readonly initialDistance: number;
|
||||||
|
readonly initialBoundsCx: number;
|
||||||
|
readonly initialBoundsCy: number;
|
||||||
|
readonly baseWidth: number;
|
||||||
|
readonly baseHeight: number;
|
||||||
|
readonly shouldClearScaleAnimation: boolean;
|
||||||
|
readonly animationsWithoutScale: ElementAnimations | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EdgeScaleSession extends CapturedPointerState {
|
||||||
|
readonly kind: "edge-scale";
|
||||||
|
readonly edge: Edge;
|
||||||
|
readonly trackId: string;
|
||||||
|
readonly elementId: string;
|
||||||
|
readonly initialTransform: Transform;
|
||||||
|
readonly initialBoundsCx: number;
|
||||||
|
readonly initialBoundsCy: number;
|
||||||
|
readonly baseWidth: number;
|
||||||
|
readonly baseHeight: number;
|
||||||
|
readonly rotationRad: number;
|
||||||
|
readonly shouldClearScaleAnimation: boolean;
|
||||||
|
readonly animationsWithoutScale: ElementAnimations | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RotationSession extends CapturedPointerState {
|
||||||
|
readonly kind: "rotation";
|
||||||
|
readonly trackId: string;
|
||||||
|
readonly elementId: string;
|
||||||
|
readonly initialTransform: Transform;
|
||||||
|
readonly initialAngle: number;
|
||||||
|
readonly initialBoundsCx: number;
|
||||||
|
readonly initialBoundsCy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransformSession =
|
||||||
|
| { readonly kind: "idle" }
|
||||||
|
| CornerScaleSession
|
||||||
|
| EdgeScaleSession
|
||||||
|
| RotationSession;
|
||||||
|
|
||||||
|
const IDLE_SESSION: TransformSession = { kind: "idle" };
|
||||||
|
|
||||||
|
interface VisualSelectionContext {
|
||||||
|
readonly trackId: string;
|
||||||
|
readonly elementId: string;
|
||||||
|
readonly element: VisualElement;
|
||||||
|
readonly bounds: ElementBounds;
|
||||||
|
readonly resolvedTransform: Transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewViewportAdapter {
|
||||||
|
screenToCanvas: ({
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
}: {
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
}) => Point | null;
|
||||||
|
screenPixelsToLogicalThreshold: ({
|
||||||
|
screenPixels,
|
||||||
|
}: {
|
||||||
|
screenPixels: number;
|
||||||
|
}) => Point;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputAdapter {
|
||||||
|
isShiftHeld: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SceneReader {
|
||||||
|
getSelectedElements: () => readonly ElementRef[];
|
||||||
|
getTracks: () => SceneTracks;
|
||||||
|
getCurrentTime: () => number;
|
||||||
|
getMediaAssets: () => MediaAsset[];
|
||||||
|
getCanvasSize: () => CanvasSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelinePreviewUpdate {
|
||||||
|
readonly trackId: string;
|
||||||
|
readonly elementId: string;
|
||||||
|
readonly updates: Partial<TimelineElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineOps {
|
||||||
|
previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
|
||||||
|
commitPreview: () => void;
|
||||||
|
discardPreview: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewOptions {
|
||||||
|
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformHandleDeps {
|
||||||
|
viewport: PreviewViewportAdapter;
|
||||||
|
input: InputAdapter;
|
||||||
|
scene: SceneReader;
|
||||||
|
timeline: TimelineOps;
|
||||||
|
preview: PreviewOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransformHandleDepsRef {
|
||||||
|
readonly current: TransformHandleDeps;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
|
||||||
|
return edge === "right"
|
||||||
|
? { right: true }
|
||||||
|
: edge === "left"
|
||||||
|
? { left: true }
|
||||||
|
: { bottom: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampScaleNonZero(scale: number): number {
|
||||||
|
if (Math.abs(scale) < MIN_SCALE) {
|
||||||
|
return scale < 0 ? -MIN_SCALE : MIN_SCALE;
|
||||||
|
}
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCornerDistance({
|
||||||
|
bounds,
|
||||||
|
corner,
|
||||||
|
}: {
|
||||||
|
bounds: ElementBounds;
|
||||||
|
corner: Corner;
|
||||||
|
}): number {
|
||||||
|
const halfWidth = bounds.width / 2;
|
||||||
|
const halfHeight = bounds.height / 2;
|
||||||
|
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||||
|
const cos = Math.cos(angleRad);
|
||||||
|
const sin = Math.sin(angleRad);
|
||||||
|
|
||||||
|
const localX =
|
||||||
|
corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
|
||||||
|
const localY =
|
||||||
|
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
|
||||||
|
|
||||||
|
const rotatedX = localX * cos - localY * sin;
|
||||||
|
const rotatedY = localX * sin + localY * cos;
|
||||||
|
return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelectedWithBounds({
|
||||||
|
selectedElements,
|
||||||
|
elementsWithBounds,
|
||||||
|
}: {
|
||||||
|
selectedElements: readonly ElementRef[];
|
||||||
|
elementsWithBounds: readonly ElementWithBounds[];
|
||||||
|
}): ElementWithBounds | null {
|
||||||
|
if (selectedElements.length !== 1) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
elementsWithBounds.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.trackId === selectedElements[0].trackId &&
|
||||||
|
entry.elementId === selectedElements[0].elementId,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCornerScaleAnimationReset({
|
||||||
|
animations,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
}): {
|
||||||
|
shouldClearScaleAnimation: boolean;
|
||||||
|
animationsWithoutScale: ElementAnimations | undefined;
|
||||||
|
} {
|
||||||
|
const shouldClearScaleAnimation =
|
||||||
|
hasKeyframesForPath({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.scaleX",
|
||||||
|
}) ||
|
||||||
|
hasKeyframesForPath({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.scaleY",
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldClearScaleAnimation,
|
||||||
|
animationsWithoutScale: shouldClearScaleAnimation
|
||||||
|
? setChannel({
|
||||||
|
animations: setChannel({
|
||||||
|
animations,
|
||||||
|
propertyPath: "transform.scaleX",
|
||||||
|
channel: undefined,
|
||||||
|
}),
|
||||||
|
propertyPath: "transform.scaleY",
|
||||||
|
channel: undefined,
|
||||||
|
})
|
||||||
|
: animations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEdgeScaleAnimationReset({
|
||||||
|
animations,
|
||||||
|
edge,
|
||||||
|
}: {
|
||||||
|
animations: ElementAnimations | undefined;
|
||||||
|
edge: Edge;
|
||||||
|
}): {
|
||||||
|
shouldClearScaleAnimation: boolean;
|
||||||
|
animationsWithoutScale: ElementAnimations | undefined;
|
||||||
|
} {
|
||||||
|
const propertyPath =
|
||||||
|
edge === "right" || edge === "left"
|
||||||
|
? "transform.scaleX"
|
||||||
|
: "transform.scaleY";
|
||||||
|
|
||||||
|
const shouldClearScaleAnimation = hasKeyframesForPath({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldClearScaleAnimation,
|
||||||
|
animationsWithoutScale: shouldClearScaleAnimation
|
||||||
|
? setChannel({
|
||||||
|
animations,
|
||||||
|
propertyPath,
|
||||||
|
channel: undefined,
|
||||||
|
})
|
||||||
|
: animations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TransformHandleController {
|
||||||
|
private readonly depsRef: TransformHandleDepsRef;
|
||||||
|
private readonly subscribers = new Set<() => void>();
|
||||||
|
|
||||||
|
private session: TransformSession = IDLE_SESSION;
|
||||||
|
|
||||||
|
constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) {
|
||||||
|
this.depsRef = depsRef;
|
||||||
|
|
||||||
|
this.onCornerPointerDown = this.onCornerPointerDown.bind(this);
|
||||||
|
this.onEdgePointerDown = this.onEdgePointerDown.bind(this);
|
||||||
|
this.onRotationPointerDown = this.onRotationPointerDown.bind(this);
|
||||||
|
this.onPointerMove = this.onPointerMove.bind(this);
|
||||||
|
this.onPointerUp = this.onPointerUp.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get deps(): TransformHandleDeps {
|
||||||
|
return this.depsRef.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedWithBounds(): ElementWithBounds | null {
|
||||||
|
return buildSelectedWithBounds({
|
||||||
|
selectedElements: this.deps.scene.getSelectedElements(),
|
||||||
|
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get activeHandle(): HandleType | null {
|
||||||
|
switch (this.session.kind) {
|
||||||
|
case "corner-scale":
|
||||||
|
return this.session.corner;
|
||||||
|
case "edge-scale":
|
||||||
|
return this.session.edge;
|
||||||
|
case "rotation":
|
||||||
|
return "rotation";
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get isActive(): boolean {
|
||||||
|
return this.session.kind !== "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(fn: () => void): () => void {
|
||||||
|
this.subscribers.add(fn);
|
||||||
|
return () => this.subscribers.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.session.kind !== "idle") {
|
||||||
|
const session = this.session;
|
||||||
|
this.session = IDLE_SESSION;
|
||||||
|
this.deps.timeline.discardPreview();
|
||||||
|
this.clearSnapLines();
|
||||||
|
this.releaseCapturedPointer(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subscribers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
if (this.session.kind === "idle") return;
|
||||||
|
|
||||||
|
const session = this.session;
|
||||||
|
this.session = IDLE_SESSION;
|
||||||
|
this.deps.timeline.discardPreview();
|
||||||
|
this.clearSnapLines();
|
||||||
|
this.releaseCapturedPointer(session);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
onCornerPointerDown({
|
||||||
|
event,
|
||||||
|
corner,
|
||||||
|
}: {
|
||||||
|
event: ReactPointerEvent;
|
||||||
|
corner: Corner;
|
||||||
|
}): void {
|
||||||
|
const context = this.getSelectedVisualContext();
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const { shouldClearScaleAnimation, animationsWithoutScale } =
|
||||||
|
buildCornerScaleAnimationReset({
|
||||||
|
animations: context.element.animations,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.session = {
|
||||||
|
kind: "corner-scale",
|
||||||
|
corner,
|
||||||
|
trackId: context.trackId,
|
||||||
|
elementId: context.elementId,
|
||||||
|
initialTransform: context.resolvedTransform,
|
||||||
|
initialDistance: getCornerDistance({
|
||||||
|
bounds: context.bounds,
|
||||||
|
corner,
|
||||||
|
}),
|
||||||
|
initialBoundsCx: context.bounds.cx,
|
||||||
|
initialBoundsCy: context.bounds.cy,
|
||||||
|
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
|
||||||
|
baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
|
||||||
|
shouldClearScaleAnimation,
|
||||||
|
animationsWithoutScale,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
captureTarget: this.capturePointer({
|
||||||
|
target: event.currentTarget as HTMLElement,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
onRotationPointerDown({ event }: { event: ReactPointerEvent }): void {
|
||||||
|
const context = this.getSelectedVisualContext();
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const position = this.deps.viewport.screenToCanvas({
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
});
|
||||||
|
if (!position) return;
|
||||||
|
|
||||||
|
const deltaX = position.x - context.bounds.cx;
|
||||||
|
const deltaY = position.y - context.bounds.cy;
|
||||||
|
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||||
|
|
||||||
|
this.session = {
|
||||||
|
kind: "rotation",
|
||||||
|
trackId: context.trackId,
|
||||||
|
elementId: context.elementId,
|
||||||
|
initialTransform: context.resolvedTransform,
|
||||||
|
initialAngle,
|
||||||
|
initialBoundsCx: context.bounds.cx,
|
||||||
|
initialBoundsCy: context.bounds.cy,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
captureTarget: this.capturePointer({
|
||||||
|
target: event.currentTarget as HTMLElement,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
onEdgePointerDown({
|
||||||
|
event,
|
||||||
|
edge,
|
||||||
|
}: {
|
||||||
|
event: ReactPointerEvent;
|
||||||
|
edge: Edge;
|
||||||
|
}): void {
|
||||||
|
const context = this.getSelectedVisualContext();
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const { shouldClearScaleAnimation, animationsWithoutScale } =
|
||||||
|
buildEdgeScaleAnimationReset({
|
||||||
|
animations: context.element.animations,
|
||||||
|
edge,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.session = {
|
||||||
|
kind: "edge-scale",
|
||||||
|
edge,
|
||||||
|
trackId: context.trackId,
|
||||||
|
elementId: context.elementId,
|
||||||
|
initialTransform: context.resolvedTransform,
|
||||||
|
initialBoundsCx: context.bounds.cx,
|
||||||
|
initialBoundsCy: context.bounds.cy,
|
||||||
|
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
|
||||||
|
baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
|
||||||
|
rotationRad: (context.bounds.rotation * Math.PI) / 180,
|
||||||
|
shouldClearScaleAnimation,
|
||||||
|
animationsWithoutScale,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
captureTarget: this.capturePointer({
|
||||||
|
target: event.currentTarget as HTMLElement,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerMove({ event }: { event: ReactPointerEvent }): void {
|
||||||
|
if (this.session.kind === "idle") return;
|
||||||
|
|
||||||
|
const position = this.deps.viewport.screenToCanvas({
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
});
|
||||||
|
if (!position) return;
|
||||||
|
|
||||||
|
switch (this.session.kind) {
|
||||||
|
case "corner-scale":
|
||||||
|
this.previewCornerScale({
|
||||||
|
session: this.session,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "edge-scale":
|
||||||
|
this.previewEdgeScale({
|
||||||
|
session: this.session,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
case "rotation":
|
||||||
|
this.previewRotation({
|
||||||
|
session: this.session,
|
||||||
|
position,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onPointerUp(): void {
|
||||||
|
if (this.session.kind === "idle") return;
|
||||||
|
|
||||||
|
const session = this.session;
|
||||||
|
this.session = IDLE_SESSION;
|
||||||
|
this.deps.timeline.commitPreview();
|
||||||
|
this.clearSnapLines();
|
||||||
|
this.releaseCapturedPointer(session);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const fn of this.subscribers) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSnapLines(): void {
|
||||||
|
this.deps.preview.onSnapLinesChange?.([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private capturePointer({
|
||||||
|
target,
|
||||||
|
pointerId,
|
||||||
|
}: {
|
||||||
|
target: HTMLElement;
|
||||||
|
pointerId: number;
|
||||||
|
}): HTMLElement {
|
||||||
|
target.setPointerCapture(pointerId);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaseCapturedPointer(pointerState: CapturedPointerState): void {
|
||||||
|
if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getVisibleElementsWithBounds(): ElementWithBounds[] {
|
||||||
|
return getVisibleElementsWithBounds({
|
||||||
|
tracks: this.deps.scene.getTracks(),
|
||||||
|
currentTime: this.deps.scene.getCurrentTime(),
|
||||||
|
canvasSize: this.deps.scene.getCanvasSize(),
|
||||||
|
mediaAssets: this.deps.scene.getMediaAssets(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSelectedVisualContext(): VisualSelectionContext | null {
|
||||||
|
const selectedWithBounds = this.selectedWithBounds;
|
||||||
|
if (!selectedWithBounds) return null;
|
||||||
|
if (!isVisualElement(selectedWithBounds.element)) return null;
|
||||||
|
|
||||||
|
const localTime = getElementLocalTime({
|
||||||
|
timelineTime: this.deps.scene.getCurrentTime(),
|
||||||
|
elementStartTime: selectedWithBounds.element.startTime,
|
||||||
|
elementDuration: selectedWithBounds.element.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackId: selectedWithBounds.trackId,
|
||||||
|
elementId: selectedWithBounds.elementId,
|
||||||
|
element: selectedWithBounds.element,
|
||||||
|
bounds: selectedWithBounds.bounds,
|
||||||
|
resolvedTransform: resolveTransformAtTime({
|
||||||
|
baseTransform: selectedWithBounds.element.transform,
|
||||||
|
animations: selectedWithBounds.element.animations,
|
||||||
|
localTime,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private previewCornerScale({
|
||||||
|
session,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
session: CornerScaleSession;
|
||||||
|
position: Point;
|
||||||
|
}): void {
|
||||||
|
const deltaX = position.x - session.initialBoundsCx;
|
||||||
|
const deltaY = position.y - session.initialBoundsCy;
|
||||||
|
const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
||||||
|
const scaleFactor = currentDistance / session.initialDistance;
|
||||||
|
|
||||||
|
// Use actual element dimensions (base * current scale) so snapping is
|
||||||
|
// computed from the rendered geometry when scaleX != scaleY.
|
||||||
|
const effectiveWidth = session.baseWidth * session.initialTransform.scaleX;
|
||||||
|
const effectiveHeight =
|
||||||
|
session.baseHeight * session.initialTransform.scaleY;
|
||||||
|
|
||||||
|
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||||
|
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||||
|
});
|
||||||
|
const { snappedScale, activeLines } = this.deps.input.isShiftHeld()
|
||||||
|
? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
|
||||||
|
: snapScale({
|
||||||
|
proposedScale: scaleFactor,
|
||||||
|
position: session.initialTransform.position,
|
||||||
|
baseWidth: effectiveWidth,
|
||||||
|
baseHeight: effectiveHeight,
|
||||||
|
rotation: session.initialTransform.rotate,
|
||||||
|
canvasSize: this.deps.scene.getCanvasSize(),
|
||||||
|
snapThreshold,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.deps.preview.onSnapLinesChange?.(activeLines);
|
||||||
|
|
||||||
|
this.deps.timeline.previewElements([
|
||||||
|
{
|
||||||
|
trackId: session.trackId,
|
||||||
|
elementId: session.elementId,
|
||||||
|
updates: {
|
||||||
|
transform: {
|
||||||
|
...session.initialTransform,
|
||||||
|
scaleX: clampScaleNonZero(
|
||||||
|
session.initialTransform.scaleX * snappedScale,
|
||||||
|
),
|
||||||
|
scaleY: clampScaleNonZero(
|
||||||
|
session.initialTransform.scaleY * snappedScale,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(session.shouldClearScaleAnimation && {
|
||||||
|
animations: session.animationsWithoutScale,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private previewEdgeScale({
|
||||||
|
session,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
session: EdgeScaleSession;
|
||||||
|
position: Point;
|
||||||
|
}): void {
|
||||||
|
const deltaX = position.x - session.initialBoundsCx;
|
||||||
|
const deltaY = position.y - session.initialBoundsCy;
|
||||||
|
const xProjection =
|
||||||
|
deltaX * Math.cos(session.rotationRad) +
|
||||||
|
deltaY * Math.sin(session.rotationRad);
|
||||||
|
const yProjection =
|
||||||
|
-deltaX * Math.sin(session.rotationRad) +
|
||||||
|
deltaY * Math.cos(session.rotationRad);
|
||||||
|
const projection =
|
||||||
|
session.edge === "right"
|
||||||
|
? xProjection
|
||||||
|
: session.edge === "left"
|
||||||
|
? -xProjection
|
||||||
|
: yProjection;
|
||||||
|
|
||||||
|
const baseAxisHalf =
|
||||||
|
session.edge === "right" || session.edge === "left"
|
||||||
|
? session.baseWidth / 2
|
||||||
|
: session.baseHeight / 2;
|
||||||
|
const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
|
||||||
|
|
||||||
|
const proposedScaleX =
|
||||||
|
session.edge === "right" || session.edge === "left"
|
||||||
|
? proposedScale
|
||||||
|
: session.initialTransform.scaleX;
|
||||||
|
const proposedScaleY =
|
||||||
|
session.edge === "bottom"
|
||||||
|
? proposedScale
|
||||||
|
: session.initialTransform.scaleY;
|
||||||
|
|
||||||
|
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||||
|
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||||
|
});
|
||||||
|
const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld()
|
||||||
|
? {
|
||||||
|
x: {
|
||||||
|
snappedScale: proposedScaleX,
|
||||||
|
snapDistance: Infinity,
|
||||||
|
activeLines: [] as SnapLine[],
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
snappedScale: proposedScaleY,
|
||||||
|
snapDistance: Infinity,
|
||||||
|
activeLines: [] as SnapLine[],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: snapScaleAxes({
|
||||||
|
proposedScaleX,
|
||||||
|
proposedScaleY,
|
||||||
|
position: session.initialTransform.position,
|
||||||
|
baseWidth: session.baseWidth,
|
||||||
|
baseHeight: session.baseHeight,
|
||||||
|
rotation: session.initialTransform.rotate,
|
||||||
|
canvasSize: this.deps.scene.getCanvasSize(),
|
||||||
|
snapThreshold,
|
||||||
|
preferredEdges: getPreferredEdge({ edge: session.edge }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const relevantSnap =
|
||||||
|
session.edge === "right" || session.edge === "left" ? xSnap : ySnap;
|
||||||
|
this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines);
|
||||||
|
|
||||||
|
this.deps.timeline.previewElements([
|
||||||
|
{
|
||||||
|
trackId: session.trackId,
|
||||||
|
elementId: session.elementId,
|
||||||
|
updates: {
|
||||||
|
transform: {
|
||||||
|
...session.initialTransform,
|
||||||
|
scaleX:
|
||||||
|
session.edge === "right" || session.edge === "left"
|
||||||
|
? xSnap.snappedScale
|
||||||
|
: session.initialTransform.scaleX,
|
||||||
|
scaleY:
|
||||||
|
session.edge === "bottom"
|
||||||
|
? ySnap.snappedScale
|
||||||
|
: session.initialTransform.scaleY,
|
||||||
|
},
|
||||||
|
...(session.shouldClearScaleAnimation && {
|
||||||
|
animations: session.animationsWithoutScale,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private previewRotation({
|
||||||
|
session,
|
||||||
|
position,
|
||||||
|
}: {
|
||||||
|
session: RotationSession;
|
||||||
|
position: Point;
|
||||||
|
}): void {
|
||||||
|
const deltaX = position.x - session.initialBoundsCx;
|
||||||
|
const deltaY = position.y - session.initialBoundsCy;
|
||||||
|
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||||
|
let deltaAngle = currentAngle - session.initialAngle;
|
||||||
|
if (deltaAngle > 180) deltaAngle -= 360;
|
||||||
|
if (deltaAngle < -180) deltaAngle += 360;
|
||||||
|
|
||||||
|
const newRotate = session.initialTransform.rotate + deltaAngle;
|
||||||
|
const { snappedRotation } = this.deps.input.isShiftHeld()
|
||||||
|
? { snappedRotation: newRotate }
|
||||||
|
: snapRotation({ proposedRotation: newRotate });
|
||||||
|
|
||||||
|
this.deps.timeline.previewElements([
|
||||||
|
{
|
||||||
|
trackId: session.trackId,
|
||||||
|
elementId: session.elementId,
|
||||||
|
updates: {
|
||||||
|
transform: {
|
||||||
|
...session.initialTransform,
|
||||||
|
rotate: snappedRotation,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,97 +1,17 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useReducer, useRef } from "react";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||||
import type { Transform } from "@/rendering";
|
import type { SnapLine } from "@/preview/preview-snap";
|
||||||
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 { registerCanceller } from "@/editor/cancel-interaction";
|
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||||
|
import {
|
||||||
|
PreviewInteractionController,
|
||||||
|
type PreviewInteractionDeps,
|
||||||
|
type PreviewInteractionDepsRef,
|
||||||
|
} from "@/preview/controllers/preview-interaction-controller";
|
||||||
|
|
||||||
export type OnSnapLinesChange = (lines: SnapLine[]) => void;
|
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({
|
export function usePreviewInteraction({
|
||||||
onSnapLinesChange,
|
onSnapLinesChange,
|
||||||
isMaskMode = false,
|
isMaskMode = false,
|
||||||
|
|
@ -102,355 +22,74 @@ export function usePreviewInteraction({
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const viewport = usePreviewViewport();
|
const viewport = usePreviewViewport();
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const deps: PreviewInteractionDeps = {
|
||||||
const [editingText, setEditingText] = useState<{
|
viewport: {
|
||||||
trackId: string;
|
screenToCanvas: viewport.screenToCanvas,
|
||||||
elementId: string;
|
screenPixelsToLogicalThreshold: viewport.screenPixelsToLogicalThreshold,
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
[],
|
input: {
|
||||||
);
|
isShiftHeld: () => isShiftHeldRef.current,
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
getTracks: () => editor.scenes.getActiveScene().tracks,
|
||||||
|
getCurrentTime: () => editor.playback.getCurrentTime(),
|
||||||
|
getMediaAssets: () => editor.media.getAssets(),
|
||||||
|
getCanvasSize: () => editor.project.getActive().settings.canvasSize,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
getSelected: () => editor.selection.getSelectedElements(),
|
||||||
|
setSelected: (elements) =>
|
||||||
|
editor.selection.setSelectedElements({ elements: [...elements] }),
|
||||||
|
clearSelection: () => editor.selection.clearSelection(),
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
getElementsWithTracks: ({ elements }) =>
|
||||||
|
editor.timeline.getElementsWithTracks({ elements: [...elements] }),
|
||||||
|
previewElements: (updates) =>
|
||||||
|
editor.timeline.previewElements({ updates: [...updates] }),
|
||||||
|
commitPreview: () => editor.timeline.commitPreview(),
|
||||||
|
discardPreview: () => editor.timeline.discardPreview(),
|
||||||
|
},
|
||||||
|
playback: {
|
||||||
|
getIsPlaying: () => editor.playback.getIsPlaying(),
|
||||||
|
subscribe: (listener) => editor.playback.subscribe(listener),
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
isMaskMode: () => isMaskMode,
|
||||||
|
onSnapLinesChange,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const commitTextEdit = useCallback(() => {
|
const depsRef = useRef<PreviewInteractionDeps>(deps);
|
||||||
const current = editingTextRef.current;
|
depsRef.current = deps;
|
||||||
if (!current) return;
|
|
||||||
editingTextRef.current = null;
|
const controllerRef = useRef<PreviewInteractionController | null>(null);
|
||||||
editor.timeline.commitPreview();
|
if (!controllerRef.current) {
|
||||||
setEditingText(null);
|
controllerRef.current = new PreviewInteractionController({
|
||||||
}, [editor.timeline]);
|
depsRef: depsRef as PreviewInteractionDepsRef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const controller = controllerRef.current;
|
||||||
|
|
||||||
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||||
|
useEffect(
|
||||||
|
() => controller.subscribe({ listener: rerender }),
|
||||||
|
[controller],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = editor.playback.subscribe(() => {
|
if (!controller.isDragging) return;
|
||||||
const isPlaying = editor.playback.getIsPlaying();
|
return registerCanceller({ fn: () => controller.cancel() });
|
||||||
if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
|
}, [controller.isDragging, controller]);
|
||||||
commitTextEdit();
|
|
||||||
}
|
|
||||||
wasPlayingRef.current = isPlaying;
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
}, [editor.playback, commitTextEdit]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
if (!isDragging) return;
|
|
||||||
|
|
||||||
return registerCanceller({
|
|
||||||
fn: () => {
|
|
||||||
const dragState = dragStateRef.current;
|
|
||||||
if (!dragState) return;
|
|
||||||
|
|
||||||
editor.timeline.discardPreview();
|
|
||||||
dragStateRef.current = null;
|
|
||||||
pendingGestureRef.current = null;
|
|
||||||
setIsDragging(false);
|
|
||||||
onSnapLinesChange?.([]);
|
|
||||||
releaseCapturedPointer(dragState);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
|
|
||||||
|
|
||||||
const handleDoubleClick = useCallback(
|
|
||||||
({ clientX, clientY }: React.MouseEvent) => {
|
|
||||||
if (editingText || isMaskMode) return;
|
|
||||||
|
|
||||||
const tracks = editor.scenes.getActiveScene().tracks;
|
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
|
||||||
const mediaAssets = editor.media.getAssets();
|
|
||||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
|
||||||
|
|
||||||
const startPos = viewport.screenToCanvas({
|
|
||||||
clientX,
|
|
||||||
clientY,
|
|
||||||
});
|
|
||||||
if (!startPos) return;
|
|
||||||
|
|
||||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
|
||||||
tracks,
|
|
||||||
currentTime,
|
|
||||||
canvasSize,
|
|
||||||
mediaAssets,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hit = hitTest({
|
|
||||||
canvasX: startPos.x,
|
|
||||||
canvasY: startPos.y,
|
|
||||||
elementsWithBounds,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hit || hit.element.type !== "text") return;
|
|
||||||
|
|
||||||
const textElement = hit.element as TextElement;
|
|
||||||
setEditingText({
|
|
||||||
trackId: hit.trackId,
|
|
||||||
elementId: hit.elementId,
|
|
||||||
element: textElement,
|
|
||||||
originalOpacity: textElement.opacity,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[editor, editingText, isMaskMode, viewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
|
||||||
({
|
|
||||||
clientX,
|
|
||||||
clientY,
|
|
||||||
currentTarget,
|
|
||||||
pointerId,
|
|
||||||
button,
|
|
||||||
}: React.PointerEvent) => {
|
|
||||||
if (editingText) return;
|
|
||||||
if (isMaskMode) return;
|
|
||||||
if (button !== 0) return;
|
|
||||||
|
|
||||||
const tracks = editor.scenes.getActiveScene().tracks;
|
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
|
||||||
const mediaAssets = editor.media.getAssets();
|
|
||||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
|
||||||
|
|
||||||
const startPos = viewport.screenToCanvas({
|
|
||||||
clientX,
|
|
||||||
clientY,
|
|
||||||
});
|
|
||||||
if (!startPos) return;
|
|
||||||
|
|
||||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
|
||||||
tracks,
|
|
||||||
currentTime,
|
|
||||||
canvasSize,
|
|
||||||
mediaAssets,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hits = getHitElements({
|
|
||||||
canvasX: startPos.x,
|
|
||||||
canvasY: startPos.y,
|
|
||||||
elementsWithBounds,
|
|
||||||
});
|
|
||||||
const selectedElements = editor.selection.getSelectedElements();
|
|
||||||
const topmostHit = hits[0] ?? null;
|
|
||||||
|
|
||||||
pendingGestureRef.current = {
|
|
||||||
startX: startPos.x,
|
|
||||||
startY: startPos.y,
|
|
||||||
pointerId,
|
|
||||||
captureTarget: currentTarget as HTMLElement,
|
|
||||||
topmostHit,
|
|
||||||
selectedHit: resolvePreferredHit({
|
|
||||||
hits,
|
|
||||||
preferredElements: selectedElements,
|
|
||||||
}),
|
|
||||||
selectedElements,
|
|
||||||
};
|
|
||||||
currentTarget.setPointerCapture(pointerId);
|
|
||||||
},
|
|
||||||
[editor, editingText, isMaskMode, viewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
|
||||||
({ clientX, clientY }: React.PointerEvent) => {
|
|
||||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
|
||||||
|
|
||||||
const currentPos = viewport.screenToCanvas({
|
|
||||||
clientX,
|
|
||||||
clientY,
|
|
||||||
});
|
|
||||||
if (!currentPos) return;
|
|
||||||
|
|
||||||
let dragState = dragStateRef.current;
|
|
||||||
|
|
||||||
if (!dragState) {
|
|
||||||
const pendingGesture = pendingGestureRef.current;
|
|
||||||
if (!pendingGesture) return;
|
|
||||||
|
|
||||||
const deltaX = currentPos.x - pendingGesture.startX;
|
|
||||||
const deltaY = currentPos.y - pendingGesture.startY;
|
|
||||||
const hasMovement =
|
|
||||||
Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
|
|
||||||
Math.abs(deltaY) > MIN_DRAG_DISTANCE;
|
|
||||||
|
|
||||||
if (!hasMovement) {
|
|
||||||
onSnapLinesChange?.([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
|
|
||||||
if (!dragTarget) {
|
|
||||||
pendingGestureRef.current = null;
|
|
||||||
onSnapLinesChange?.([]);
|
|
||||||
releaseCapturedPointer(pendingGesture);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dragSelection = buildDragSelection({
|
|
||||||
selectedElements: pendingGesture.selectedElements,
|
|
||||||
dragTarget,
|
|
||||||
});
|
|
||||||
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
|
||||||
elements: dragSelection,
|
|
||||||
});
|
|
||||||
const draggableElements = elementsWithTracks.filter(({ element }) =>
|
|
||||||
isVisualElement(element),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (draggableElements.length === 0) {
|
|
||||||
pendingGestureRef.current = null;
|
|
||||||
onSnapLinesChange?.([]);
|
|
||||||
releaseCapturedPointer(pendingGesture);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingGesture.selectedHit === null) {
|
|
||||||
editor.selection.setSelectedElements({
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
trackId: dragTarget.trackId,
|
|
||||||
elementId: dragTarget.elementId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
dragState = {
|
|
||||||
startX: pendingGesture.startX,
|
|
||||||
startY: pendingGesture.startY,
|
|
||||||
pointerId: pendingGesture.pointerId,
|
|
||||||
captureTarget: pendingGesture.captureTarget,
|
|
||||||
bounds: {
|
|
||||||
width: dragTarget.bounds.width,
|
|
||||||
height: dragTarget.bounds.height,
|
|
||||||
rotation: dragTarget.bounds.rotation,
|
|
||||||
},
|
|
||||||
elements: draggableElements.map(({ track, element }) => ({
|
|
||||||
trackId: track.id,
|
|
||||||
elementId: element.id,
|
|
||||||
initialTransform: (element as { transform: Transform }).transform,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
dragStateRef.current = dragState;
|
|
||||||
pendingGestureRef.current = null;
|
|
||||||
setIsDragging(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaX = currentPos.x - dragState.startX;
|
|
||||||
const deltaY = currentPos.y - dragState.startY;
|
|
||||||
const firstElement = dragState.elements[0];
|
|
||||||
const proposedPosition = {
|
|
||||||
x: firstElement.initialTransform.position.x + deltaX,
|
|
||||||
y: firstElement.initialTransform.position.y + deltaY,
|
|
||||||
};
|
|
||||||
|
|
||||||
const shouldSnap = !isShiftHeldRef.current;
|
|
||||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
|
||||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
|
||||||
});
|
|
||||||
const { snappedPosition, activeLines } = shouldSnap
|
|
||||||
? snapPosition({
|
|
||||||
proposedPosition,
|
|
||||||
canvasSize,
|
|
||||||
elementSize: dragState.bounds,
|
|
||||||
rotation: dragState.bounds.rotation,
|
|
||||||
snapThreshold,
|
|
||||||
})
|
|
||||||
: {
|
|
||||||
snappedPosition: proposedPosition,
|
|
||||||
activeLines: [] as SnapLine[],
|
|
||||||
};
|
|
||||||
|
|
||||||
onSnapLinesChange?.(activeLines);
|
|
||||||
|
|
||||||
const deltaSnappedX =
|
|
||||||
snappedPosition.x - firstElement.initialTransform.position.x;
|
|
||||||
const deltaSnappedY =
|
|
||||||
snappedPosition.y - firstElement.initialTransform.position.y;
|
|
||||||
|
|
||||||
const updates = dragState.elements.map(
|
|
||||||
({ trackId, elementId, initialTransform }) => ({
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
updates: {
|
|
||||||
transform: {
|
|
||||||
...initialTransform,
|
|
||||||
position: {
|
|
||||||
x: initialTransform.position.x + deltaSnappedX,
|
|
||||||
y: initialTransform.position.y + deltaSnappedY,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
editor.timeline.previewElements({ updates });
|
|
||||||
},
|
|
||||||
[editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback(
|
|
||||||
({ type }: React.PointerEvent) => {
|
|
||||||
const dragState = dragStateRef.current;
|
|
||||||
if (dragState) {
|
|
||||||
if (type === "pointercancel") {
|
|
||||||
editor.timeline.discardPreview();
|
|
||||||
} else {
|
|
||||||
editor.timeline.commitPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
dragStateRef.current = null;
|
|
||||||
pendingGestureRef.current = null;
|
|
||||||
setIsDragging(false);
|
|
||||||
onSnapLinesChange?.([]);
|
|
||||||
releaseCapturedPointer(dragState);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingGesture = pendingGestureRef.current;
|
|
||||||
if (!pendingGesture) return;
|
|
||||||
|
|
||||||
if (type !== "pointercancel") {
|
|
||||||
const clickTarget = pendingGesture.topmostHit;
|
|
||||||
if (!clickTarget) {
|
|
||||||
editor.selection.clearSelection();
|
|
||||||
} else {
|
|
||||||
editor.selection.setSelectedElements({
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
trackId: clickTarget.trackId,
|
|
||||||
elementId: clickTarget.elementId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingGestureRef.current = null;
|
|
||||||
onSnapLinesChange?.([]);
|
|
||||||
releaseCapturedPointer(pendingGesture);
|
|
||||||
},
|
|
||||||
[editor, onSnapLinesChange, releaseCapturedPointer],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onPointerDown: handlePointerDown,
|
onPointerDown: controller.onPointerDown,
|
||||||
onPointerMove: handlePointerMove,
|
onPointerMove: controller.onPointerMove,
|
||||||
onPointerUp: handlePointerUp,
|
onPointerUp: controller.onPointerUp,
|
||||||
onDoubleClick: handleDoubleClick,
|
onDoubleClick: controller.onDoubleClick,
|
||||||
editingText,
|
editingText: controller.editingText,
|
||||||
commitTextEdit,
|
commitTextEdit: controller.commitTextEdit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,629 +1,84 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useReducer, useRef } from "react";
|
||||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||||
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
|
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
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";
|
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||||
|
import {
|
||||||
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
TransformHandleController,
|
||||||
type Edge = "right" | "left" | "bottom";
|
type TransformHandleDeps,
|
||||||
type HandleType = Corner | Edge | "rotation";
|
} from "@/preview/controllers/transform-handle-controller";
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTransformHandles({
|
export function useTransformHandles({
|
||||||
onSnapLinesChange,
|
onSnapLinesChange,
|
||||||
}: {
|
}: {
|
||||||
onSnapLinesChange?: OnSnapLinesChange;
|
onSnapLinesChange?: OnSnapLinesChange;
|
||||||
}) {
|
}) {
|
||||||
|
const viewport = usePreviewViewport();
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
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 selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
||||||
const tracks = useEditor(
|
const tracks = useEditor(
|
||||||
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||||
);
|
);
|
||||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||||
const currentTimeRef = useRef(currentTime);
|
|
||||||
currentTimeRef.current = currentTime;
|
|
||||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
const canvasSize = useEditor(
|
const canvasSize = useEditor(
|
||||||
(e) => e.project.getActive().settings.canvasSize,
|
(e) => e.project.getActive().settings.canvasSize,
|
||||||
);
|
);
|
||||||
|
const deps: TransformHandleDeps = {
|
||||||
|
viewport,
|
||||||
|
input: {
|
||||||
|
isShiftHeld: () => isShiftHeldRef.current,
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
getSelectedElements: () => selectedElements,
|
||||||
|
getTracks: () => tracks,
|
||||||
|
getCurrentTime: () => currentTime,
|
||||||
|
getMediaAssets: () => mediaAssets,
|
||||||
|
getCanvasSize: () => canvasSize,
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
previewElements: (updates) =>
|
||||||
|
editor.timeline.previewElements({ updates }),
|
||||||
|
commitPreview: () => editor.timeline.commitPreview(),
|
||||||
|
discardPreview: () => editor.timeline.discardPreview(),
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
onSnapLinesChange,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
const depsRef = useRef<TransformHandleDeps>(deps);
|
||||||
tracks,
|
depsRef.current = deps;
|
||||||
currentTime,
|
|
||||||
canvasSize,
|
|
||||||
mediaAssets,
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedWithBounds: ElementWithBounds | null =
|
const controllerRef = useRef<TransformHandleController | null>(null);
|
||||||
selectedElements.length === 1
|
if (!controllerRef.current) {
|
||||||
? (elementsWithBounds.find(
|
controllerRef.current = new TransformHandleController({ depsRef });
|
||||||
(entry) =>
|
}
|
||||||
entry.trackId === selectedElements[0].trackId &&
|
const controller = controllerRef.current;
|
||||||
entry.elementId === selectedElements[0].elementId,
|
|
||||||
) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const hasVisualSelection =
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||||
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
|
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
if (!activeHandle) return;
|
if (!controller.isActive) return;
|
||||||
|
return registerCanceller({ fn: () => controller.cancel() });
|
||||||
|
}, [controller, controller.isActive]);
|
||||||
|
|
||||||
return registerCanceller({
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
fn: () => {
|
|
||||||
editor.timeline.discardPreview();
|
|
||||||
clearActiveHandleState();
|
|
||||||
releaseCapturedPointer();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
activeHandle,
|
|
||||||
clearActiveHandleState,
|
|
||||||
editor.timeline,
|
|
||||||
releaseCapturedPointer,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleCornerPointerDown = useCallback(
|
const selectedWithBounds = controller.selectedWithBounds;
|
||||||
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
|
const hasVisualSelection = selectedWithBounds !== null;
|
||||||
if (!selectedWithBounds) return;
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
|
||||||
if (!isVisualElement(element)) return;
|
|
||||||
|
|
||||||
const localTime = getElementLocalTime({
|
|
||||||
timelineTime: currentTimeRef.current,
|
|
||||||
elementStartTime: element.startTime,
|
|
||||||
elementDuration: element.duration,
|
|
||||||
});
|
|
||||||
const resolvedTransform = resolveTransformAtTime({
|
|
||||||
baseTransform: element.transform,
|
|
||||||
animations: element.animations,
|
|
||||||
localTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
const initialDistance = getCornerDistance({ bounds, corner });
|
|
||||||
const baseWidth = bounds.width / resolvedTransform.scaleX;
|
|
||||||
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
|
||||||
const shouldClearScaleAnimation =
|
|
||||||
hasKeyframesForPath({
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: "transform.scaleX",
|
|
||||||
}) ||
|
|
||||||
hasKeyframesForPath({
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: "transform.scaleY",
|
|
||||||
});
|
|
||||||
const animationsWithoutScale = shouldClearScaleAnimation
|
|
||||||
? setChannel({
|
|
||||||
animations: setChannel({
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: "transform.scaleX",
|
|
||||||
channel: undefined,
|
|
||||||
}),
|
|
||||||
propertyPath: "transform.scaleY",
|
|
||||||
channel: undefined,
|
|
||||||
})
|
|
||||||
: element.animations;
|
|
||||||
|
|
||||||
scaleStateRef.current = {
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
initialTransform: resolvedTransform,
|
|
||||||
initialDistance,
|
|
||||||
initialBoundsCx: bounds.cx,
|
|
||||||
initialBoundsCy: bounds.cy,
|
|
||||||
baseWidth,
|
|
||||||
baseHeight,
|
|
||||||
shouldClearScaleAnimation,
|
|
||||||
animationsWithoutScale,
|
|
||||||
};
|
|
||||||
setActiveHandle(corner);
|
|
||||||
const captureTarget = event.currentTarget as HTMLElement;
|
|
||||||
captureTarget.setPointerCapture(event.pointerId);
|
|
||||||
captureRef.current = {
|
|
||||||
element: captureTarget,
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[selectedWithBounds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRotationPointerDown = useCallback(
|
|
||||||
({ event }: { event: React.PointerEvent }) => {
|
|
||||||
if (!selectedWithBounds) return;
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
|
||||||
if (!isVisualElement(element)) return;
|
|
||||||
|
|
||||||
const localTime = getElementLocalTime({
|
|
||||||
timelineTime: currentTimeRef.current,
|
|
||||||
elementStartTime: element.startTime,
|
|
||||||
elementDuration: element.duration,
|
|
||||||
});
|
|
||||||
const resolvedTransform = resolveTransformAtTime({
|
|
||||||
baseTransform: element.transform,
|
|
||||||
animations: element.animations,
|
|
||||||
localTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
const position = viewport.screenToCanvas({
|
|
||||||
clientX: event.clientX,
|
|
||||||
clientY: event.clientY,
|
|
||||||
});
|
|
||||||
if (!position) return;
|
|
||||||
const deltaX = position.x - bounds.cx;
|
|
||||||
const deltaY = position.y - bounds.cy;
|
|
||||||
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
|
||||||
|
|
||||||
rotationStateRef.current = {
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
initialTransform: resolvedTransform,
|
|
||||||
initialAngle,
|
|
||||||
initialBoundsCx: bounds.cx,
|
|
||||||
initialBoundsCy: bounds.cy,
|
|
||||||
};
|
|
||||||
setActiveHandle("rotation");
|
|
||||||
const captureTarget = event.currentTarget as HTMLElement;
|
|
||||||
captureTarget.setPointerCapture(event.pointerId);
|
|
||||||
captureRef.current = {
|
|
||||||
element: captureTarget,
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[selectedWithBounds, viewport],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleEdgePointerDown = useCallback(
|
|
||||||
({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
|
|
||||||
if (!selectedWithBounds) return;
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
|
||||||
if (!isVisualElement(element)) return;
|
|
||||||
|
|
||||||
const localTime = getElementLocalTime({
|
|
||||||
timelineTime: currentTimeRef.current,
|
|
||||||
elementStartTime: element.startTime,
|
|
||||||
elementDuration: element.duration,
|
|
||||||
});
|
|
||||||
const resolvedTransform = resolveTransformAtTime({
|
|
||||||
baseTransform: element.transform,
|
|
||||||
animations: element.animations,
|
|
||||||
localTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseWidth = bounds.width / resolvedTransform.scaleX;
|
|
||||||
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
|
||||||
const rotationRad = (bounds.rotation * Math.PI) / 180;
|
|
||||||
|
|
||||||
const propertyPath =
|
|
||||||
edge === "right" || edge === "left"
|
|
||||||
? "transform.scaleX"
|
|
||||||
: "transform.scaleY";
|
|
||||||
const shouldClearScaleAnimation = hasKeyframesForPath({
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath,
|
|
||||||
});
|
|
||||||
const animationsWithoutScale = shouldClearScaleAnimation
|
|
||||||
? setChannel({
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath,
|
|
||||||
channel: undefined,
|
|
||||||
})
|
|
||||||
: element.animations;
|
|
||||||
|
|
||||||
edgeScaleStateRef.current = {
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
initialTransform: resolvedTransform,
|
|
||||||
initialBoundsCx: bounds.cx,
|
|
||||||
initialBoundsCy: bounds.cy,
|
|
||||||
baseWidth,
|
|
||||||
baseHeight,
|
|
||||||
edge,
|
|
||||||
rotationRad,
|
|
||||||
shouldClearScaleAnimation,
|
|
||||||
animationsWithoutScale,
|
|
||||||
};
|
|
||||||
setActiveHandle(edge);
|
|
||||||
const captureTarget = event.currentTarget as HTMLElement;
|
|
||||||
captureTarget.setPointerCapture(event.pointerId);
|
|
||||||
captureRef.current = {
|
|
||||||
element: captureTarget,
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[selectedWithBounds],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
|
||||||
({ event }: { event: React.PointerEvent }) => {
|
|
||||||
if (
|
|
||||||
!scaleStateRef.current &&
|
|
||||||
!rotationStateRef.current &&
|
|
||||||
!edgeScaleStateRef.current
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const position = viewport.screenToCanvas({
|
|
||||||
clientX: event.clientX,
|
|
||||||
clientY: event.clientY,
|
|
||||||
});
|
|
||||||
if (!position) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
scaleStateRef.current &&
|
|
||||||
activeHandle &&
|
|
||||||
activeHandle !== "rotation"
|
|
||||||
) {
|
|
||||||
const {
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
initialTransform,
|
|
||||||
initialDistance,
|
|
||||||
initialBoundsCx,
|
|
||||||
initialBoundsCy,
|
|
||||||
baseWidth,
|
|
||||||
baseHeight,
|
|
||||||
shouldClearScaleAnimation,
|
|
||||||
animationsWithoutScale,
|
|
||||||
} = scaleStateRef.current;
|
|
||||||
|
|
||||||
const deltaX = position.x - initialBoundsCx;
|
|
||||||
const deltaY = position.y - initialBoundsCy;
|
|
||||||
const currentDistance =
|
|
||||||
Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
|
||||||
const scaleFactor = currentDistance / initialDistance;
|
|
||||||
|
|
||||||
// Use actual element dimensions (base * current scale) so snap
|
|
||||||
// computes the correct edges when scaleX ≠ scaleY
|
|
||||||
const effectiveWidth = baseWidth * initialTransform.scaleX;
|
|
||||||
const effectiveHeight = baseHeight * initialTransform.scaleY;
|
|
||||||
|
|
||||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
|
||||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
|
||||||
});
|
|
||||||
const { snappedScale: snappedFactor, activeLines } =
|
|
||||||
isShiftHeldRef.current
|
|
||||||
? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
|
|
||||||
: snapScale({
|
|
||||||
proposedScale: scaleFactor,
|
|
||||||
position: initialTransform.position,
|
|
||||||
baseWidth: effectiveWidth,
|
|
||||||
baseHeight: effectiveHeight,
|
|
||||||
rotation: initialTransform.rotate,
|
|
||||||
canvasSize,
|
|
||||||
snapThreshold,
|
|
||||||
});
|
|
||||||
|
|
||||||
onSnapLinesChange?.(activeLines);
|
|
||||||
|
|
||||||
editor.timeline.previewElements({
|
|
||||||
updates: [
|
|
||||||
{
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
updates: {
|
|
||||||
transform: {
|
|
||||||
...initialTransform,
|
|
||||||
scaleX: clampScaleNonZero(
|
|
||||||
initialTransform.scaleX * snappedFactor,
|
|
||||||
),
|
|
||||||
scaleY: clampScaleNonZero(
|
|
||||||
initialTransform.scaleY * snappedFactor,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
...(shouldClearScaleAnimation && {
|
|
||||||
animations: animationsWithoutScale,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
edgeScaleStateRef.current &&
|
|
||||||
(activeHandle === "right" ||
|
|
||||||
activeHandle === "left" ||
|
|
||||||
activeHandle === "bottom")
|
|
||||||
) {
|
|
||||||
const {
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
initialTransform,
|
|
||||||
initialBoundsCx,
|
|
||||||
initialBoundsCy,
|
|
||||||
baseWidth,
|
|
||||||
baseHeight,
|
|
||||||
edge,
|
|
||||||
rotationRad,
|
|
||||||
shouldClearScaleAnimation,
|
|
||||||
animationsWithoutScale,
|
|
||||||
} = edgeScaleStateRef.current;
|
|
||||||
|
|
||||||
const deltaX = position.x - initialBoundsCx;
|
|
||||||
const deltaY = position.y - initialBoundsCy;
|
|
||||||
const xProjection =
|
|
||||||
deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
|
|
||||||
const yProjection =
|
|
||||||
-deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
|
|
||||||
const projection =
|
|
||||||
edge === "right"
|
|
||||||
? xProjection
|
|
||||||
: edge === "left"
|
|
||||||
? -xProjection
|
|
||||||
: yProjection;
|
|
||||||
|
|
||||||
const baseAxisHalf =
|
|
||||||
edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
|
|
||||||
const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
|
|
||||||
|
|
||||||
const proposedScaleX =
|
|
||||||
edge === "right" || edge === "left"
|
|
||||||
? proposedScale
|
|
||||||
: initialTransform.scaleX;
|
|
||||||
const proposedScaleY =
|
|
||||||
edge === "bottom" ? proposedScale : initialTransform.scaleY;
|
|
||||||
|
|
||||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
|
||||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
|
||||||
});
|
|
||||||
const { x: xSnap, y: ySnap } = isShiftHeldRef.current
|
|
||||||
? {
|
|
||||||
x: {
|
|
||||||
snappedScale: proposedScaleX,
|
|
||||||
snapDistance: Infinity,
|
|
||||||
activeLines: [] as SnapLine[],
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
snappedScale: proposedScaleY,
|
|
||||||
snapDistance: Infinity,
|
|
||||||
activeLines: [] as SnapLine[],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: snapScaleAxes({
|
|
||||||
proposedScaleX,
|
|
||||||
proposedScaleY,
|
|
||||||
position: initialTransform.position,
|
|
||||||
baseWidth,
|
|
||||||
baseHeight,
|
|
||||||
rotation: initialTransform.rotate,
|
|
||||||
canvasSize,
|
|
||||||
snapThreshold,
|
|
||||||
preferredEdges: getPreferredEdge({ edge }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const relevantSnap =
|
|
||||||
edge === "right" || edge === "left" ? xSnap : ySnap;
|
|
||||||
onSnapLinesChange?.(relevantSnap.activeLines);
|
|
||||||
|
|
||||||
editor.timeline.previewElements({
|
|
||||||
updates: [
|
|
||||||
{
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
updates: {
|
|
||||||
transform: {
|
|
||||||
...initialTransform,
|
|
||||||
scaleX:
|
|
||||||
edge === "right" || edge === "left"
|
|
||||||
? xSnap.snappedScale
|
|
||||||
: initialTransform.scaleX,
|
|
||||||
scaleY:
|
|
||||||
edge === "bottom"
|
|
||||||
? ySnap.snappedScale
|
|
||||||
: initialTransform.scaleY,
|
|
||||||
},
|
|
||||||
...(shouldClearScaleAnimation && {
|
|
||||||
animations: animationsWithoutScale,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rotationStateRef.current && activeHandle === "rotation") {
|
|
||||||
const {
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
initialTransform,
|
|
||||||
initialAngle,
|
|
||||||
initialBoundsCx,
|
|
||||||
initialBoundsCy,
|
|
||||||
} = rotationStateRef.current;
|
|
||||||
|
|
||||||
const deltaX = position.x - initialBoundsCx;
|
|
||||||
const deltaY = position.y - initialBoundsCy;
|
|
||||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
|
||||||
let deltaAngle = currentAngle - initialAngle;
|
|
||||||
if (deltaAngle > 180) deltaAngle -= 360;
|
|
||||||
if (deltaAngle < -180) deltaAngle += 360;
|
|
||||||
const newRotate = initialTransform.rotate + deltaAngle;
|
|
||||||
const { snappedRotation } = isShiftHeldRef.current
|
|
||||||
? { snappedRotation: newRotate }
|
|
||||||
: snapRotation({ proposedRotation: newRotate });
|
|
||||||
|
|
||||||
editor.timeline.previewElements({
|
|
||||||
updates: [
|
|
||||||
{
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
updates: {
|
|
||||||
transform: { ...initialTransform, rotate: snappedRotation },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
activeHandle,
|
|
||||||
canvasSize,
|
|
||||||
editor,
|
|
||||||
isShiftHeldRef,
|
|
||||||
onSnapLinesChange,
|
|
||||||
viewport,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback(() => {
|
|
||||||
if (
|
|
||||||
scaleStateRef.current ||
|
|
||||||
rotationStateRef.current ||
|
|
||||||
edgeScaleStateRef.current
|
|
||||||
) {
|
|
||||||
editor.timeline.commitPreview();
|
|
||||||
clearActiveHandleState();
|
|
||||||
}
|
|
||||||
releaseCapturedPointer();
|
|
||||||
}, [clearActiveHandleState, editor, releaseCapturedPointer]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedWithBounds,
|
selectedWithBounds,
|
||||||
hasVisualSelection,
|
hasVisualSelection,
|
||||||
activeHandle,
|
activeHandle: controller.activeHandle,
|
||||||
handleCornerPointerDown,
|
handleCornerPointerDown: controller.onCornerPointerDown,
|
||||||
handleEdgePointerDown,
|
handleEdgePointerDown: controller.onEdgePointerDown,
|
||||||
handleRotationPointerDown,
|
handleRotationPointerDown: controller.onRotationPointerDown,
|
||||||
handlePointerMove,
|
handlePointerMove: controller.onPointerMove,
|
||||||
handlePointerUp,
|
handlePointerUp: controller.onPointerUp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from "mediabunny";
|
} from "mediabunny";
|
||||||
|
|
||||||
interface VideoSinkData {
|
interface VideoSinkData {
|
||||||
|
input: Input;
|
||||||
sink: CanvasSink;
|
sink: CanvasSink;
|
||||||
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
|
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
|
||||||
currentFrame: WrappedCanvas | null;
|
currentFrame: WrappedCanvas | null;
|
||||||
|
|
@ -20,6 +21,7 @@ export class VideoCache {
|
||||||
private sinks = new Map<string, VideoSinkData>();
|
private sinks = new Map<string, VideoSinkData>();
|
||||||
private initPromises = new Map<string, Promise<void>>();
|
private initPromises = new Map<string, Promise<void>>();
|
||||||
private frameChain = new Map<string, Promise<unknown>>();
|
private frameChain = new Map<string, Promise<unknown>>();
|
||||||
|
private seekGenerations = new Map<string, number>();
|
||||||
|
|
||||||
async getFrameAt({
|
async getFrameAt({
|
||||||
mediaId,
|
mediaId,
|
||||||
|
|
@ -35,11 +37,20 @@ export class VideoCache {
|
||||||
const sinkData = this.sinks.get(mediaId);
|
const sinkData = this.sinks.get(mediaId);
|
||||||
if (!sinkData) return null;
|
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 previous = this.frameChain.get(mediaId) ?? Promise.resolve();
|
||||||
const current = previous.then(() =>
|
const current = previous.then(() => {
|
||||||
this.resolveFrame({ sinkData, time }),
|
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;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -172,18 +183,7 @@ export class VideoCache {
|
||||||
|
|
||||||
if (frame) {
|
if (frame) {
|
||||||
sinkData.currentFrame = frame;
|
sinkData.currentFrame = frame;
|
||||||
|
this.startPrefetch({ sinkData });
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -262,12 +262,12 @@ export class VideoCache {
|
||||||
mediaId: string;
|
mediaId: string;
|
||||||
file: File;
|
file: File;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
try {
|
const input = new Input({
|
||||||
const input = new Input({
|
source: new BlobSource(file),
|
||||||
source: new BlobSource(file),
|
formats: ALL_FORMATS,
|
||||||
formats: ALL_FORMATS,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
try {
|
||||||
const videoTrack = await input.getPrimaryVideoTrack();
|
const videoTrack = await input.getPrimaryVideoTrack();
|
||||||
if (!videoTrack) {
|
if (!videoTrack) {
|
||||||
throw new Error("No video track found");
|
throw new Error("No video track found");
|
||||||
|
|
@ -284,6 +284,7 @@ export class VideoCache {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sinks.set(mediaId, {
|
this.sinks.set(mediaId, {
|
||||||
|
input,
|
||||||
sink,
|
sink,
|
||||||
iterator: null,
|
iterator: null,
|
||||||
currentFrame: null,
|
currentFrame: null,
|
||||||
|
|
@ -293,6 +294,7 @@ export class VideoCache {
|
||||||
prefetchPromise: null,
|
prefetchPromise: null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
input.dispose();
|
||||||
console.error(`Failed to initialize video sink for ${mediaId}:`, error);
|
console.error(`Failed to initialize video sink for ${mediaId}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -305,10 +307,13 @@ export class VideoCache {
|
||||||
void sinkData.iterator.return();
|
void sinkData.iterator.return();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sinkData.input.dispose();
|
||||||
this.sinks.delete(mediaId);
|
this.sinks.delete(mediaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.initPromises.delete(mediaId);
|
this.initPromises.delete(mediaId);
|
||||||
|
this.frameChain.delete(mediaId);
|
||||||
|
this.seekGenerations.delete(mediaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAll(): void {
|
clearAll(): void {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import type { BookmarkDragState } from "../hooks/use-bookmark-drag";
|
||||||
import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme";
|
import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme";
|
||||||
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout";
|
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout";
|
||||||
import { DEFAULT_FPS } from "@/fps/defaults";
|
import { DEFAULT_FPS } from "@/fps/defaults";
|
||||||
import { snappedSeekTime } from "opencut-wasm";
|
|
||||||
import {
|
import {
|
||||||
ArrowTurnBackwardIcon,
|
ArrowTurnBackwardIcon,
|
||||||
Delete02Icon,
|
Delete02Icon,
|
||||||
|
|
@ -30,6 +29,7 @@ import {
|
||||||
type MediaTime,
|
type MediaTime,
|
||||||
mediaTimeFromSeconds,
|
mediaTimeFromSeconds,
|
||||||
mediaTimeToSeconds,
|
mediaTimeToSeconds,
|
||||||
|
snapSeekMediaTime,
|
||||||
subMediaTime,
|
subMediaTime,
|
||||||
ZERO_MEDIA_TIME,
|
ZERO_MEDIA_TIME,
|
||||||
} from "@/wasm";
|
} from "@/wasm";
|
||||||
|
|
@ -51,7 +51,7 @@ function seekToBookmarkTime({
|
||||||
const activeProject = editor.project.getActive();
|
const activeProject = editor.project.getActive();
|
||||||
const duration = editor.timeline.getTotalDuration();
|
const duration = editor.timeline.getTotalDuration();
|
||||||
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
|
const rate = activeProject?.settings.fps ?? DEFAULT_FPS;
|
||||||
const snappedTime = (snappedSeekTime({ time, duration, rate }) ?? time) as MediaTime;
|
const snappedTime = snapSeekMediaTime({ time, duration, fps: rate });
|
||||||
editor.playback.seek({ time: snappedTime });
|
editor.playback.seek({ time: snappedTime });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import {
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||||
import { roundToFrame } from "opencut-wasm";
|
|
||||||
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
|
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
|
||||||
import {
|
import {
|
||||||
buildTimelineSnapPoints,
|
buildTimelineSnapPoints,
|
||||||
|
|
@ -21,7 +20,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||||
import type { Bookmark } from "@/timeline";
|
import type { Bookmark } from "@/timeline";
|
||||||
import { type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
import { roundFrameTime, type MediaTime, ZERO_MEDIA_TIME } from "@/wasm";
|
||||||
|
|
||||||
export interface BookmarkDragState {
|
export interface BookmarkDragState {
|
||||||
isDragging: boolean;
|
isDragging: boolean;
|
||||||
|
|
@ -117,7 +116,7 @@ export function useBookmarkDrag({
|
||||||
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
|
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
snappedTime: result.snappedTime as MediaTime,
|
snappedTime: result.snappedTime,
|
||||||
snapPoint: result.snapPoint,
|
snapPoint: result.snapPoint,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
@ -165,12 +164,10 @@ export function useBookmarkDrag({
|
||||||
});
|
});
|
||||||
const clampedTime =
|
const clampedTime =
|
||||||
mouseTime > duration ? duration : mouseTime;
|
mouseTime > duration ? duration : mouseTime;
|
||||||
const frameSnappedTime = (
|
const frameSnappedTime = roundFrameTime({
|
||||||
roundToFrame({
|
time: clampedTime,
|
||||||
time: clampedTime,
|
fps: activeProject.settings.fps,
|
||||||
rate: activeProject.settings.fps,
|
});
|
||||||
}) ?? clampedTime
|
|
||||||
) as MediaTime;
|
|
||||||
const { snappedTime: initialTime } = getSnapResult({
|
const { snappedTime: initialTime } = getSnapResult({
|
||||||
rawTime: frameSnappedTime,
|
rawTime: frameSnappedTime,
|
||||||
excludeBookmarkTime: bookmarkTime,
|
excludeBookmarkTime: bookmarkTime,
|
||||||
|
|
@ -199,10 +196,10 @@ export function useBookmarkDrag({
|
||||||
});
|
});
|
||||||
const clampedTime =
|
const clampedTime =
|
||||||
mouseTime > duration ? duration : mouseTime;
|
mouseTime > duration ? duration : mouseTime;
|
||||||
const frameSnappedTime = (
|
const frameSnappedTime = roundFrameTime({
|
||||||
roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ??
|
time: clampedTime,
|
||||||
clampedTime
|
fps: activeProject.settings.fps,
|
||||||
) as MediaTime;
|
});
|
||||||
const snapResult = getSnapResult({
|
const snapResult = getSnapResult({
|
||||||
rawTime: frameSnappedTime,
|
rawTime: frameSnappedTime,
|
||||||
excludeBookmarkTime: dragState.bookmarkTime,
|
excludeBookmarkTime: dragState.bookmarkTime,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import type { Bookmark } from "@/timeline";
|
import type { Bookmark } from "@/timeline";
|
||||||
import type { SnapPoint } from "@/timeline/snapping";
|
import type { SnapPoint } from "@/timeline/snapping";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function getBookmarkSnapPoints({
|
export function getBookmarkSnapPoints({
|
||||||
bookmarks,
|
bookmarks,
|
||||||
excludeBookmarkTime,
|
excludeBookmarkTime,
|
||||||
}: {
|
}: {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
excludeBookmarkTime?: number;
|
excludeBookmarkTime?: MediaTime;
|
||||||
}): SnapPoint[] {
|
}): SnapPoint[] {
|
||||||
return bookmarks.flatMap((bookmark) => {
|
return bookmarks.flatMap((bookmark) => {
|
||||||
if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) {
|
if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import type { Bookmark } from "@/timeline";
|
import type { Bookmark } from "@/timeline";
|
||||||
import type { FrameRate } from "opencut-wasm";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import { roundToFrame } from "opencut-wasm";
|
import { addMediaTime, roundFrameTime, type MediaTime } from "@/wasm";
|
||||||
import { addMediaTime, type MediaTime } from "@/wasm";
|
|
||||||
|
|
||||||
function bookmarkTimeEqual({
|
function bookmarkTimeEqual({
|
||||||
bookmarkTime,
|
bookmarkTime,
|
||||||
|
|
@ -114,7 +113,7 @@ export function getFrameTime({
|
||||||
time: MediaTime;
|
time: MediaTime;
|
||||||
fps: FrameRate;
|
fps: FrameRate;
|
||||||
}): MediaTime {
|
}): MediaTime {
|
||||||
return (roundToFrame({ time, rate: fps }) ?? time) as MediaTime;
|
return roundFrameTime({ time, fps });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBookmarkAtTime({
|
export function getBookmarkAtTime({
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import {
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { MediaTime } from "@/wasm";
|
import type { MediaTime } from "@/wasm";
|
||||||
import type { ElementDragState, DropTarget } from "@/timeline";
|
import type { ElementDragView, DropTarget } from "@/timeline";
|
||||||
import { TimelineTrackContent } from "./timeline-track";
|
import { TimelineTrackContent } from "./timeline-track";
|
||||||
import { TimelinePlayhead } from "./timeline-playhead";
|
import { TimelinePlayhead } from "./timeline-playhead";
|
||||||
import { SelectionBox } from "@/selection/selection-box";
|
import { SelectionBox } from "@/selection/selection-box";
|
||||||
|
|
@ -288,20 +288,15 @@ export function Timeline() {
|
||||||
isReady: tracks.length > 0,
|
isReady: tracks.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const { dragView, handleElementMouseDown, handleElementClick } =
|
||||||
dragState,
|
useElementInteraction({
|
||||||
dragDropTarget,
|
|
||||||
handleElementMouseDown,
|
|
||||||
handleElementClick,
|
|
||||||
lastMouseXRef,
|
|
||||||
} = useElementInteraction({
|
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
timelineRef,
|
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
snappingEnabled,
|
snappingEnabled,
|
||||||
onSnapPointChange: handleSnapPointChange,
|
onSnapPointChange: handleSnapPointChange,
|
||||||
});
|
});
|
||||||
|
const isElementDragging = dragView.kind === "dragging";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
dragState: bookmarkDragState,
|
dragState: bookmarkDragState,
|
||||||
|
|
@ -392,10 +387,19 @@ export function Timeline() {
|
||||||
contentWidth: dynamicTimelineWidth,
|
contentWidth: dynamicTimelineWidth,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEdgeAutoScroll({
|
||||||
|
isActive: isElementDragging,
|
||||||
|
getMouseClientX: () =>
|
||||||
|
dragView.kind === "dragging" ? dragView.currentMouseX : 0,
|
||||||
|
rulerScrollRef,
|
||||||
|
tracksScrollRef,
|
||||||
|
contentWidth: dynamicTimelineWidth,
|
||||||
|
});
|
||||||
|
|
||||||
const showSnapIndicator =
|
const showSnapIndicator =
|
||||||
snappingEnabled &&
|
snappingEnabled &&
|
||||||
currentSnapPoint !== null &&
|
currentSnapPoint !== null &&
|
||||||
(dragState.isDragging || bookmarkDragState.isDragging || isResizing);
|
(isElementDragging || bookmarkDragState.isDragging || isResizing);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleTracksMouseDown,
|
handleTracksMouseDown,
|
||||||
|
|
@ -458,9 +462,9 @@ export function Timeline() {
|
||||||
headerHeight={timelineHeaderHeight}
|
headerHeight={timelineHeaderHeight}
|
||||||
/>
|
/>
|
||||||
<DragLine
|
<DragLine
|
||||||
dropTarget={dragDropTarget}
|
dropTarget={isElementDragging ? dragView.dropTarget : null}
|
||||||
tracks={tracks}
|
tracks={tracks}
|
||||||
isVisible={dragState.isDragging}
|
isVisible={isElementDragging}
|
||||||
headerHeight={timelineHeaderHeight}
|
headerHeight={timelineHeaderHeight}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -541,9 +545,7 @@ export function Timeline() {
|
||||||
<TimelineTrackRows
|
<TimelineTrackRows
|
||||||
mainTrackId={mainTrackId}
|
mainTrackId={mainTrackId}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
dragState={dragState}
|
dragView={dragView}
|
||||||
tracksScrollRef={tracksScrollRef}
|
|
||||||
lastMouseXRef={lastMouseXRef}
|
|
||||||
onResizeStart={handleResizeStart}
|
onResizeStart={handleResizeStart}
|
||||||
onElementMouseDown={handleElementMouseDown}
|
onElementMouseDown={handleElementMouseDown}
|
||||||
onElementClick={handleElementClick}
|
onElementClick={handleElementClick}
|
||||||
|
|
@ -719,9 +721,7 @@ function TrackLabelsPanel({
|
||||||
function TimelineTrackRows({
|
function TimelineTrackRows({
|
||||||
mainTrackId,
|
mainTrackId,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
dragState,
|
dragView,
|
||||||
tracksScrollRef,
|
|
||||||
lastMouseXRef,
|
|
||||||
onResizeStart,
|
onResizeStart,
|
||||||
onElementMouseDown,
|
onElementMouseDown,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
|
|
@ -733,9 +733,7 @@ function TimelineTrackRows({
|
||||||
}: {
|
}: {
|
||||||
mainTrackId: string | null;
|
mainTrackId: string | null;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
dragState: ElementDragState;
|
dragView: ElementDragView;
|
||||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
lastMouseXRef: React.RefObject<number>;
|
|
||||||
onResizeStart: React.ComponentProps<
|
onResizeStart: React.ComponentProps<
|
||||||
typeof TimelineTrackContent
|
typeof TimelineTrackContent
|
||||||
>["onResizeStart"];
|
>["onResizeStart"];
|
||||||
|
|
@ -777,8 +775,16 @@ function TimelineTrackRows({
|
||||||
[tracks, expandedElementIds],
|
[tracks, expandedElementIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const draggingElementIds = useMemo(
|
||||||
|
() =>
|
||||||
|
dragView.kind === "dragging"
|
||||||
|
? dragView.memberTimeOffsets
|
||||||
|
: (null as ReadonlyMap<string, MediaTime> | null),
|
||||||
|
[dragView],
|
||||||
|
);
|
||||||
const sortedTracks = useMemo(() => {
|
const sortedTracks = useMemo(() => {
|
||||||
const draggingElementIds = new Set(dragState.dragElementIds);
|
if (!draggingElementIds)
|
||||||
|
return tracks.map((track, index) => ({ track, index }));
|
||||||
return [...tracks]
|
return [...tracks]
|
||||||
.map((track, index) => ({ track, index }))
|
.map((track, index) => ({ track, index }))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|
@ -792,7 +798,7 @@ function TimelineTrackRows({
|
||||||
if (bHasDragged) return -1;
|
if (bHasDragged) return -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
}, [tracks, dragState.dragElementIds]);
|
}, [tracks, draggingElementIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -812,10 +818,7 @@ function TimelineTrackRows({
|
||||||
<TimelineTrackContent
|
<TimelineTrackContent
|
||||||
track={track}
|
track={track}
|
||||||
zoomLevel={zoomLevel}
|
zoomLevel={zoomLevel}
|
||||||
dragState={dragState}
|
dragView={dragView}
|
||||||
rulerScrollRef={tracksScrollRef}
|
|
||||||
tracksScrollRef={tracksScrollRef}
|
|
||||||
lastMouseXRef={lastMouseXRef}
|
|
||||||
onResizeStart={onResizeStart}
|
onResizeStart={onResizeStart}
|
||||||
onElementMouseDown={onElementMouseDown}
|
onElementMouseDown={onElementMouseDown}
|
||||||
onElementClick={onElementClick}
|
onElementClick={onElementClick}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import {
|
||||||
import type {
|
import type {
|
||||||
TimelineElement as TimelineElementType,
|
TimelineElement as TimelineElementType,
|
||||||
TimelineTrack,
|
TimelineTrack,
|
||||||
ElementDragState,
|
ElementDragView,
|
||||||
VideoElement,
|
VideoElement,
|
||||||
ImageElement,
|
ImageElement,
|
||||||
AudioElement,
|
AudioElement,
|
||||||
|
|
@ -49,12 +49,7 @@ import {
|
||||||
import { buildWaveformGainSamples } from "@/timeline/audio-state";
|
import { buildWaveformGainSamples } from "@/timeline/audio-state";
|
||||||
import { getTimelinePixelsPerSecond } from "@/timeline";
|
import { getTimelinePixelsPerSecond } from "@/timeline";
|
||||||
import { buildWaveformSourceKey } from "@/media/waveform-summary";
|
import { buildWaveformSourceKey } from "@/media/waveform-summary";
|
||||||
import {
|
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
||||||
addMediaTime,
|
|
||||||
type MediaTime,
|
|
||||||
TICKS_PER_SECOND,
|
|
||||||
ZERO_MEDIA_TIME,
|
|
||||||
} from "@/wasm";
|
|
||||||
import {
|
import {
|
||||||
getActionDefinition,
|
getActionDefinition,
|
||||||
type TAction,
|
type TAction,
|
||||||
|
|
@ -219,7 +214,7 @@ interface TimelineElementProps {
|
||||||
event: React.MouseEvent,
|
event: React.MouseEvent,
|
||||||
element: TimelineElementType,
|
element: TimelineElementType,
|
||||||
) => void;
|
) => void;
|
||||||
dragState: ElementDragState;
|
dragView: ElementDragView;
|
||||||
isDropTarget?: boolean;
|
isDropTarget?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,7 +226,7 @@ export function TimelineElement({
|
||||||
onResizeStart,
|
onResizeStart,
|
||||||
onElementMouseDown,
|
onElementMouseDown,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
dragState,
|
dragView,
|
||||||
isDropTarget = false,
|
isDropTarget = false,
|
||||||
}: TimelineElementProps) {
|
}: TimelineElementProps) {
|
||||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||||
|
|
@ -257,16 +252,18 @@ export function TimelineElement({
|
||||||
selected.elementId === element.id && selected.trackId === track.id,
|
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 =
|
const dragOffsetY =
|
||||||
isBeingDragged && dragState.isDragging
|
isDragging && isBeingDragged
|
||||||
? dragState.currentMouseY - dragState.startMouseY
|
? dragView.currentMouseY - dragView.startMouseY
|
||||||
: 0;
|
: 0;
|
||||||
const dragTimeOffset =
|
|
||||||
dragState.dragTimeOffsets[element.id] ?? ZERO_MEDIA_TIME;
|
|
||||||
const elementStartTime =
|
const elementStartTime =
|
||||||
isBeingDragged && dragState.isDragging
|
isDragging && isBeingDragged
|
||||||
? addMediaTime({ a: dragState.currentTime, b: dragTimeOffset })
|
? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset })
|
||||||
: renderElement.startTime;
|
: renderElement.startTime;
|
||||||
const displayedStartTime = elementStartTime;
|
const displayedStartTime = elementStartTime;
|
||||||
const displayedDuration = renderElement.duration;
|
const displayedDuration = renderElement.duration;
|
||||||
|
|
@ -388,7 +385,7 @@ export function TimelineElement({
|
||||||
? `${baseTrackHeight + expansionHeight}px`
|
? `${baseTrackHeight + expansionHeight}px`
|
||||||
: "100%",
|
: "100%",
|
||||||
transform:
|
transform:
|
||||||
isBeingDragged && dragState.isDragging
|
isDragging && isBeingDragged
|
||||||
? `translate3d(0, ${dragOffsetY}px, 0)`
|
? `translate3d(0, ${dragOffsetY}px, 0)`
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,12 @@ import { TimelineElement } from "./timeline-element";
|
||||||
import type { TimelineTrack } from "@/timeline";
|
import type { TimelineTrack } from "@/timeline";
|
||||||
import type { TimelineElement as TimelineElementType } from "@/timeline";
|
import type { TimelineElement as TimelineElementType } from "@/timeline";
|
||||||
import { TIMELINE_LAYERS } from "./layers";
|
import { TIMELINE_LAYERS } from "./layers";
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
import type { ElementDragView } from "@/timeline";
|
||||||
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
|
||||||
import type { ElementDragState } from "@/timeline";
|
|
||||||
import { useEditor } from "@/editor/use-editor";
|
|
||||||
|
|
||||||
interface TimelineTrackContentProps {
|
interface TimelineTrackContentProps {
|
||||||
track: TimelineTrack;
|
track: TimelineTrack;
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
dragState: ElementDragState;
|
dragView: ElementDragView;
|
||||||
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
lastMouseXRef: React.RefObject<number>;
|
|
||||||
onResizeStart: (params: {
|
onResizeStart: (params: {
|
||||||
event: React.MouseEvent;
|
event: React.MouseEvent;
|
||||||
element: TimelineElementType;
|
element: TimelineElementType;
|
||||||
|
|
@ -42,10 +36,7 @@ interface TimelineTrackContentProps {
|
||||||
export function TimelineTrackContent({
|
export function TimelineTrackContent({
|
||||||
track,
|
track,
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
dragState,
|
dragView,
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
|
||||||
lastMouseXRef,
|
|
||||||
onResizeStart,
|
onResizeStart,
|
||||||
onElementMouseDown,
|
onElementMouseDown,
|
||||||
onElementClick,
|
onElementClick,
|
||||||
|
|
@ -55,15 +46,6 @@ export function TimelineTrackContent({
|
||||||
targetElementId = null,
|
targetElementId = null,
|
||||||
}: TimelineTrackContentProps) {
|
}: TimelineTrackContentProps) {
|
||||||
const { isElementSelected } = useElementSelection();
|
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 (
|
return (
|
||||||
<div className="relative size-full">
|
<div className="relative size-full">
|
||||||
|
|
@ -120,7 +102,7 @@ export function TimelineTrackContent({
|
||||||
onElementClick={(event, element) =>
|
onElementClick={(event, element) =>
|
||||||
onElementClick({ event, element, track })
|
onElementClick({ event, element, track })
|
||||||
}
|
}
|
||||||
dragState={dragState}
|
dragView={dragView}
|
||||||
isDropTarget={element.id === targetElementId}
|
isDropTarget={element.id === targetElementId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,577 @@
|
||||||
|
import type { DragEvent } from "react";
|
||||||
|
import { processMediaAssets } from "@/media/processing";
|
||||||
|
import { showMediaUploadToast } from "@/media/upload-toast";
|
||||||
|
import {
|
||||||
|
DEFAULT_NEW_ELEMENT_DURATION,
|
||||||
|
toElementDurationTicks,
|
||||||
|
} from "@/timeline/creation";
|
||||||
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||||
|
import 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";
|
||||||
|
import { roundFrameTime, type MediaTime } from "@/wasm";
|
||||||
|
|
||||||
|
// --- Config ---
|
||||||
|
|
||||||
|
export interface DragDropConfig {
|
||||||
|
zoomLevel: number;
|
||||||
|
getContainerEl: () => HTMLDivElement | null;
|
||||||
|
getHeaderEl: () => HTMLElement | null;
|
||||||
|
getTracksScrollEl: () => HTMLDivElement | null;
|
||||||
|
getActiveProjectFps: () => FrameRate | null;
|
||||||
|
getActiveProjectId: () => string | null;
|
||||||
|
getSceneTracks: () => SceneTracks;
|
||||||
|
getCurrentPlayheadTime: () => MediaTime;
|
||||||
|
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[];
|
||||||
|
}): MediaTime {
|
||||||
|
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
|
||||||
|
? roundFrameTime({ time: target.xPosition, fps })
|
||||||
|
: target.xPosition;
|
||||||
|
|
||||||
|
this.setOver({ dropTarget: target, elementType });
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
}
|
||||||
|
|
||||||
|
onDragLeave(event: DragEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
if (this.enterCount === 0) return;
|
||||||
|
this.enterCount -= 1;
|
||||||
|
if (this.enterCount === 0) {
|
||||||
|
this.setIdle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDrop(event: DragEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.enterCount = 0;
|
||||||
|
|
||||||
|
const dragData = this.config.dragSource.getActive();
|
||||||
|
const hasFiles = event.dataTransfer.files?.length > 0;
|
||||||
|
if (!dragData && !hasFiles) return;
|
||||||
|
|
||||||
|
const currentTarget = this.dropTarget;
|
||||||
|
this.setIdle();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (dragData) {
|
||||||
|
if (!currentTarget) return;
|
||||||
|
this.executeAssetDrop({ target: currentTarget, dragData });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = this.getMouseTimelineCoords({ event });
|
||||||
|
if (!coords) return;
|
||||||
|
this.executeFileDrop({
|
||||||
|
files: Array.from(event.dataTransfer.files),
|
||||||
|
mouseX: coords.mouseX,
|
||||||
|
mouseY: coords.mouseY,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error("Failed to process file drop:", error);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to process drop:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private ---
|
||||||
|
|
||||||
|
private setOver(state: {
|
||||||
|
dropTarget: DropTarget | null;
|
||||||
|
elementType: ElementType | null;
|
||||||
|
}): void {
|
||||||
|
this.state = { kind: "over", ...state };
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setIdle(): void {
|
||||||
|
this.state = { kind: "idle" };
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const fn of this.subscribers) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMouseTimelineCoords({
|
||||||
|
event,
|
||||||
|
}: {
|
||||||
|
event: DragEvent;
|
||||||
|
}): TimelineCoords | null {
|
||||||
|
const scrollContainer = this.config.getTracksScrollEl();
|
||||||
|
const referenceRect =
|
||||||
|
scrollContainer?.getBoundingClientRect() ??
|
||||||
|
this.config.getContainerEl()?.getBoundingClientRect();
|
||||||
|
if (!referenceRect) return null;
|
||||||
|
|
||||||
|
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
||||||
|
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||||
|
const headerHeight =
|
||||||
|
this.config.getHeaderEl()?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mouseX: event.clientX - referenceRect.left + scrollLeft,
|
||||||
|
mouseY: event.clientY - referenceRect.top + scrollTop - headerHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared insertion logic — new track vs existing track.
|
||||||
|
private insertAtTarget({
|
||||||
|
element,
|
||||||
|
target,
|
||||||
|
trackType,
|
||||||
|
}: {
|
||||||
|
element: CreateTimelineElement;
|
||||||
|
target: DropTarget;
|
||||||
|
trackType: TrackType;
|
||||||
|
}): void {
|
||||||
|
if (target.isNewTrack) {
|
||||||
|
const addTrackCmd = new AddTrackCommand(trackType, target.trackIndex);
|
||||||
|
this.config.executeCommand(
|
||||||
|
new BatchCommand([
|
||||||
|
addTrackCmd,
|
||||||
|
new InsertElementCommand({
|
||||||
|
element,
|
||||||
|
placement: { mode: "explicit", trackId: addTrackCmd.getTrackId() },
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks = orderedTracks({ sceneTracks: this.config.getSceneTracks() });
|
||||||
|
const track = tracks[target.trackIndex];
|
||||||
|
if (!track) return;
|
||||||
|
this.config.insertElement({
|
||||||
|
placement: { mode: "explicit", trackId: track.id },
|
||||||
|
element,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeAssetDrop({
|
||||||
|
target,
|
||||||
|
dragData,
|
||||||
|
}: {
|
||||||
|
target: DropTarget;
|
||||||
|
dragData: TimelineDragData;
|
||||||
|
}): void {
|
||||||
|
switch (dragData.type) {
|
||||||
|
case "text":
|
||||||
|
this.executeTextDrop({ target, dragData });
|
||||||
|
return;
|
||||||
|
case "graphic":
|
||||||
|
this.executeGraphicDrop({ target, dragData });
|
||||||
|
return;
|
||||||
|
case "sticker":
|
||||||
|
this.executeStickerDrop({ target, dragData });
|
||||||
|
return;
|
||||||
|
case "effect":
|
||||||
|
this.executeEffectDrop({ target, dragData });
|
||||||
|
return;
|
||||||
|
case "media":
|
||||||
|
this.executeMediaDrop({ target, dragData });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeTextDrop({
|
||||||
|
target,
|
||||||
|
dragData,
|
||||||
|
}: {
|
||||||
|
target: DropTarget;
|
||||||
|
dragData: Extract<TimelineDragData, { type: "text" }>;
|
||||||
|
}): void {
|
||||||
|
const element = buildTextElement({
|
||||||
|
raw: { name: dragData.name ?? "", content: dragData.content ?? "" },
|
||||||
|
startTime: target.xPosition,
|
||||||
|
});
|
||||||
|
this.insertAtTarget({ element, target, trackType: "text" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeStickerDrop({
|
||||||
|
target,
|
||||||
|
dragData,
|
||||||
|
}: {
|
||||||
|
target: DropTarget;
|
||||||
|
dragData: Extract<TimelineDragData, { type: "sticker" }>;
|
||||||
|
}): void {
|
||||||
|
const element = buildStickerElement({
|
||||||
|
stickerId: dragData.stickerId,
|
||||||
|
name: dragData.name,
|
||||||
|
startTime: target.xPosition,
|
||||||
|
});
|
||||||
|
this.insertAtTarget({ element, target, trackType: "graphic" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeGraphicDrop({
|
||||||
|
target,
|
||||||
|
dragData,
|
||||||
|
}: {
|
||||||
|
target: DropTarget;
|
||||||
|
dragData: Extract<TimelineDragData, { type: "graphic" }>;
|
||||||
|
}): void {
|
||||||
|
const element = buildGraphicElement({
|
||||||
|
definitionId: dragData.definitionId,
|
||||||
|
name: dragData.name,
|
||||||
|
startTime: target.xPosition,
|
||||||
|
params: dragData.params,
|
||||||
|
});
|
||||||
|
this.insertAtTarget({ element, target, trackType: "graphic" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeMediaDrop({
|
||||||
|
target,
|
||||||
|
dragData,
|
||||||
|
}: {
|
||||||
|
target: DropTarget;
|
||||||
|
dragData: Extract<TimelineDragData, { type: "media" }>;
|
||||||
|
}): void {
|
||||||
|
if (target.targetElement) {
|
||||||
|
// Replace media source — not yet implemented
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaAsset = this.config
|
||||||
|
.getMediaAssets()
|
||||||
|
.find((asset) => asset.id === dragData.id);
|
||||||
|
if (!mediaAsset) return;
|
||||||
|
|
||||||
|
const trackType: TrackType =
|
||||||
|
dragData.mediaType === "audio" ? "audio" : "video";
|
||||||
|
const element = buildElementFromMedia({
|
||||||
|
mediaId: mediaAsset.id,
|
||||||
|
mediaType: mediaAsset.type,
|
||||||
|
name: mediaAsset.name,
|
||||||
|
duration: toElementDurationTicks({ seconds: mediaAsset.duration }),
|
||||||
|
startTime: target.xPosition,
|
||||||
|
});
|
||||||
|
this.insertAtTarget({ element, target, trackType });
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeEffectDrop({
|
||||||
|
target,
|
||||||
|
dragData,
|
||||||
|
}: {
|
||||||
|
target: DropTarget;
|
||||||
|
dragData: Extract<TimelineDragData, { type: "effect" }>;
|
||||||
|
}): void {
|
||||||
|
if (target.targetElement) {
|
||||||
|
this.config.addClipEffect({
|
||||||
|
trackId: target.targetElement.trackId,
|
||||||
|
elementId: target.targetElement.elementId,
|
||||||
|
effectType: dragData.effectType,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = buildEffectElement({
|
||||||
|
effectType: dragData.effectType,
|
||||||
|
startTime: target.xPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingEffectTrack = orderedTracks({
|
||||||
|
sceneTracks: this.config.getSceneTracks(),
|
||||||
|
}).find((track) => track.type === "effect");
|
||||||
|
|
||||||
|
if (existingEffectTrack) {
|
||||||
|
this.config.insertElement({
|
||||||
|
placement: { mode: "explicit", trackId: existingEffectTrack.id },
|
||||||
|
element,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.insertAtTarget({ element, target, trackType: "effect" });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeFileDrop({
|
||||||
|
files,
|
||||||
|
mouseX,
|
||||||
|
mouseY,
|
||||||
|
}: {
|
||||||
|
files: File[];
|
||||||
|
mouseX: number;
|
||||||
|
mouseY: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const projectId = this.config.getActiveProjectId();
|
||||||
|
if (!projectId) return;
|
||||||
|
|
||||||
|
await showMediaUploadToast({
|
||||||
|
filesCount: files.length,
|
||||||
|
promise: async () => {
|
||||||
|
const processedAssets = await processMediaAssets({ files });
|
||||||
|
|
||||||
|
// Sequential on purpose: each iteration reads getSceneTracks()
|
||||||
|
// to decide placement (reuse empty main vs new track) and that
|
||||||
|
// decision depends on the effects of prior inserts.
|
||||||
|
for (const asset of processedAssets) {
|
||||||
|
const createdAsset = await this.config.addMediaAsset({
|
||||||
|
projectId,
|
||||||
|
asset,
|
||||||
|
});
|
||||||
|
if (!createdAsset) continue;
|
||||||
|
|
||||||
|
const duration = toElementDurationTicks({
|
||||||
|
seconds: createdAsset.duration,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sceneTracks = this.config.getSceneTracks();
|
||||||
|
const currentTime = this.config.getCurrentPlayheadTime();
|
||||||
|
|
||||||
|
const reuseMainTrackId =
|
||||||
|
createdAsset.type !== "audio" &&
|
||||||
|
sceneTracks.overlay.length === 0 &&
|
||||||
|
sceneTracks.audio.length === 0 &&
|
||||||
|
sceneTracks.main.elements.length === 0
|
||||||
|
? sceneTracks.main.id
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (reuseMainTrackId) {
|
||||||
|
this.config.insertElement({
|
||||||
|
placement: { mode: "explicit", trackId: reuseMainTrackId },
|
||||||
|
element: buildElementFromMedia({
|
||||||
|
mediaId: createdAsset.id,
|
||||||
|
mediaType: createdAsset.type,
|
||||||
|
name: createdAsset.name,
|
||||||
|
duration,
|
||||||
|
startTime: currentTime,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropTarget = computeDropTarget({
|
||||||
|
elementType: createdAsset.type,
|
||||||
|
mouseX,
|
||||||
|
mouseY,
|
||||||
|
tracks: sceneTracks,
|
||||||
|
playheadTime: currentTime,
|
||||||
|
isExternalDrop: true,
|
||||||
|
elementDuration: duration,
|
||||||
|
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
||||||
|
zoomLevel: this.config.zoomLevel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trackType: TrackType =
|
||||||
|
createdAsset.type === "audio" ? "audio" : "video";
|
||||||
|
this.insertAtTarget({
|
||||||
|
element: buildElementFromMedia({
|
||||||
|
mediaId: createdAsset.id,
|
||||||
|
mediaType: createdAsset.type,
|
||||||
|
name: createdAsset.name,
|
||||||
|
duration,
|
||||||
|
startTime: dropTarget.xPosition,
|
||||||
|
}),
|
||||||
|
target: dropTarget,
|
||||||
|
trackType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadedCount: processedAssets.length,
|
||||||
|
assetNames: processedAssets.map((asset) => asset.name),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,702 @@
|
||||||
|
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 {
|
||||||
|
maxMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
mediaTime,
|
||||||
|
roundFrameTime,
|
||||||
|
subMediaTime,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
|
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||||
|
import 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: () => MediaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: MediaTime;
|
||||||
|
readonly clickOffsetTime: MediaTime;
|
||||||
|
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: MediaTime;
|
||||||
|
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,
|
||||||
|
): MediaTime {
|
||||||
|
const clickOffsetX = clientX - elementRect.left;
|
||||||
|
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
||||||
|
return mediaTime({ ticks: 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: MediaTime;
|
||||||
|
fps: FrameRate;
|
||||||
|
}): MediaTime {
|
||||||
|
const mouseTime = getMouseTimeFromClientX({
|
||||||
|
clientX,
|
||||||
|
containerRect: scrollContainer.getBoundingClientRect(),
|
||||||
|
zoomLevel,
|
||||||
|
scrollLeft: scrollContainer.scrollLeft,
|
||||||
|
});
|
||||||
|
const adjusted = maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: subMediaTime({ a: mouseTime, b: clickOffsetTime }),
|
||||||
|
});
|
||||||
|
return roundFrameTime({ time: adjusted, fps });
|
||||||
|
}
|
||||||
|
|
||||||
|
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: MediaTime;
|
||||||
|
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: MediaTime;
|
||||||
|
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, MediaTime>();
|
||||||
|
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: MediaTime,
|
||||||
|
group: MoveGroup,
|
||||||
|
): { snappedTime: MediaTime; 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: MediaTime;
|
||||||
|
}): void {
|
||||||
|
const { scene, viewport } = this.deps;
|
||||||
|
const tracks = scene.getTracks();
|
||||||
|
const zoomLevel = viewport.getZoomLevel();
|
||||||
|
|
||||||
|
const anchorDropTarget = resolveDropTarget({
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
elementId: mousedown.elementId,
|
||||||
|
trackId: mousedown.trackId,
|
||||||
|
tracks,
|
||||||
|
viewport,
|
||||||
|
zoomLevel,
|
||||||
|
snappedTime,
|
||||||
|
verticalDragDirection: verticalDirection(mousedown.origin.y, clientY),
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextGroupMoveResult = anchorDropTarget
|
||||||
|
? resolveGroupMoveForDrop({
|
||||||
|
group: drag.moveGroup,
|
||||||
|
tracks,
|
||||||
|
anchorStartTime: snappedTime,
|
||||||
|
dropTarget: anchorDropTarget,
|
||||||
|
reservedNewTrackIds: drag.reservedNewTrackIds,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
drag.groupMoveResult = nextGroupMoveResult;
|
||||||
|
drag.dropTarget =
|
||||||
|
anchorDropTarget && (anchorDropTarget.isNewTrack || !nextGroupMoveResult)
|
||||||
|
? { ...anchorDropTarget, isNewTrack: true }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseMove = ({ clientX, clientY }: MouseEvent): void => {
|
||||||
|
const scrollContainer = this.deps.viewport.getTracksScrollEl();
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
if (this.session.kind === "pending") {
|
||||||
|
this.beginDragFromPending({
|
||||||
|
mousedown: this.session.mousedown,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
scrollContainer,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.session.kind === "dragging") {
|
||||||
|
this.updateActiveDrag({
|
||||||
|
mousedown: this.session.mousedown,
|
||||||
|
drag: this.session.drag,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
scrollContainer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private beginDragFromPending({
|
||||||
|
mousedown,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
scrollContainer,
|
||||||
|
}: {
|
||||||
|
mousedown: MousedownSnapshot;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
scrollContainer: HTMLDivElement;
|
||||||
|
}): void {
|
||||||
|
if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fps = this.deps.scene.getActiveFps();
|
||||||
|
if (!fps) return;
|
||||||
|
|
||||||
|
const moveGroup = buildMoveGroup({
|
||||||
|
anchorRef: {
|
||||||
|
trackId: mousedown.trackId,
|
||||||
|
elementId: mousedown.elementId,
|
||||||
|
},
|
||||||
|
selectedElements: [...mousedown.selectedElements],
|
||||||
|
tracks: this.deps.scene.getTracks(),
|
||||||
|
});
|
||||||
|
if (!moveGroup) return;
|
||||||
|
|
||||||
|
const zoomLevel = this.deps.viewport.getZoomLevel();
|
||||||
|
const frameSnappedTime = frameSnappedMouseTime({
|
||||||
|
clientX,
|
||||||
|
scrollContainer,
|
||||||
|
zoomLevel,
|
||||||
|
clickOffsetTime: mousedown.clickOffsetTime,
|
||||||
|
fps,
|
||||||
|
});
|
||||||
|
const { snappedTime, snapPoint } = this.snapResult(
|
||||||
|
frameSnappedTime,
|
||||||
|
moveGroup,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure the anchor is selected before we render the drag — covers the
|
||||||
|
// case where the selection store hasn't committed the mousedown-time
|
||||||
|
// selection click yet.
|
||||||
|
const anchorRef = {
|
||||||
|
trackId: mousedown.trackId,
|
||||||
|
elementId: mousedown.elementId,
|
||||||
|
};
|
||||||
|
if (!this.deps.selection.isSelected(anchorRef)) {
|
||||||
|
this.deps.selection.select(anchorRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag: DragProgress = {
|
||||||
|
moveGroup,
|
||||||
|
reservedNewTrackIds: moveGroup.members.map(() => generateUUID()),
|
||||||
|
currentTime: snappedTime,
|
||||||
|
currentMouseX: clientX,
|
||||||
|
currentMouseY: clientY,
|
||||||
|
groupMoveResult: null,
|
||||||
|
dropTarget: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.session = { kind: "dragging", mousedown, drag };
|
||||||
|
this.lastGestureWasDrag = true;
|
||||||
|
|
||||||
|
this.updateDropTarget({
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
mousedown,
|
||||||
|
drag,
|
||||||
|
snappedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.deps.snap.onChange?.(snapPoint);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateActiveDrag({
|
||||||
|
mousedown,
|
||||||
|
drag,
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
scrollContainer,
|
||||||
|
}: {
|
||||||
|
mousedown: MousedownSnapshot;
|
||||||
|
drag: DragProgress;
|
||||||
|
clientX: number;
|
||||||
|
clientY: number;
|
||||||
|
scrollContainer: HTMLDivElement;
|
||||||
|
}): void {
|
||||||
|
const fps = this.deps.scene.getActiveFps();
|
||||||
|
if (!fps) return;
|
||||||
|
|
||||||
|
const frameSnappedTime = frameSnappedMouseTime({
|
||||||
|
clientX,
|
||||||
|
scrollContainer,
|
||||||
|
zoomLevel: this.deps.viewport.getZoomLevel(),
|
||||||
|
clickOffsetTime: mousedown.clickOffsetTime,
|
||||||
|
fps,
|
||||||
|
});
|
||||||
|
const { snappedTime, snapPoint } = this.snapResult(
|
||||||
|
frameSnappedTime,
|
||||||
|
drag.moveGroup,
|
||||||
|
);
|
||||||
|
|
||||||
|
drag.currentTime = snappedTime;
|
||||||
|
drag.currentMouseX = clientX;
|
||||||
|
drag.currentMouseY = clientY;
|
||||||
|
|
||||||
|
this.updateDropTarget({
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
mousedown,
|
||||||
|
drag,
|
||||||
|
snappedTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.deps.snap.onChange?.(snapPoint);
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseUp = ({ clientX, clientY }: MouseEvent): void => {
|
||||||
|
if (this.session.kind === "pending") {
|
||||||
|
this.finishSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.session.kind !== "dragging") return;
|
||||||
|
|
||||||
|
const { mousedown, drag } = this.session;
|
||||||
|
|
||||||
|
// If the drag returned within the click threshold of its origin, treat
|
||||||
|
// this as a cancel rather than a commit — the user dragged then put the
|
||||||
|
// element back.
|
||||||
|
if (!movedPastDragThreshold({ x: clientX, y: clientY }, mousedown.origin)) {
|
||||||
|
this.lastGestureWasDrag = false;
|
||||||
|
this.finishSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { moveGroup, groupMoveResult } = drag;
|
||||||
|
if (!groupMoveResult) {
|
||||||
|
this.finishSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const didMove = groupMoveResult.moves.some((move) => {
|
||||||
|
const member = moveGroup.members.find(
|
||||||
|
(m) => m.elementId === move.elementId,
|
||||||
|
);
|
||||||
|
const originalStartTime =
|
||||||
|
mousedown.startElementTime + (member?.timeOffset ?? 0);
|
||||||
|
return (
|
||||||
|
member?.trackId !== move.targetTrackId ||
|
||||||
|
originalStartTime !== move.newStartTime
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (didMove || groupMoveResult.createTracks.length > 0) {
|
||||||
|
this.deps.timeline.moveElements({
|
||||||
|
moves: groupMoveResult.moves,
|
||||||
|
createTracks: groupMoveResult.createTracks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.finishSession();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,358 @@
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||||
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
clampMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
mediaTime,
|
||||||
|
roundFrameTicks,
|
||||||
|
snapSeekMediaTime,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} 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: MediaTime;
|
||||||
|
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: MediaTime }) => void;
|
||||||
|
getTotalDuration: () => MediaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: MediaTime;
|
||||||
|
}): 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 absoluteIndicatorTime = addMediaTime({
|
||||||
|
a: displayedStartTime,
|
||||||
|
b: indicatorTime,
|
||||||
|
});
|
||||||
|
const seekTime =
|
||||||
|
fps != null
|
||||||
|
? snapSeekMediaTime({
|
||||||
|
time: absoluteIndicatorTime,
|
||||||
|
duration: getTotalDuration(),
|
||||||
|
fps,
|
||||||
|
})
|
||||||
|
: absoluteIndicatorTime;
|
||||||
|
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: MediaTime;
|
||||||
|
indicatorOffsetPx: number;
|
||||||
|
isBeingDragged: boolean;
|
||||||
|
displayedStartTime: MediaTime;
|
||||||
|
elementLeft: number;
|
||||||
|
}): number {
|
||||||
|
if (!isBeingDragged || this.session.kind !== "active")
|
||||||
|
return indicatorOffsetPx;
|
||||||
|
const deltaTime = mediaTime({ ticks: this.session.deltaTicks });
|
||||||
|
const clampedTime = clampMediaTime({
|
||||||
|
time: addMediaTime({ a: indicatorTime, b: deltaTime }),
|
||||||
|
min: ZERO_MEDIA_TIME,
|
||||||
|
max: this.config.element.duration,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
timelineTimeToSnappedPixels({
|
||||||
|
time: addMediaTime({ a: displayedStartTime, b: 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: clampMediaTime({
|
||||||
|
time: addMediaTime({
|
||||||
|
a: keyframe.time,
|
||||||
|
b: mediaTime({ ticks: deltaTicks }),
|
||||||
|
}),
|
||||||
|
min: ZERO_MEDIA_TIME,
|
||||||
|
max: element.duration,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
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 =
|
||||||
|
roundFrameTicks({ ticks: rawDeltaTicks, fps });
|
||||||
|
this.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseUp(): void {
|
||||||
|
if (this.session.kind === "pending") {
|
||||||
|
this.finishSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.session.kind !== "active") return;
|
||||||
|
|
||||||
|
const { selectedKeyframes, element } = this.config;
|
||||||
|
const { keyframeRefs, deltaTicks } = this.session;
|
||||||
|
const draggingIds = new Set(keyframeRefs.map((r) => r.keyframeId));
|
||||||
|
const draggingRefs = selectedKeyframes.filter(
|
||||||
|
(kf) => kf.elementId === element.id && draggingIds.has(kf.keyframeId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (draggingRefs.length > 0 && deltaTicks !== 0) {
|
||||||
|
this.commitDrag({ keyframeRefs: draggingRefs, deltaTicks });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.finishSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,318 @@
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
|
import {
|
||||||
|
mediaTime,
|
||||||
|
snapSeekMediaTime,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
type MediaTime,
|
||||||
|
} 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: MediaTime | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Session = { kind: "idle" } | ScrubSession;
|
||||||
|
|
||||||
|
// --- Config ---
|
||||||
|
|
||||||
|
export interface PlayheadConfig {
|
||||||
|
zoomLevel: number;
|
||||||
|
duration: MediaTime;
|
||||||
|
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: MediaTime) => void;
|
||||||
|
setScrubbing: (isScrubbing: boolean) => void;
|
||||||
|
setTimelineViewState: (viewState: {
|
||||||
|
zoomLevel: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
playheadTime: MediaTime;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayheadConfigRef {
|
||||||
|
readonly current: PlayheadConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Pure helpers (px → logical) ---
|
||||||
|
|
||||||
|
function pixelToTime({
|
||||||
|
clientX,
|
||||||
|
rulerEl,
|
||||||
|
zoomLevel,
|
||||||
|
duration,
|
||||||
|
}: {
|
||||||
|
clientX: number;
|
||||||
|
rulerEl: HTMLDivElement;
|
||||||
|
zoomLevel: number;
|
||||||
|
duration: MediaTime;
|
||||||
|
}): MediaTime {
|
||||||
|
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 mediaTime({ ticks: 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: MediaTime): 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: MediaTime): 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 = snapSeekMediaTime({ time: rawTime, duration, fps });
|
||||||
|
|
||||||
|
const time = (() => {
|
||||||
|
if (!isElementSnappingEnabled || this.config.isShiftHeld())
|
||||||
|
return frameTime;
|
||||||
|
|
||||||
|
const snapPoints = buildTimelineSnapPoints({
|
||||||
|
sources: [
|
||||||
|
() =>
|
||||||
|
getElementEdgeSnapPoints({ tracks: this.config.getSceneTracks() }),
|
||||||
|
() =>
|
||||||
|
getBookmarkSnapPoints({
|
||||||
|
bookmarks: this.config.getSceneBookmarks(),
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
getAnimationKeyframeSnapPointsForTimeline({
|
||||||
|
tracks: this.config.getSceneTracks(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = resolveTimelineSnap({
|
||||||
|
targetTime: frameTime,
|
||||||
|
snapPoints,
|
||||||
|
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
|
||||||
|
});
|
||||||
|
return result.snapPoint ? result.snappedTime : frameTime;
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (this.session.kind === "scrubbing") {
|
||||||
|
this.session.currentTime = time;
|
||||||
|
}
|
||||||
|
this.config.seek(time);
|
||||||
|
this.lastMouseClientX = event.clientX;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseMove(event: MouseEvent): void {
|
||||||
|
if (this.session.kind !== "scrubbing") return;
|
||||||
|
this.scrub({ event, isElementSnappingEnabled: true });
|
||||||
|
if (this.session.didStartFromRuler) {
|
||||||
|
this.session.hasMoved = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseUp(event: MouseEvent): void {
|
||||||
|
if (this.session.kind !== "scrubbing") return;
|
||||||
|
|
||||||
|
const session = this.session;
|
||||||
|
this.config.setScrubbing(false);
|
||||||
|
|
||||||
|
if (session.currentTime !== null) {
|
||||||
|
this.config.seek(session.currentTime);
|
||||||
|
this.config.setTimelineViewState({
|
||||||
|
zoomLevel: this.config.zoomLevel,
|
||||||
|
scrollLeft: this.config.getTracksScrollEl()?.scrollLeft ?? 0,
|
||||||
|
playheadTime: session.currentTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ruler click without drag: snap to clicked position on mouseup.
|
||||||
|
if (session.didStartFromRuler && !session.hasMoved) {
|
||||||
|
this.scrub({ event, isElementSnappingEnabled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.session = { kind: "idle" };
|
||||||
|
this.deactivate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,360 @@
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||||
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
mediaTime,
|
||||||
|
minMediaTime,
|
||||||
|
subMediaTime,
|
||||||
|
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: () => MediaTime;
|
||||||
|
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) =>
|
||||||
|
addMediaTime({ a: el.startTime, b: el.duration }) <=
|
||||||
|
element.startTime,
|
||||||
|
)
|
||||||
|
.reduce<MediaTime | null>((bound, el) => {
|
||||||
|
const elementEnd = addMediaTime({
|
||||||
|
a: el.startTime,
|
||||||
|
b: el.duration,
|
||||||
|
});
|
||||||
|
return bound === null
|
||||||
|
? elementEnd
|
||||||
|
: maxMediaTime({ a: bound, b: elementEnd });
|
||||||
|
}, null);
|
||||||
|
const rightNeighborBound = otherElements
|
||||||
|
.filter(
|
||||||
|
(el) =>
|
||||||
|
el.startTime >= addMediaTime({ a: element.startTime, b: element.duration }),
|
||||||
|
)
|
||||||
|
.reduce<MediaTime | null>(
|
||||||
|
(bound, el) =>
|
||||||
|
bound === null
|
||||||
|
? el.startTime
|
||||||
|
: minMediaTime({ a: bound, b: el.startTime }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
rawDeltaTime: MediaTime,
|
||||||
|
): MediaTime {
|
||||||
|
const { snappingEnabled, isShiftHeld, zoomLevel } = this.config;
|
||||||
|
|
||||||
|
if (!snappingEnabled || isShiftHeld()) {
|
||||||
|
this.config.onSnapPointChange?.(null);
|
||||||
|
return rawDeltaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 deltaTime = rawDeltaTime;
|
||||||
|
|
||||||
|
for (const member of session.members) {
|
||||||
|
const baseEdgeTime =
|
||||||
|
session.side === "left"
|
||||||
|
? member.startTime
|
||||||
|
: addMediaTime({ a: member.startTime, b: member.duration });
|
||||||
|
const snapResult = resolveTimelineSnap({
|
||||||
|
targetTime: addMediaTime({ a: baseEdgeTime, b: rawDeltaTime }),
|
||||||
|
snapPoints,
|
||||||
|
maxSnapDistance,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
snapResult.snapPoint &&
|
||||||
|
snapResult.snapDistance < closestSnapDistance
|
||||||
|
) {
|
||||||
|
closestSnapDistance = snapResult.snapDistance;
|
||||||
|
closestSnapPoint = snapResult.snapPoint;
|
||||||
|
deltaTime = subMediaTime({ a: snapResult.snappedTime, b: baseEdgeTime });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.onSnapPointChange?.(closestSnapPoint);
|
||||||
|
return deltaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseMove({ clientX }: MouseEvent): void {
|
||||||
|
if (this.session.kind !== "active") return;
|
||||||
|
const session = this.session;
|
||||||
|
|
||||||
|
const rawDeltaTime = mediaTime({
|
||||||
|
ticks: Math.round(
|
||||||
|
((clientX - session.startX) /
|
||||||
|
(BASE_TIMELINE_PIXELS_PER_SECOND * this.config.zoomLevel)) *
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const deltaTime = this.snappedDelta(session, rawDeltaTime);
|
||||||
|
const result = computeGroupResize({
|
||||||
|
members: session.members,
|
||||||
|
side: session.side,
|
||||||
|
deltaTime,
|
||||||
|
fps: session.fps,
|
||||||
|
});
|
||||||
|
|
||||||
|
session.result = result;
|
||||||
|
this.config.previewElements(result.updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMouseUp(): void {
|
||||||
|
if (this.session.kind !== "active") return;
|
||||||
|
const session = this.session;
|
||||||
|
|
||||||
|
this.config.discardPreview();
|
||||||
|
|
||||||
|
if (
|
||||||
|
session.result &&
|
||||||
|
hasResizeChanges({ members: session.members, result: session.result })
|
||||||
|
) {
|
||||||
|
this.config.commitElements(session.result.updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.finishSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
|
import type { FrameRate } from "opencut-wasm";
|
||||||
|
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||||
|
import { mediaTime, snapSeekMediaTime, TICKS_PER_SECOND, type MediaTime } 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: MediaTime;
|
||||||
|
isSelecting: boolean;
|
||||||
|
getPlayheadEl: () => HTMLDivElement | null;
|
||||||
|
getTrackLabelsEl: () => HTMLDivElement | null;
|
||||||
|
getRulerScrollEl: () => HTMLDivElement | null;
|
||||||
|
getTracksScrollEl: () => HTMLDivElement | null;
|
||||||
|
getActiveProjectFps: () => FrameRate | null;
|
||||||
|
clearSelectedElements: () => void;
|
||||||
|
seek: (time: MediaTime) => void;
|
||||||
|
setTimelineViewState: (viewState: {
|
||||||
|
zoomLevel: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
playheadTime: MediaTime;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeekConfigRef {
|
||||||
|
readonly current: SeekConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pixelToTime({
|
||||||
|
clientX,
|
||||||
|
scrollContainer,
|
||||||
|
zoomLevel,
|
||||||
|
duration,
|
||||||
|
}: {
|
||||||
|
clientX: number;
|
||||||
|
scrollContainer: HTMLDivElement;
|
||||||
|
zoomLevel: number;
|
||||||
|
duration: MediaTime;
|
||||||
|
}): MediaTime {
|
||||||
|
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 mediaTime({ ticks: 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
|
||||||
|
? snapSeekMediaTime({
|
||||||
|
time: rawTime,
|
||||||
|
duration: this.config.duration,
|
||||||
|
fps,
|
||||||
|
})
|
||||||
|
: rawTime;
|
||||||
|
|
||||||
|
this.config.seek(time);
|
||||||
|
this.config.setTimelineViewState({
|
||||||
|
zoomLevel: this.config.zoomLevel,
|
||||||
|
scrollLeft: scrollContainer.scrollLeft,
|
||||||
|
playheadTime: time,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
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";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
|
type ZoomUpdater = number | ((prev: number) => number);
|
||||||
|
|
||||||
|
export interface ZoomConfig {
|
||||||
|
minZoom: number;
|
||||||
|
getContainerEl: () => HTMLDivElement | null;
|
||||||
|
getTracksScrollEl: () => HTMLDivElement | null;
|
||||||
|
getRulerScrollEl: () => HTMLDivElement | null;
|
||||||
|
getCurrentPlayheadTime: () => MediaTime;
|
||||||
|
seek: (time: MediaTime) => void;
|
||||||
|
setTimelineViewState: (viewState: {
|
||||||
|
zoomLevel: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
playheadTime: MediaTime;
|
||||||
|
}) => 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?: MediaTime): void {
|
||||||
|
if (initialPlayheadTime === undefined) return;
|
||||||
|
if (this.hasRestoredPlayhead) return;
|
||||||
|
|
||||||
|
this.hasRestoredPlayhead = true;
|
||||||
|
this.config.seek(initialPlayheadTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindPreventBrowserZoom(): () => void {
|
||||||
|
const preventZoom = (event: WheelEvent) => {
|
||||||
|
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
|
||||||
|
const container = this.config.getContainerEl();
|
||||||
|
const isInContainer = container?.contains(event.target as Node) ?? false;
|
||||||
|
if (isZoomKeyPressed && isInContainer) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("wheel", preventZoom, {
|
||||||
|
passive: false,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("wheel", preventZoom, { capture: true });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(): void {
|
||||||
|
for (const fn of this.subscribers) {
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,17 @@
|
||||||
import { mediaTime, TICKS_PER_SECOND } from "@/wasm";
|
import { mediaTime, mediaTimeFromSeconds, TICKS_PER_SECOND } from "@/wasm";
|
||||||
|
|
||||||
export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({
|
export const DEFAULT_NEW_ELEMENT_DURATION = mediaTime({
|
||||||
ticks: 5 * TICKS_PER_SECOND,
|
ticks: 5 * TICKS_PER_SECOND,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function toElementDurationTicks({
|
||||||
|
seconds,
|
||||||
|
}: {
|
||||||
|
seconds: number | null | undefined;
|
||||||
|
}) {
|
||||||
|
if (seconds == null) {
|
||||||
|
return DEFAULT_NEW_ELEMENT_DURATION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaTimeFromSeconds({ seconds });
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import type { TimelineDragData } from "@/timeline/drag";
|
|
||||||
|
|
||||||
const MIME_TYPE = "application/x-timeline-drag";
|
|
||||||
let lastDragData: TimelineDragData | null = null;
|
|
||||||
|
|
||||||
export function setDragData({
|
|
||||||
dataTransfer,
|
|
||||||
dragData,
|
|
||||||
}: {
|
|
||||||
dataTransfer: DataTransfer;
|
|
||||||
dragData: TimelineDragData;
|
|
||||||
}): void {
|
|
||||||
dataTransfer.setData(MIME_TYPE, JSON.stringify(dragData));
|
|
||||||
dataTransfer.setData("text/plain", JSON.stringify(dragData));
|
|
||||||
lastDragData = dragData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDragData({
|
|
||||||
dataTransfer,
|
|
||||||
}: {
|
|
||||||
dataTransfer: DataTransfer;
|
|
||||||
}): TimelineDragData | null {
|
|
||||||
const data = dataTransfer.getData(MIME_TYPE);
|
|
||||||
if (data) return JSON.parse(data) as TimelineDragData;
|
|
||||||
|
|
||||||
const textData = dataTransfer.getData("text/plain");
|
|
||||||
if (textData) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(textData) as TimelineDragData;
|
|
||||||
} catch {
|
|
||||||
return lastDragData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lastDragData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasDragData({
|
|
||||||
dataTransfer,
|
|
||||||
}: {
|
|
||||||
dataTransfer: DataTransfer;
|
|
||||||
}): boolean {
|
|
||||||
return dataTransfer.types.includes(MIME_TYPE) || lastDragData !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearDragData(): void {
|
|
||||||
lastDragData = null;
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { TimelineDragData } from "@/timeline/drag";
|
||||||
|
|
||||||
|
const TIMELINE_DRAG_MIME = "application/x-timeline-drag";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the state of an in-progress timeline drag session.
|
||||||
|
*
|
||||||
|
* Exists because browsers restrict `DataTransfer.getData()` to the `drop`
|
||||||
|
* event for security — during `dragover`/`dragenter` only `types` is
|
||||||
|
* readable. The drop target needs the payload (element type, target
|
||||||
|
* element types, source duration) while the pointer is hovering, so we
|
||||||
|
* keep a live copy here and hand it out via {@link getActive}.
|
||||||
|
*/
|
||||||
|
export class TimelineDragSource {
|
||||||
|
private active: TimelineDragData | null = null;
|
||||||
|
|
||||||
|
begin({
|
||||||
|
dataTransfer,
|
||||||
|
dragData,
|
||||||
|
}: {
|
||||||
|
dataTransfer: DataTransfer;
|
||||||
|
dragData: TimelineDragData;
|
||||||
|
}): void {
|
||||||
|
dataTransfer.setData(TIMELINE_DRAG_MIME, JSON.stringify(dragData));
|
||||||
|
dataTransfer.effectAllowed = "copy";
|
||||||
|
this.active = dragData;
|
||||||
|
}
|
||||||
|
|
||||||
|
end(): void {
|
||||||
|
this.active = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getActive(): TimelineDragData | null {
|
||||||
|
return this.active;
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.active !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { SceneTracks } from "@/timeline";
|
import type { SceneTracks } from "@/timeline";
|
||||||
import type { SnapPoint } from "@/timeline/snapping";
|
import type { SnapPoint } from "@/timeline/snapping";
|
||||||
|
import { addMediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function getElementEdgeSnapPoints({
|
export function getElementEdgeSnapPoints({
|
||||||
tracks,
|
tracks,
|
||||||
|
|
@ -25,7 +26,7 @@ export function getElementEdgeSnapPoints({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
time: element.startTime + element.duration,
|
time: addMediaTime({ a: element.startTime, b: element.duration }),
|
||||||
type: "element-end",
|
type: "element-end",
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ export function snapGroupEdges({
|
||||||
) {
|
) {
|
||||||
closestSnapDistance = memberStartSnap.snapDistance;
|
closestSnapDistance = memberStartSnap.snapDistance;
|
||||||
snappedAnchorStartTime = subMediaTime({
|
snappedAnchorStartTime = subMediaTime({
|
||||||
a: memberStartSnap.snappedTime as MediaTime,
|
a: memberStartSnap.snappedTime,
|
||||||
b: member.timeOffset,
|
b: member.timeOffset,
|
||||||
});
|
});
|
||||||
snapPoint = memberStartSnap.snapPoint;
|
snapPoint = memberStartSnap.snapPoint;
|
||||||
|
|
@ -84,7 +84,7 @@ export function snapGroupEdges({
|
||||||
closestSnapDistance = memberEndSnap.snapDistance;
|
closestSnapDistance = memberEndSnap.snapDistance;
|
||||||
snappedAnchorStartTime = subMediaTime({
|
snappedAnchorStartTime = subMediaTime({
|
||||||
a: subMediaTime({
|
a: subMediaTime({
|
||||||
a: memberEndSnap.snappedTime as MediaTime,
|
a: memberEndSnap.snappedTime,
|
||||||
b: member.duration,
|
b: member.duration,
|
||||||
}),
|
}),
|
||||||
b: member.timeOffset,
|
b: member.timeOffset,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,20 @@
|
||||||
import { roundToFrame } from "opencut-wasm";
|
|
||||||
import {
|
import {
|
||||||
getSourceSpanAtClipTime,
|
getSourceSpanAtClipTime,
|
||||||
getTimelineDurationForSourceSpan,
|
getTimelineDurationForSourceSpan,
|
||||||
} from "@/retime";
|
} from "@/retime";
|
||||||
import { TICKS_PER_SECOND, roundMediaTime } from "@/wasm";
|
import {
|
||||||
|
addMediaTime,
|
||||||
|
clampMediaTime,
|
||||||
|
maxMediaTime,
|
||||||
|
type MediaTime,
|
||||||
|
mediaTime,
|
||||||
|
minMediaTime,
|
||||||
|
roundFrameTicks,
|
||||||
|
roundMediaTime,
|
||||||
|
subMediaTime,
|
||||||
|
TICKS_PER_SECOND,
|
||||||
|
ZERO_MEDIA_TIME,
|
||||||
|
} from "@/wasm";
|
||||||
import type {
|
import type {
|
||||||
ComputeGroupResizeArgs,
|
ComputeGroupResizeArgs,
|
||||||
GroupResizeMember,
|
GroupResizeMember,
|
||||||
|
|
@ -18,31 +29,54 @@ export function computeGroupResize({
|
||||||
deltaTime,
|
deltaTime,
|
||||||
fps,
|
fps,
|
||||||
}: ComputeGroupResizeArgs): GroupResizeResult {
|
}: ComputeGroupResizeArgs): GroupResizeResult {
|
||||||
const minDuration = Math.round(
|
if (members.length === 0) {
|
||||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
return { deltaTime: ZERO_MEDIA_TIME, updates: [] };
|
||||||
);
|
}
|
||||||
const minimumDeltaTime = Math.max(
|
|
||||||
...members.map((member) =>
|
const minDuration = mediaTime({
|
||||||
getMinimumAllowedDeltaTime({
|
ticks: Math.round((TICKS_PER_SECOND * fps.denominator) / fps.numerator),
|
||||||
|
});
|
||||||
|
let minimumDeltaTime = getMinimumAllowedDeltaTime({
|
||||||
|
member: members[0],
|
||||||
|
side,
|
||||||
|
minDuration,
|
||||||
|
});
|
||||||
|
let maximumDeltaTime = getMaximumAllowedDeltaTime({
|
||||||
|
member: members[0],
|
||||||
|
side,
|
||||||
|
minDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const member of members.slice(1)) {
|
||||||
|
minimumDeltaTime = maxMediaTime({
|
||||||
|
a: minimumDeltaTime,
|
||||||
|
b: getMinimumAllowedDeltaTime({
|
||||||
member,
|
member,
|
||||||
side,
|
side,
|
||||||
minDuration,
|
minDuration,
|
||||||
}),
|
}),
|
||||||
),
|
});
|
||||||
);
|
const memberMaximum = getMaximumAllowedDeltaTime({
|
||||||
const maximumDeltaTime = Math.min(
|
member,
|
||||||
...members.map((member) =>
|
side,
|
||||||
getMaximumAllowedDeltaTime({
|
minDuration,
|
||||||
member,
|
});
|
||||||
side,
|
if (memberMaximum !== null) {
|
||||||
minDuration,
|
maximumDeltaTime =
|
||||||
}),
|
maximumDeltaTime === null
|
||||||
),
|
? memberMaximum
|
||||||
);
|
: minMediaTime({ a: maximumDeltaTime, b: memberMaximum });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const clampedDeltaTime =
|
const clampedDeltaTime =
|
||||||
minimumDeltaTime > maximumDeltaTime
|
maximumDeltaTime === null
|
||||||
? minimumDeltaTime
|
? maxMediaTime({ a: minimumDeltaTime, b: deltaTime })
|
||||||
: Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
|
: clampMediaTime({
|
||||||
|
time: deltaTime,
|
||||||
|
min: minimumDeltaTime,
|
||||||
|
max: maximumDeltaTime,
|
||||||
|
});
|
||||||
|
|
||||||
// Snap the drag delta to a frame exactly once, then derive every patch
|
// Snap the drag delta to a frame exactly once, then derive every patch
|
||||||
// field from that single snapped value. This keeps the invariant
|
// field from that single snapped value. This keeps the invariant
|
||||||
|
|
@ -51,22 +85,24 @@ export function computeGroupResize({
|
||||||
// so the rounding cancels by construction. Per-field rounding (the old
|
// so the rounding cancels by construction. Per-field rounding (the old
|
||||||
// approach) couldn't preserve this because the individual rounds don't
|
// approach) couldn't preserve this because the individual rounds don't
|
||||||
// compose when `sourceDuration` isn't frame-aligned.
|
// compose when `sourceDuration` isn't frame-aligned.
|
||||||
const snappedDeltaTime =
|
const snappedDeltaTime = mediaTime({
|
||||||
roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime;
|
ticks: roundFrameTicks({ ticks: clampedDeltaTime, fps }),
|
||||||
|
});
|
||||||
// Re-clamp after rounding. Bounds derived from other elements are
|
// Re-clamp after rounding. Bounds derived from other elements are
|
||||||
// frame-aligned, so this is normally a no-op; at the source-extent limit
|
// 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
|
// the bound may not be frame-aligned, and honouring the bound takes
|
||||||
// precedence over frame alignment (you can't extend past real content).
|
// precedence over frame alignment (you can't extend past real content).
|
||||||
const finalDeltaTime =
|
const finalDeltaTime =
|
||||||
minimumDeltaTime > maximumDeltaTime
|
maximumDeltaTime === null
|
||||||
? minimumDeltaTime
|
? maxMediaTime({ a: minimumDeltaTime, b: snappedDeltaTime })
|
||||||
: Math.min(
|
: clampMediaTime({
|
||||||
maximumDeltaTime,
|
time: snappedDeltaTime,
|
||||||
Math.max(minimumDeltaTime, snappedDeltaTime),
|
min: minimumDeltaTime,
|
||||||
);
|
max: maximumDeltaTime,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
|
deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime,
|
||||||
updates: members.map((member) =>
|
updates: members.map((member) =>
|
||||||
buildResizeUpdate({
|
buildResizeUpdate({
|
||||||
member,
|
member,
|
||||||
|
|
@ -84,7 +120,7 @@ function buildResizeUpdate({
|
||||||
}: {
|
}: {
|
||||||
member: GroupResizeMember;
|
member: GroupResizeMember;
|
||||||
side: ResizeSide;
|
side: ResizeSide;
|
||||||
deltaTime: number;
|
deltaTime: MediaTime;
|
||||||
}): GroupResizeUpdate {
|
}): GroupResizeUpdate {
|
||||||
const sourceDelta = getSourceDeltaForClipDelta({
|
const sourceDelta = getSourceDeltaForClipDelta({
|
||||||
member,
|
member,
|
||||||
|
|
@ -96,10 +132,13 @@ function buildResizeUpdate({
|
||||||
trackId: member.trackId,
|
trackId: member.trackId,
|
||||||
elementId: member.elementId,
|
elementId: member.elementId,
|
||||||
patch: {
|
patch: {
|
||||||
trimStart: Math.max(0, member.trimStart + sourceDelta),
|
trimStart: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: addMediaTime({ a: member.trimStart, b: sourceDelta }),
|
||||||
|
}),
|
||||||
trimEnd: member.trimEnd,
|
trimEnd: member.trimEnd,
|
||||||
startTime: member.startTime + deltaTime,
|
startTime: addMediaTime({ a: member.startTime, b: deltaTime }),
|
||||||
duration: member.duration - deltaTime,
|
duration: subMediaTime({ a: member.duration, b: deltaTime }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -109,9 +148,12 @@ function buildResizeUpdate({
|
||||||
elementId: member.elementId,
|
elementId: member.elementId,
|
||||||
patch: {
|
patch: {
|
||||||
trimStart: member.trimStart,
|
trimStart: member.trimStart,
|
||||||
trimEnd: Math.max(0, member.trimEnd - sourceDelta),
|
trimEnd: maxMediaTime({
|
||||||
|
a: ZERO_MEDIA_TIME,
|
||||||
|
b: subMediaTime({ a: member.trimEnd, b: sourceDelta }),
|
||||||
|
}),
|
||||||
startTime: member.startTime,
|
startTime: member.startTime,
|
||||||
duration: member.duration + deltaTime,
|
duration: addMediaTime({ a: member.duration, b: deltaTime }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -123,29 +165,37 @@ function getMinimumAllowedDeltaTime({
|
||||||
}: {
|
}: {
|
||||||
member: GroupResizeMember;
|
member: GroupResizeMember;
|
||||||
side: ResizeSide;
|
side: ResizeSide;
|
||||||
minDuration: number;
|
minDuration: MediaTime;
|
||||||
}): number {
|
}): MediaTime {
|
||||||
if (side === "right") {
|
if (side === "right") {
|
||||||
return minDuration - member.duration;
|
return subMediaTime({ a: minDuration, b: member.duration });
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
|
const leftNeighborFloor =
|
||||||
? member.leftNeighborBound - member.startTime
|
member.leftNeighborBound !== null
|
||||||
: -member.startTime;
|
? subMediaTime({ a: member.leftNeighborBound, b: member.startTime })
|
||||||
|
: subMediaTime({ a: ZERO_MEDIA_TIME, b: member.startTime });
|
||||||
if (member.sourceDuration == null) {
|
if (member.sourceDuration == null) {
|
||||||
return leftNeighborFloor;
|
return leftNeighborFloor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maximumSourceExtension =
|
const maximumSourceExtension = subMediaTime({
|
||||||
getDurationForVisibleSourceSpan({
|
a: getDurationForVisibleSourceSpan({
|
||||||
member,
|
member,
|
||||||
sourceSpan:
|
sourceSpan: addMediaTime({
|
||||||
getVisibleSourceSpanForDuration({
|
a: getVisibleSourceSpanForDuration({
|
||||||
member,
|
member,
|
||||||
duration: member.duration,
|
duration: member.duration,
|
||||||
}) + member.trimStart,
|
}),
|
||||||
}) - member.duration;
|
b: member.trimStart,
|
||||||
return Math.max(leftNeighborFloor, -maximumSourceExtension);
|
}),
|
||||||
|
}),
|
||||||
|
b: member.duration,
|
||||||
|
});
|
||||||
|
return maxMediaTime({
|
||||||
|
a: leftNeighborFloor,
|
||||||
|
b: subMediaTime({ a: ZERO_MEDIA_TIME, b: maximumSourceExtension }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMaximumAllowedDeltaTime({
|
function getMaximumAllowedDeltaTime({
|
||||||
|
|
@ -155,26 +205,38 @@ function getMaximumAllowedDeltaTime({
|
||||||
}: {
|
}: {
|
||||||
member: GroupResizeMember;
|
member: GroupResizeMember;
|
||||||
side: ResizeSide;
|
side: ResizeSide;
|
||||||
minDuration: number;
|
minDuration: MediaTime;
|
||||||
}): number {
|
}): MediaTime | null {
|
||||||
if (side === "left") {
|
if (side === "left") {
|
||||||
return member.duration - minDuration;
|
return subMediaTime({ a: member.duration, b: minDuration });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
|
const rightNeighborCeiling =
|
||||||
? member.rightNeighborBound - (member.startTime + member.duration)
|
member.rightNeighborBound === null
|
||||||
: Infinity;
|
? null
|
||||||
|
: subMediaTime({
|
||||||
|
a: member.rightNeighborBound,
|
||||||
|
b: addMediaTime({ a: member.startTime, b: member.duration }),
|
||||||
|
});
|
||||||
if (member.sourceDuration == null) {
|
if (member.sourceDuration == null) {
|
||||||
return rightNeighborCeiling;
|
return rightNeighborCeiling;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maximumVisibleSourceSpan =
|
const maximumVisibleSourceSpan = subMediaTime({
|
||||||
getSourceDuration({ member }) - member.trimStart;
|
a: getSourceDuration({ member }),
|
||||||
|
b: member.trimStart,
|
||||||
|
});
|
||||||
const maximumDuration = getDurationForVisibleSourceSpan({
|
const maximumDuration = getDurationForVisibleSourceSpan({
|
||||||
member,
|
member,
|
||||||
sourceSpan: maximumVisibleSourceSpan,
|
sourceSpan: maximumVisibleSourceSpan,
|
||||||
});
|
});
|
||||||
return Math.min(rightNeighborCeiling, maximumDuration - member.duration);
|
const sourceDurationCeiling = subMediaTime({
|
||||||
|
a: maximumDuration,
|
||||||
|
b: member.duration,
|
||||||
|
});
|
||||||
|
return rightNeighborCeiling === null
|
||||||
|
? sourceDurationCeiling
|
||||||
|
: minMediaTime({ a: rightNeighborCeiling, b: sourceDurationCeiling });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourceDeltaForClipDelta({
|
function getSourceDeltaForClipDelta({
|
||||||
|
|
@ -182,8 +244,8 @@ function getSourceDeltaForClipDelta({
|
||||||
clipDelta,
|
clipDelta,
|
||||||
}: {
|
}: {
|
||||||
member: GroupResizeMember;
|
member: GroupResizeMember;
|
||||||
clipDelta: number;
|
clipDelta: MediaTime;
|
||||||
}): number {
|
}): MediaTime {
|
||||||
if (!member.retime) {
|
if (!member.retime) {
|
||||||
return clipDelta;
|
return clipDelta;
|
||||||
}
|
}
|
||||||
|
|
@ -206,15 +268,17 @@ function getVisibleSourceSpanForDuration({
|
||||||
duration,
|
duration,
|
||||||
}: {
|
}: {
|
||||||
member: GroupResizeMember;
|
member: GroupResizeMember;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
}): number {
|
}): MediaTime {
|
||||||
if (!member.retime) {
|
if (!member.retime) {
|
||||||
return duration;
|
return duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getSourceSpanAtClipTime({
|
return roundMediaTime({
|
||||||
clipTime: duration,
|
time: getSourceSpanAtClipTime({
|
||||||
retime: member.retime,
|
clipTime: duration,
|
||||||
|
retime: member.retime,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -223,8 +287,8 @@ function getDurationForVisibleSourceSpan({
|
||||||
sourceSpan,
|
sourceSpan,
|
||||||
}: {
|
}: {
|
||||||
member: GroupResizeMember;
|
member: GroupResizeMember;
|
||||||
sourceSpan: number;
|
sourceSpan: MediaTime;
|
||||||
}): number {
|
}): MediaTime {
|
||||||
if (!member.retime) {
|
if (!member.retime) {
|
||||||
return sourceSpan;
|
return sourceSpan;
|
||||||
}
|
}
|
||||||
|
|
@ -237,17 +301,19 @@ function getDurationForVisibleSourceSpan({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSourceDuration({ member }: { member: GroupResizeMember }): number {
|
function getSourceDuration({ member }: { member: GroupResizeMember }): MediaTime {
|
||||||
if (typeof member.sourceDuration === "number") {
|
if (member.sourceDuration != null) {
|
||||||
return member.sourceDuration;
|
return member.sourceDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return addMediaTime({
|
||||||
member.trimStart +
|
a: addMediaTime({
|
||||||
getVisibleSourceSpanForDuration({
|
a: member.trimStart,
|
||||||
|
b: getVisibleSourceSpanForDuration({
|
||||||
member,
|
member,
|
||||||
duration: member.duration,
|
duration: member.duration,
|
||||||
}) +
|
}),
|
||||||
member.trimEnd
|
}),
|
||||||
);
|
b: member.trimEnd,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,37 @@
|
||||||
import type { FrameRate } from "opencut-wasm";
|
import type { FrameRate } from "opencut-wasm";
|
||||||
import type { ElementRef, RetimeConfig } from "@/timeline/types";
|
import type { ElementRef, RetimeConfig } from "@/timeline/types";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export type ResizeSide = "left" | "right";
|
export type ResizeSide = "left" | "right";
|
||||||
|
|
||||||
export interface GroupResizeMember extends ElementRef {
|
export interface GroupResizeMember extends ElementRef {
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
trimStart: number;
|
trimStart: MediaTime;
|
||||||
trimEnd: number;
|
trimEnd: MediaTime;
|
||||||
sourceDuration?: number;
|
sourceDuration?: MediaTime;
|
||||||
retime?: RetimeConfig;
|
retime?: RetimeConfig;
|
||||||
leftNeighborBound: number;
|
leftNeighborBound: MediaTime | null;
|
||||||
rightNeighborBound: number;
|
rightNeighborBound: MediaTime | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupResizeUpdate extends ElementRef {
|
export interface GroupResizeUpdate extends ElementRef {
|
||||||
patch: {
|
patch: {
|
||||||
trimStart: number;
|
trimStart: MediaTime;
|
||||||
trimEnd: number;
|
trimEnd: MediaTime;
|
||||||
startTime: number;
|
startTime: MediaTime;
|
||||||
duration: number;
|
duration: MediaTime;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupResizeResult {
|
export interface GroupResizeResult {
|
||||||
deltaTime: number;
|
deltaTime: MediaTime;
|
||||||
updates: GroupResizeUpdate[];
|
updates: GroupResizeUpdate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComputeGroupResizeArgs {
|
export interface ComputeGroupResizeArgs {
|
||||||
members: GroupResizeMember[];
|
members: GroupResizeMember[];
|
||||||
side: ResizeSide;
|
side: ResizeSide;
|
||||||
deltaTime: number;
|
deltaTime: MediaTime;
|
||||||
fps: FrameRate;
|
fps: FrameRate;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,17 @@
|
||||||
import {
|
import { useEffect, useReducer, useRef, type RefObject } from "react";
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
type MouseEvent as ReactMouseEvent,
|
|
||||||
type RefObject,
|
|
||||||
} from "react";
|
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||||
import {
|
|
||||||
buildMoveGroup,
|
|
||||||
resolveGroupMove,
|
|
||||||
snapGroupEdges,
|
|
||||||
type GroupMoveResult,
|
|
||||||
type MoveGroup,
|
|
||||||
} from "@/timeline/group-move";
|
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
|
||||||
import {
|
|
||||||
addMediaTime,
|
|
||||||
type MediaTime,
|
|
||||||
mediaTime,
|
|
||||||
subMediaTime,
|
|
||||||
TICKS_PER_SECOND,
|
|
||||||
ZERO_MEDIA_TIME,
|
|
||||||
} from "@/wasm";
|
|
||||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
|
||||||
import { roundToFrame } from "opencut-wasm";
|
|
||||||
import { computeDropTarget } from "@/timeline/components/drop-target";
|
|
||||||
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
|
|
||||||
import { generateUUID } from "@/utils/id";
|
|
||||||
import type { SnapPoint } from "@/timeline/snapping";
|
|
||||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||||
import type {
|
import {
|
||||||
DropTarget,
|
ElementInteractionController,
|
||||||
ElementRef,
|
type ElementInteractionDeps,
|
||||||
ElementDragState,
|
type ElementInteractionDepsRef,
|
||||||
SceneTracks,
|
} from "@/timeline/controllers/element-interaction-controller";
|
||||||
TimelineElement,
|
import type { SnapPoint } from "@/timeline/snapping";
|
||||||
TimelineTrack,
|
|
||||||
} from "@/timeline";
|
|
||||||
|
|
||||||
interface UseElementInteractionProps {
|
interface UseElementInteractionProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
timelineRef: RefObject<HTMLDivElement | null>;
|
|
||||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||||
headerRef?: RefObject<HTMLElement | null>;
|
headerRef?: RefObject<HTMLElement | null>;
|
||||||
|
|
@ -51,133 +19,8 @@ interface UseElementInteractionProps {
|
||||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
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: ZERO_MEDIA_TIME,
|
|
||||||
clickOffsetTime: ZERO_MEDIA_TIME,
|
|
||||||
currentTime: ZERO_MEDIA_TIME,
|
|
||||||
currentMouseY: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PendingDragState {
|
|
||||||
elementId: string;
|
|
||||||
trackId: string;
|
|
||||||
selectedElements: ElementRef[];
|
|
||||||
startMouseX: number;
|
|
||||||
startMouseY: number;
|
|
||||||
startElementTime: MediaTime;
|
|
||||||
clickOffsetTime: MediaTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClickOffsetTime({
|
|
||||||
clientX,
|
|
||||||
elementRect,
|
|
||||||
zoomLevel,
|
|
||||||
}: {
|
|
||||||
clientX: number;
|
|
||||||
elementRect: DOMRect;
|
|
||||||
zoomLevel: number;
|
|
||||||
}): MediaTime {
|
|
||||||
const clickOffsetX = clientX - elementRect.left;
|
|
||||||
const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel);
|
|
||||||
return mediaTime({
|
|
||||||
ticks: 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: MediaTime;
|
|
||||||
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: MediaTime;
|
|
||||||
initialCurrentMouseY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useElementInteraction({
|
export function useElementInteraction({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
timelineRef,
|
|
||||||
tracksContainerRef,
|
tracksContainerRef,
|
||||||
tracksScrollRef,
|
tracksScrollRef,
|
||||||
headerRef,
|
headerRef,
|
||||||
|
|
@ -186,597 +29,65 @@ export function useElementInteraction({
|
||||||
}: UseElementInteractionProps) {
|
}: UseElementInteractionProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
const selection = useElementSelection();
|
||||||
const {
|
|
||||||
selectedElements,
|
|
||||||
isElementSelected,
|
|
||||||
selectElement,
|
|
||||||
handleElementClick: handleSelectionClick,
|
|
||||||
} = useElementSelection();
|
|
||||||
|
|
||||||
const [dragState, setDragState] =
|
const deps: ElementInteractionDeps = {
|
||||||
useState<ElementDragState>(initialDragState);
|
viewport: {
|
||||||
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
|
getZoomLevel: () => zoomLevel,
|
||||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
getTracksScrollEl: () => tracksScrollRef.current,
|
||||||
const pendingDragRef = useRef<PendingDragState | null>(null);
|
getTracksContainerEl: () => tracksContainerRef.current,
|
||||||
const moveGroupRef = useRef<MoveGroup | null>(null);
|
getHeaderEl: () => headerRef?.current ?? 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,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[],
|
input: {
|
||||||
);
|
isShiftHeld: () => isShiftHeldRef.current,
|
||||||
|
|
||||||
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: MediaTime;
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[sceneTracks],
|
scene: {
|
||||||
);
|
getTracks: () => editor.scenes.getActiveScene().tracks,
|
||||||
|
getActiveFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
getSelected: () => selection.selectedElements,
|
||||||
|
isSelected: selection.isElementSelected,
|
||||||
|
select: selection.selectElement,
|
||||||
|
handleClick: selection.handleElementClick,
|
||||||
|
clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(),
|
||||||
|
},
|
||||||
|
playback: {
|
||||||
|
getCurrentTime: () => editor.playback.getCurrentTime(),
|
||||||
|
},
|
||||||
|
timeline: {
|
||||||
|
moveElements: (args) => editor.timeline.moveElements(args),
|
||||||
|
},
|
||||||
|
snap: {
|
||||||
|
isEnabled: () => snappingEnabled,
|
||||||
|
onChange: onSnapPointChange,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const depsRef = useRef<ElementInteractionDeps>(deps);
|
||||||
|
depsRef.current = deps;
|
||||||
|
|
||||||
|
const controllerRef = useRef<ElementInteractionController | null>(null);
|
||||||
|
if (!controllerRef.current) {
|
||||||
|
controllerRef.current = new ElementInteractionController({
|
||||||
|
depsRef: depsRef as ElementInteractionDepsRef,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const controller = controllerRef.current;
|
||||||
|
|
||||||
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||||
|
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dragState.isDragging && !isPendingDrag) return;
|
if (!controller.isActive) return;
|
||||||
|
return registerCanceller({ fn: () => controller.cancel() });
|
||||||
|
}, [controller.isActive, controller]);
|
||||||
|
|
||||||
return registerCanceller({ fn: cancelCurrentDrag });
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
}, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
|
|
||||||
|
|
||||||
const getDragSnapResult = useCallback(
|
|
||||||
({
|
|
||||||
frameSnappedTime,
|
|
||||||
group,
|
|
||||||
}: {
|
|
||||||
frameSnappedTime: MediaTime;
|
|
||||||
group: MoveGroup | null;
|
|
||||||
}) => {
|
|
||||||
if (!group || !snappingEnabled || isShiftHeldRef.current) {
|
|
||||||
return { snappedTime: frameSnappedTime, snapPoint: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupSnap = snapGroupEdges({
|
|
||||||
group,
|
|
||||||
anchorStartTime: frameSnappedTime,
|
|
||||||
tracks: sceneTracks,
|
|
||||||
playheadTime: editor.playback.getCurrentTime(),
|
|
||||||
zoomLevel,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
snappedTime: groupSnap.snappedAnchorStartTime,
|
|
||||||
snapPoint: groupSnap.snapPoint,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[snappingEnabled, editor.playback, sceneTracks, zoomLevel, isShiftHeldRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!dragState.isDragging && !isPendingDrag) return;
|
|
||||||
|
|
||||||
const handleMouseMove = ({ clientX, clientY }: MouseEvent) => {
|
|
||||||
let startedDragThisEvent = false;
|
|
||||||
const timeline = timelineRef.current;
|
|
||||||
const scrollContainer = tracksScrollRef.current;
|
|
||||||
if (!timeline || !scrollContainer) return;
|
|
||||||
lastMouseXRef.current = clientX;
|
|
||||||
|
|
||||||
if (isPendingDrag && pendingDragRef.current) {
|
|
||||||
const deltaX = Math.abs(clientX - pendingDragRef.current.startMouseX);
|
|
||||||
const deltaY = Math.abs(clientY - pendingDragRef.current.startMouseY);
|
|
||||||
if (
|
|
||||||
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
|
||||||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
|
||||||
) {
|
|
||||||
const activeProject = editor.project.getActive();
|
|
||||||
if (!activeProject) return;
|
|
||||||
const scrollLeft = scrollContainer.scrollLeft;
|
|
||||||
const mouseTime = getMouseTimeFromClientX({
|
|
||||||
clientX,
|
|
||||||
containerRect: scrollContainer.getBoundingClientRect(),
|
|
||||||
zoomLevel,
|
|
||||||
scrollLeft,
|
|
||||||
});
|
|
||||||
const adjustedTime =
|
|
||||||
mouseTime > pendingDragRef.current.clickOffsetTime
|
|
||||||
? subMediaTime({
|
|
||||||
a: mouseTime,
|
|
||||||
b: pendingDragRef.current.clickOffsetTime,
|
|
||||||
})
|
|
||||||
: ZERO_MEDIA_TIME;
|
|
||||||
const snappedTime = (
|
|
||||||
roundToFrame({
|
|
||||||
time: adjustedTime,
|
|
||||||
rate: activeProject.settings.fps,
|
|
||||||
}) ?? adjustedTime
|
|
||||||
) as MediaTime;
|
|
||||||
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, MediaTime> = {};
|
|
||||||
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 =
|
|
||||||
mouseTime > dragState.clickOffsetTime
|
|
||||||
? subMediaTime({
|
|
||||||
a: mouseTime,
|
|
||||||
b: dragState.clickOffsetTime,
|
|
||||||
})
|
|
||||||
: ZERO_MEDIA_TIME;
|
|
||||||
const fps = activeProject.settings.fps;
|
|
||||||
const frameSnappedTime = (
|
|
||||||
roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime
|
|
||||||
) as MediaTime;
|
|
||||||
|
|
||||||
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 = addMediaTime({
|
|
||||||
a: dragState.startElementTime,
|
|
||||||
b: currentMember?.timeOffset ?? ZERO_MEDIA_TIME,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
currentMember?.trackId !== move.targetTrackId ||
|
|
||||||
originalStartTime !== move.newStartTime
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (!didMove && groupMoveResult.createTracks.length === 0) {
|
|
||||||
endDrag();
|
|
||||||
onSnapPointChange?.(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.timeline.moveElements({
|
|
||||||
moves: groupMoveResult.moves,
|
|
||||||
createTracks: groupMoveResult.createTracks,
|
|
||||||
});
|
|
||||||
endDrag();
|
|
||||||
onSnapPointChange?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
}, [
|
|
||||||
dragState.isDragging,
|
|
||||||
dragState.elementId,
|
|
||||||
dragState.startElementTime,
|
|
||||||
dragState.trackId,
|
|
||||||
endDrag,
|
|
||||||
onSnapPointChange,
|
|
||||||
editor.timeline,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPendingDrag) return;
|
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
|
||||||
pendingDragRef.current = null;
|
|
||||||
setIsPendingDrag(false);
|
|
||||||
onSnapPointChange?.(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
|
||||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
|
||||||
}, [isPendingDrag, onSnapPointChange]);
|
|
||||||
|
|
||||||
const handleElementMouseDown = useCallback(
|
|
||||||
({
|
|
||||||
event,
|
|
||||||
element,
|
|
||||||
track,
|
|
||||||
}: {
|
|
||||||
event: ReactMouseEvent;
|
|
||||||
element: TimelineElement;
|
|
||||||
track: TimelineTrack;
|
|
||||||
}) => {
|
|
||||||
const isRightClick = event.button === MOUSE_BUTTON_RIGHT;
|
|
||||||
|
|
||||||
// right-click: don't stop propagation so ContextMenu can open
|
|
||||||
if (isRightClick) {
|
|
||||||
const alreadySelected = isElementSelected({
|
|
||||||
trackId: track.id,
|
|
||||||
elementId: element.id,
|
|
||||||
});
|
|
||||||
if (!alreadySelected) {
|
|
||||||
handleSelectionClick({
|
|
||||||
trackId: track.id,
|
|
||||||
elementId: element.id,
|
|
||||||
isMultiKey: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.stopPropagation();
|
|
||||||
mouseDownLocationRef.current = { x: event.clientX, y: event.clientY };
|
|
||||||
|
|
||||||
const isMultiSelect = event.metaKey || event.ctrlKey || event.shiftKey;
|
|
||||||
|
|
||||||
if (isMultiSelect) {
|
|
||||||
handleSelectionClick({
|
|
||||||
trackId: track.id,
|
|
||||||
elementId: element.id,
|
|
||||||
isMultiKey: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const clickOffsetTime = getClickOffsetTime({
|
|
||||||
clientX: event.clientX,
|
|
||||||
elementRect: event.currentTarget.getBoundingClientRect(),
|
|
||||||
zoomLevel,
|
|
||||||
});
|
|
||||||
const elementRef = {
|
|
||||||
trackId: track.id,
|
|
||||||
elementId: element.id,
|
|
||||||
};
|
|
||||||
const pendingSelectedElements = isElementSelected(elementRef)
|
|
||||||
? selectedElements
|
|
||||||
: [elementRef];
|
|
||||||
pendingDragRef.current = {
|
|
||||||
elementId: element.id,
|
|
||||||
trackId: track.id,
|
|
||||||
selectedElements: pendingSelectedElements,
|
|
||||||
startMouseX: event.clientX,
|
|
||||||
startMouseY: event.clientY,
|
|
||||||
startElementTime: element.startTime,
|
|
||||||
clickOffsetTime,
|
|
||||||
};
|
|
||||||
setIsPendingDrag(true);
|
|
||||||
},
|
|
||||||
[zoomLevel, isElementSelected, handleSelectionClick, selectedElements],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleElementClick = useCallback(
|
|
||||||
({
|
|
||||||
event,
|
|
||||||
element,
|
|
||||||
track,
|
|
||||||
}: {
|
|
||||||
event: ReactMouseEvent;
|
|
||||||
element: TimelineElement;
|
|
||||||
track: TimelineTrack;
|
|
||||||
}) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
if (mouseDownLocationRef.current) {
|
|
||||||
const deltaX = Math.abs(event.clientX - mouseDownLocationRef.current.x);
|
|
||||||
const deltaY = Math.abs(event.clientY - mouseDownLocationRef.current.y);
|
|
||||||
if (
|
|
||||||
deltaX > TIMELINE_DRAG_THRESHOLD_PX ||
|
|
||||||
deltaY > TIMELINE_DRAG_THRESHOLD_PX
|
|
||||||
) {
|
|
||||||
mouseDownLocationRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// modifier keys already handled in mousedown
|
|
||||||
if (event.metaKey || event.ctrlKey || event.shiftKey) return;
|
|
||||||
|
|
||||||
const alreadySelected = isElementSelected({
|
|
||||||
trackId: track.id,
|
|
||||||
elementId: element.id,
|
|
||||||
});
|
|
||||||
if (!alreadySelected || selectedElements.length > 1) {
|
|
||||||
selectElement({ trackId: track.id, elementId: element.id });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.selection.clearKeyframeSelection();
|
|
||||||
},
|
|
||||||
[editor.selection, isElementSelected, selectElement, selectedElements],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dragState,
|
dragView: controller.view,
|
||||||
dragDropTarget,
|
handleElementMouseDown: controller.onElementMouseDown,
|
||||||
handleElementMouseDown,
|
handleElementClick: controller.onElementClick,
|
||||||
handleElementClick,
|
|
||||||
lastMouseXRef,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,16 @@
|
||||||
import {
|
import { useEffect, useReducer, useRef } from "react";
|
||||||
useState,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
type MouseEvent as ReactMouseEvent,
|
|
||||||
} from "react";
|
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { getKeyframeById } from "@/animation";
|
|
||||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
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 {
|
|
||||||
addMediaTime,
|
|
||||||
type MediaTime,
|
|
||||||
maxMediaTime,
|
|
||||||
mediaTime,
|
|
||||||
minMediaTime,
|
|
||||||
TICKS_PER_SECOND,
|
|
||||||
ZERO_MEDIA_TIME,
|
|
||||||
} 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";
|
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||||
export interface KeyframeDragState {
|
import {
|
||||||
isDragging: boolean;
|
KeyframeDragController,
|
||||||
draggingKeyframeIds: Set<string>;
|
type KeyframeDragConfig,
|
||||||
deltaTime: MediaTime;
|
type KeyframeDragState,
|
||||||
}
|
} from "@/timeline/controllers/keyframe-drag-controller";
|
||||||
|
import type { TimelineElement } from "@/timeline";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
const initialDragState: KeyframeDragState = {
|
export type { KeyframeDragState };
|
||||||
isDragging: false,
|
|
||||||
draggingKeyframeIds: new Set(),
|
|
||||||
deltaTime: ZERO_MEDIA_TIME,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PendingKeyframeDrag {
|
|
||||||
keyframeRefs: SelectedKeyframeRef[];
|
|
||||||
startMouseX: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useKeyframeDrag({
|
export function useKeyframeDrag({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
|
|
@ -62,286 +30,44 @@ export function useKeyframeDrag({
|
||||||
selectKeyframeRange,
|
selectKeyframeRange,
|
||||||
} = useKeyframeSelection();
|
} = useKeyframeSelection();
|
||||||
|
|
||||||
const [dragState, setDragState] =
|
const config: KeyframeDragConfig = {
|
||||||
useState<KeyframeDragState>(initialDragState);
|
zoomLevel,
|
||||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
element,
|
||||||
|
displayedStartTime,
|
||||||
const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
|
getFps: () => editor.project.getActive()?.settings.fps ?? 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: MediaTime;
|
|
||||||
}) => {
|
|
||||||
const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => {
|
|
||||||
const keyframe = getKeyframeById({
|
|
||||||
animations: element.animations,
|
|
||||||
propertyPath: keyframeRef.propertyPath,
|
|
||||||
keyframeId: keyframeRef.keyframeId,
|
|
||||||
});
|
|
||||||
if (!keyframe) return [];
|
|
||||||
const nextTime = maxMediaTime({
|
|
||||||
a: ZERO_MEDIA_TIME,
|
|
||||||
b: minMediaTime({
|
|
||||||
a: element.duration,
|
|
||||||
b: addMediaTime({ a: keyframe.time, b: 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: ZERO_MEDIA_TIME,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dragState.isDragging) return;
|
|
||||||
|
|
||||||
const startX = mouseDownXRef.current ?? clientX;
|
|
||||||
const rawDelta = mediaTime({
|
|
||||||
ticks: Math.round(
|
|
||||||
((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const snappedDelta = (
|
|
||||||
roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta
|
|
||||||
) as MediaTime;
|
|
||||||
|
|
||||||
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,
|
|
||||||
selectedKeyframes,
|
selectedKeyframes,
|
||||||
element.id,
|
isKeyframeSelected,
|
||||||
commitDrag,
|
setKeyframeSelection,
|
||||||
endDrag,
|
toggleKeyframeSelection,
|
||||||
]);
|
selectKeyframeRange,
|
||||||
|
executeCommand: (command) => editor.command.execute({ command }),
|
||||||
|
seek: ({ time }) => editor.playback.seek({ time }),
|
||||||
|
getTotalDuration: () => editor.timeline.getTotalDuration(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const configRef = useRef<KeyframeDragConfig>(config);
|
||||||
|
configRef.current = config;
|
||||||
|
|
||||||
|
const controllerRef = useRef<KeyframeDragController | null>(null);
|
||||||
|
if (!controllerRef.current) {
|
||||||
|
controllerRef.current = new KeyframeDragController({ configRef });
|
||||||
|
}
|
||||||
|
const controller = controllerRef.current;
|
||||||
|
|
||||||
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||||
|
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPendingDrag) return;
|
if (!controller.isActive) return;
|
||||||
|
return registerCanceller({ fn: () => controller.cancel() });
|
||||||
|
}, [controller.isActive, controller]);
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
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: MediaTime;
|
|
||||||
}) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const wasDrag =
|
|
||||||
mouseDownXRef.current !== null &&
|
|
||||||
Math.abs(event.clientX - mouseDownXRef.current) >
|
|
||||||
TIMELINE_DRAG_THRESHOLD_PX;
|
|
||||||
mouseDownXRef.current = null;
|
|
||||||
|
|
||||||
if (wasDrag) return;
|
|
||||||
|
|
||||||
const duration = editor.timeline.getTotalDuration();
|
|
||||||
const absoluteIndicatorTime = addMediaTime({
|
|
||||||
a: displayedStartTime,
|
|
||||||
b: indicatorTime,
|
|
||||||
});
|
|
||||||
const seekTime = (
|
|
||||||
snappedSeekTime({
|
|
||||||
time: absoluteIndicatorTime,
|
|
||||||
duration,
|
|
||||||
rate: fps,
|
|
||||||
}) ?? absoluteIndicatorTime
|
|
||||||
) as MediaTime;
|
|
||||||
editor.playback.seek({ time: seekTime });
|
|
||||||
|
|
||||||
if (event.shiftKey) {
|
|
||||||
selectKeyframeRange({
|
|
||||||
orderedKeyframes,
|
|
||||||
targetKeyframes: keyframes,
|
|
||||||
isAdditive: event.metaKey || event.ctrlKey,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleKeyframeSelection({
|
|
||||||
keyframes,
|
|
||||||
isMultiKey: event.metaKey || event.ctrlKey,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
toggleKeyframeSelection,
|
|
||||||
selectKeyframeRange,
|
|
||||||
editor,
|
|
||||||
displayedStartTime,
|
|
||||||
fps,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getVisualOffsetPx = useCallback(
|
|
||||||
({
|
|
||||||
indicatorTime,
|
|
||||||
indicatorOffsetPx,
|
|
||||||
isBeingDragged,
|
|
||||||
displayedStartTime,
|
|
||||||
elementLeft,
|
|
||||||
}: {
|
|
||||||
indicatorTime: number;
|
|
||||||
indicatorOffsetPx: number;
|
|
||||||
isBeingDragged: boolean;
|
|
||||||
displayedStartTime: number;
|
|
||||||
elementLeft: number;
|
|
||||||
}): number => {
|
|
||||||
if (!isBeingDragged) return indicatorOffsetPx;
|
|
||||||
const clampedTime = Math.max(
|
|
||||||
0,
|
|
||||||
Math.min(element.duration, indicatorTime + dragState.deltaTime),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
timelineTimeToSnappedPixels({
|
|
||||||
time: displayedStartTime + clampedTime,
|
|
||||||
zoomLevel,
|
|
||||||
}) - elementLeft
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[dragState.deltaTime, element.duration, zoomLevel],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
keyframeDragState: dragState,
|
keyframeDragState: controller.keyframeDragState,
|
||||||
handleKeyframeMouseDown,
|
handleKeyframeMouseDown: controller.onKeyframeMouseDown,
|
||||||
handleKeyframeClick,
|
handleKeyframeClick: controller.onKeyframeClick,
|
||||||
getVisualOffsetPx,
|
getVisualOffsetPx: controller.getVisualOffsetPx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,9 @@
|
||||||
import { useState, useCallback, type RefObject } from "react";
|
import { useEffect, useReducer, useRef, type RefObject } from "react";
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { 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 { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
|
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
|
||||||
import { roundToFrame } from "opencut-wasm";
|
|
||||||
import {
|
import {
|
||||||
buildTextElement,
|
DragDropController,
|
||||||
buildGraphicElement,
|
type DragDropConfig,
|
||||||
buildStickerElement,
|
} from "@/timeline/controllers/drag-drop-controller";
|
||||||
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";
|
|
||||||
|
|
||||||
interface UseTimelineDragDropProps {
|
interface UseTimelineDragDropProps {
|
||||||
containerRef: RefObject<HTMLDivElement | null>;
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
|
@ -40,609 +19,47 @@ export function useTimelineDragDrop({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
}: UseTimelineDragDropProps) {
|
}: UseTimelineDragDropProps) {
|
||||||
const editor = useEditor();
|
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(
|
const config: DragDropConfig = {
|
||||||
({ time }: { time: MediaTime }) => {
|
zoomLevel,
|
||||||
const projectFps = editor.project.getActive().settings.fps;
|
getContainerEl: () => containerRef.current,
|
||||||
return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime;
|
getHeaderEl: () => headerRef?.current ?? null,
|
||||||
},
|
getTracksScrollEl: () => tracksScrollRef?.current ?? null,
|
||||||
[editor],
|
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||||
);
|
getActiveProjectId: () =>
|
||||||
|
editor.project.getActiveOrNull()?.metadata.id ?? null,
|
||||||
|
getSceneTracks: () => editor.scenes.getActiveScene().tracks,
|
||||||
|
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
|
||||||
|
getMediaAssets: () => editor.media.getAssets(),
|
||||||
|
dragSource: editor.timeline.dragSource,
|
||||||
|
addMediaAsset: (args) => editor.media.addMediaAsset(args),
|
||||||
|
executeCommand: (command) => editor.command.execute({ command }),
|
||||||
|
insertElement: (args) => editor.timeline.insertElement(args),
|
||||||
|
addClipEffect: (args) => editor.timeline.addClipEffect(args),
|
||||||
|
};
|
||||||
|
|
||||||
const getElementType = useCallback(
|
const configRef = useRef<DragDropConfig>(config);
|
||||||
({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
|
configRef.current = config;
|
||||||
const dragData = getDragData({ dataTransfer });
|
|
||||||
if (!dragData) return null;
|
|
||||||
|
|
||||||
if (dragData.type === "text") return "text";
|
const controllerRef = useRef<DragDropController | null>(null);
|
||||||
if (dragData.type === "graphic") return "graphic";
|
if (!controllerRef.current) {
|
||||||
if (dragData.type === "sticker") return "sticker";
|
controllerRef.current = new DragDropController({ configRef });
|
||||||
if (dragData.type === "effect") return "effect";
|
}
|
||||||
if (dragData.type === "media") {
|
const controller = controllerRef.current;
|
||||||
return dragData.mediaType;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const getElementDuration = useCallback(
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||||
({
|
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||||
elementType,
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
mediaId,
|
|
||||||
}: {
|
|
||||||
elementType: ElementType;
|
|
||||||
mediaId?: string;
|
|
||||||
}): MediaTime => {
|
|
||||||
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
|
|
||||||
? mediaTimeFromSeconds({ seconds: media.duration })
|
|
||||||
: 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
|
|
||||||
? mediaTimeFromSeconds({ seconds: mediaAsset.duration })
|
|
||||||
: 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
|
|
||||||
? mediaTimeFromSeconds({ seconds: createdAsset.duration })
|
|
||||||
: DEFAULT_NEW_ELEMENT_DURATION;
|
|
||||||
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
|
||||||
const currentTime = editor.playback.getCurrentTime();
|
|
||||||
const reuseMainTrackId =
|
|
||||||
createdAsset.type !== "audio" &&
|
|
||||||
sceneTracks.overlay.length === 0 &&
|
|
||||||
sceneTracks.audio.length === 0 &&
|
|
||||||
sceneTracks.main.elements.length === 0
|
|
||||||
? sceneTracks.main.id
|
|
||||||
: null;
|
|
||||||
const dropTarget = reuseMainTrackId
|
|
||||||
? null
|
|
||||||
: computeDropTarget({
|
|
||||||
elementType: createdAsset.type,
|
|
||||||
mouseX,
|
|
||||||
mouseY,
|
|
||||||
tracks: sceneTracks,
|
|
||||||
playheadTime: currentTime,
|
|
||||||
isExternalDrop: true,
|
|
||||||
elementDuration: duration,
|
|
||||||
pixelsPerSecond: BASE_TIMELINE_PIXELS_PER_SECOND,
|
|
||||||
zoomLevel,
|
|
||||||
});
|
|
||||||
|
|
||||||
const trackType: TrackType =
|
|
||||||
createdAsset.type === "audio" ? "audio" : "video";
|
|
||||||
|
|
||||||
const startTime = dropTarget?.xPosition ?? currentTime;
|
|
||||||
const element = buildElementFromMedia({
|
|
||||||
mediaId: createdAsset.id,
|
|
||||||
mediaType: createdAsset.type,
|
|
||||||
name: createdAsset.name,
|
|
||||||
duration,
|
|
||||||
startTime,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (reuseMainTrackId) {
|
|
||||||
editor.command.execute({
|
|
||||||
command: new InsertElementCommand({
|
|
||||||
element,
|
|
||||||
placement: { mode: "explicit", trackId: reuseMainTrackId },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (!dropTarget) continue;
|
|
||||||
if (dropTarget.isNewTrack) {
|
|
||||||
const addTrackCmd = new AddTrackCommand(
|
|
||||||
trackType,
|
|
||||||
dropTarget.trackIndex,
|
|
||||||
);
|
|
||||||
editor.command.execute({
|
|
||||||
command: new BatchCommand([
|
|
||||||
addTrackCmd,
|
|
||||||
new InsertElementCommand({
|
|
||||||
element,
|
|
||||||
placement: {
|
|
||||||
mode: "explicit",
|
|
||||||
trackId: addTrackCmd.getTrackId(),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const trackId = [
|
|
||||||
...sceneTracks.overlay,
|
|
||||||
sceneTracks.main,
|
|
||||||
...sceneTracks.audio,
|
|
||||||
][dropTarget.trackIndex]?.id;
|
|
||||||
if (!trackId) continue;
|
|
||||||
editor.command.execute({
|
|
||||||
command: new InsertElementCommand({
|
|
||||||
element,
|
|
||||||
placement: { mode: "explicit", trackId },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
uploadedCount: processedAssets.length,
|
|
||||||
assetNames: processedAssets.map((asset) => asset.name),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[editor, zoomLevel],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
async (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const hasAsset = hasDragData({ dataTransfer: e.dataTransfer });
|
|
||||||
const hasFiles = e.dataTransfer.files?.length > 0;
|
|
||||||
|
|
||||||
if (!hasAsset && !hasFiles) return;
|
|
||||||
|
|
||||||
const currentTarget = dropTarget;
|
|
||||||
setIsDragOver(false);
|
|
||||||
setDropTarget(null);
|
|
||||||
setElementType(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (hasAsset) {
|
|
||||||
if (!currentTarget) return;
|
|
||||||
const dragData = getDragData({ dataTransfer: e.dataTransfer });
|
|
||||||
if (!dragData) return;
|
|
||||||
|
|
||||||
if (dragData.type === "text") {
|
|
||||||
executeTextDrop({ target: currentTarget, dragData });
|
|
||||||
} else if (dragData.type === "graphic") {
|
|
||||||
executeGraphicDrop({
|
|
||||||
target: currentTarget,
|
|
||||||
dragData: dragData as GraphicDragData,
|
|
||||||
});
|
|
||||||
} else if (dragData.type === "sticker") {
|
|
||||||
executeStickerDrop({ target: currentTarget, dragData });
|
|
||||||
} else if (dragData.type === "effect") {
|
|
||||||
executeEffectDrop({
|
|
||||||
target: currentTarget,
|
|
||||||
dragData: dragData as EffectDragData,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
executeMediaDrop({ target: currentTarget, dragData });
|
|
||||||
}
|
|
||||||
} else if (hasFiles) {
|
|
||||||
const scrollContainer = tracksScrollRef?.current;
|
|
||||||
const referenceRect =
|
|
||||||
scrollContainer?.getBoundingClientRect() ??
|
|
||||||
containerRef.current?.getBoundingClientRect();
|
|
||||||
if (!referenceRect) return;
|
|
||||||
const scrollLeft = scrollContainer?.scrollLeft ?? 0;
|
|
||||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
|
||||||
const mouseX = e.clientX - referenceRect.left + scrollLeft;
|
|
||||||
const headerHeight =
|
|
||||||
headerRef?.current?.getBoundingClientRect().height ?? 0;
|
|
||||||
const mouseY =
|
|
||||||
e.clientY - referenceRect.top + scrollTop - headerHeight;
|
|
||||||
await executeFileDrop({
|
|
||||||
files: Array.from(e.dataTransfer.files),
|
|
||||||
mouseX,
|
|
||||||
mouseY,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to process drop:", err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
dropTarget,
|
|
||||||
executeTextDrop,
|
|
||||||
executeGraphicDrop,
|
|
||||||
executeStickerDrop,
|
|
||||||
executeMediaDrop,
|
|
||||||
executeEffectDrop,
|
|
||||||
executeFileDrop,
|
|
||||||
containerRef,
|
|
||||||
headerRef,
|
|
||||||
tracksScrollRef,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDragOver,
|
isDragOver: controller.isDragOver,
|
||||||
dropTarget,
|
dropTarget: controller.dropTarget,
|
||||||
dragElementType,
|
dragElementType: controller.dragElementType,
|
||||||
dragProps: {
|
dragProps: {
|
||||||
onDragEnter: handleDragEnter,
|
onDragEnter: controller.onDragEnter,
|
||||||
onDragOver: handleDragOver,
|
onDragOver: controller.onDragOver,
|
||||||
onDragLeave: handleDragLeave,
|
onDragLeave: controller.onDragLeave,
|
||||||
onDrop: handleDrop,
|
onDrop: controller.onDrop,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,13 @@
|
||||||
import { snappedSeekTime } from "opencut-wasm";
|
import { useEffect, useRef } from "react";
|
||||||
import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
|
||||||
import { useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
|
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
||||||
|
import { timelineTimeToPixels } from "@/timeline";
|
||||||
import {
|
import {
|
||||||
buildTimelineSnapPoints,
|
PlayheadController,
|
||||||
getTimelineSnapThresholdInTicks,
|
type PlayheadConfig,
|
||||||
resolveTimelineSnap,
|
} from "@/timeline/controllers/playhead-controller";
|
||||||
} from "@/timeline/snapping";
|
import type { MediaTime } from "@/wasm";
|
||||||
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";
|
|
||||||
|
|
||||||
interface UseTimelinePlayheadProps {
|
interface UseTimelinePlayheadProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
|
|
@ -35,272 +25,81 @@ export function useTimelinePlayhead({
|
||||||
playheadRef,
|
playheadRef,
|
||||||
}: UseTimelinePlayheadProps) {
|
}: UseTimelinePlayheadProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
|
|
||||||
const activeProject = editor.project.getActive();
|
|
||||||
const duration = editor.timeline.getTotalDuration();
|
|
||||||
const isShiftHeldRef = useShiftKey();
|
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 config: PlayheadConfig = {
|
||||||
const durationRef = useRef(duration);
|
zoomLevel,
|
||||||
const isScrubbingRef = useRef(isScrubbing);
|
duration: editor.timeline.getTotalDuration(),
|
||||||
const isPlayingRef = useRef(false);
|
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: ({ zoomLevel, scrollLeft, playheadTime }) =>
|
||||||
|
editor.project.setTimelineViewState({
|
||||||
|
viewState: {
|
||||||
|
zoomLevel,
|
||||||
|
scrollLeft,
|
||||||
|
playheadTime,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const configRef = useRef(config);
|
||||||
|
configRef.current = config;
|
||||||
|
|
||||||
useEffect(() => {
|
const ctrlRef = useRef<PlayheadController | null>(null);
|
||||||
zoomLevelRef.current = zoomLevel;
|
if (!ctrlRef.current) {
|
||||||
durationRef.current = duration;
|
ctrlRef.current = new PlayheadController({ configRef });
|
||||||
isScrubbingRef.current = isScrubbing;
|
}
|
||||||
isPlayingRef.current = editor.playback.getIsPlaying();
|
const ctrl = ctrlRef.current;
|
||||||
}, [zoomLevel, duration, isScrubbing, editor.playback]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
({ time }: { time: MediaTime }) => editor.playback.seek({ time }),
|
|
||||||
[editor.playback],
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrubTimeRef = useRef<MediaTime | 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 = mediaTime({
|
|
||||||
ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rate = activeProject.settings.fps;
|
|
||||||
const frameTime = (
|
|
||||||
snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime
|
|
||||||
) as MediaTime;
|
|
||||||
|
|
||||||
const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
|
|
||||||
const time: MediaTime = (() => {
|
|
||||||
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 as MediaTime)
|
|
||||||
: frameTime;
|
|
||||||
})();
|
|
||||||
|
|
||||||
scrubTimeRef.current = time;
|
|
||||||
seek({ time });
|
|
||||||
|
|
||||||
lastMouseXRef.current = event.clientX;
|
|
||||||
},
|
|
||||||
[
|
|
||||||
duration,
|
|
||||||
zoomLevel,
|
|
||||||
seek,
|
|
||||||
rulerRef,
|
|
||||||
activeProject.settings.fps,
|
|
||||||
isShiftHeldRef,
|
|
||||||
editor.scenes,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePlayheadMouseDown = useCallback(
|
|
||||||
({ event }: { event: React.MouseEvent }) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
editor.playback.setScrubbing({ isScrubbing: true });
|
|
||||||
handleScrub({ event });
|
|
||||||
},
|
|
||||||
[handleScrub, editor.playback],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRulerMouseDown = useCallback(
|
|
||||||
({ event }: { event: React.MouseEvent }) => {
|
|
||||||
if (event.button !== 0) return;
|
|
||||||
if (playheadRef?.current?.contains(event.target as Node)) return;
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
isDraggingRulerRef.current = true;
|
|
||||||
hasDraggedRulerRef.current = false;
|
|
||||||
|
|
||||||
editor.playback.setScrubbing({ isScrubbing: true });
|
|
||||||
handleScrub({ event, snappingEnabled: false });
|
|
||||||
},
|
|
||||||
[handleScrub, playheadRef, editor.playback],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePlayheadMouseDownEvent = useCallback(
|
|
||||||
(event: React.MouseEvent) => handlePlayheadMouseDown({ event }),
|
|
||||||
[handlePlayheadMouseDown],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRulerMouseDownEvent = useCallback(
|
|
||||||
(event: React.MouseEvent) => handleRulerMouseDown({ event }),
|
|
||||||
[handleRulerMouseDown],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEdgeAutoScroll({
|
|
||||||
isActive: isScrubbing,
|
|
||||||
getMouseClientX: () => lastMouseXRef.current,
|
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
|
||||||
contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isScrubbing) return;
|
|
||||||
|
|
||||||
const handleMouseMove = ({ event }: { event: MouseEvent }) => {
|
|
||||||
handleScrub({ event });
|
|
||||||
if (isDraggingRulerRef.current) {
|
|
||||||
hasDraggedRulerRef.current = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
|
|
||||||
editor.playback.setScrubbing({ isScrubbing: false });
|
|
||||||
const finalTime = scrubTimeRef.current;
|
|
||||||
if (finalTime !== null) {
|
|
||||||
seek({ time: finalTime });
|
|
||||||
editor.project.setTimelineViewState({
|
|
||||||
viewState: {
|
|
||||||
zoomLevel,
|
|
||||||
scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
|
|
||||||
playheadTime: finalTime,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
scrubTimeRef.current = null;
|
|
||||||
|
|
||||||
if (isDraggingRulerRef.current) {
|
|
||||||
isDraggingRulerRef.current = false;
|
|
||||||
if (!hasDraggedRulerRef.current) {
|
|
||||||
handleScrub({ event, snappingEnabled: false });
|
|
||||||
}
|
|
||||||
hasDraggedRulerRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMouseMove = (event: MouseEvent) => handleMouseMove({ event });
|
|
||||||
const onMouseUp = (event: MouseEvent) => handleMouseUp({ event });
|
|
||||||
|
|
||||||
window.addEventListener("mousemove", onMouseMove);
|
|
||||||
window.addEventListener("mouseup", onMouseUp);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("mousemove", onMouseMove);
|
|
||||||
window.removeEventListener("mouseup", onMouseUp);
|
|
||||||
};
|
|
||||||
}, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
|
|
||||||
|
|
||||||
const updatePlayheadLeft = useCallback(
|
|
||||||
(time: MediaTime) => {
|
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// Scroll → keep playhead position in sync with scroll offset.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scrollEl = rulerScrollRef.current;
|
const scrollEl = rulerScrollRef.current;
|
||||||
if (!scrollEl) return;
|
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 = () => {
|
// Playback events → update playhead position and auto-scroll during playback.
|
||||||
updatePlayheadLeft(editor.playback.getCurrentTime());
|
|
||||||
};
|
|
||||||
|
|
||||||
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
return () => scrollEl.removeEventListener("scroll", handleScroll);
|
|
||||||
}, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePlaybackTime = (time: MediaTime) => {
|
const handler = (time: MediaTime) => ctrl.handlePlaybackUpdate(time);
|
||||||
updatePlayheadLeft(time);
|
ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
|
||||||
|
const unsubscribeUpdate = editor.playback.onUpdate(handler);
|
||||||
if (!isPlayingRef.current || isScrubbingRef.current) return;
|
const unsubscribeSeek = editor.playback.onSeek(handler);
|
||||||
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 handlePlaybackUpdate = (e: Event) => {
|
|
||||||
handlePlaybackTime((e as CustomEvent<{ time: MediaTime }>).detail.time);
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialTime = editor.playback.getCurrentTime();
|
|
||||||
handlePlaybackTime(initialTime);
|
|
||||||
|
|
||||||
window.addEventListener("playback-update", handlePlaybackUpdate);
|
|
||||||
window.addEventListener("playback-seek", handlePlaybackUpdate);
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("playback-update", handlePlaybackUpdate);
|
unsubscribeUpdate();
|
||||||
window.removeEventListener("playback-seek", handlePlaybackUpdate);
|
unsubscribeSeek();
|
||||||
};
|
};
|
||||||
}, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
|
}, [ctrl, editor.playback]);
|
||||||
|
|
||||||
|
useEdgeAutoScroll({
|
||||||
|
isActive: isScrubbing,
|
||||||
|
getMouseClientX: () => ctrl.getLastMouseClientX(),
|
||||||
|
rulerScrollRef,
|
||||||
|
tracksScrollRef,
|
||||||
|
contentWidth: timelineTimeToPixels({
|
||||||
|
time: editor.timeline.getTotalDuration(),
|
||||||
|
zoomLevel,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => () => ctrl.destroy(), [ctrl]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
|
handlePlayheadMouseDown: ctrl.onPlayheadMouseDown,
|
||||||
handleRulerMouseDown: handleRulerMouseDownEvent,
|
handleRulerMouseDown: ctrl.onRulerMouseDown,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,332 +1,82 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useEffect, useReducer, useRef } from "react";
|
||||||
import type { EditorCore } from "@/core";
|
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
|
||||||
import { TICKS_PER_SECOND } from "@/wasm";
|
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
|
||||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||||
|
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||||
import { useTimelineStore } from "@/timeline/timeline-store";
|
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 { 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";
|
||||||
|
import type { TimelineElement } from "@/timeline";
|
||||||
|
|
||||||
interface ResizeInteractionState {
|
export type { ResizeSide };
|
||||||
side: ResizeSide;
|
|
||||||
startX: number;
|
interface UseTimelineResizeProps {
|
||||||
members: GroupResizeMember[];
|
zoomLevel: number;
|
||||||
|
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTimelineResize({
|
export function useTimelineResize({
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
}: {
|
}: UseTimelineResizeProps) {
|
||||||
zoomLevel: number;
|
|
||||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
|
||||||
}) {
|
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const isShiftHeldRef = useShiftKey();
|
const isShiftHeldRef = useShiftKey();
|
||||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||||
const { selectedElements } = useElementSelection();
|
const { selectedElements } = useElementSelection();
|
||||||
const [resizeState, setResizeState] = useState<ResizeInteractionState | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const latestResultRef = useRef<GroupResizeResult | null>(null);
|
|
||||||
|
|
||||||
const cancelResize = useCallback(() => {
|
const config: ResizeConfig = {
|
||||||
editor.timeline.discardPreview();
|
zoomLevel,
|
||||||
setResizeState(null);
|
snappingEnabled,
|
||||||
latestResultRef.current = null;
|
isShiftHeld: () => isShiftHeldRef.current,
|
||||||
onSnapPointChange?.(null);
|
getSceneTracks: () => editor.scenes.getActiveScene().tracks,
|
||||||
}, [editor.timeline, onSnapPointChange]);
|
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
|
||||||
|
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||||
useEffect(() => {
|
selectedElements,
|
||||||
if (!resizeState) {
|
discardPreview: () => editor.timeline.discardPreview(),
|
||||||
return;
|
previewElements: (updates) =>
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
editor.timeline.previewElements({
|
editor.timeline.previewElements({
|
||||||
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
updates: updates.map(({ trackId, elementId, patch }) => ({
|
||||||
trackId,
|
trackId,
|
||||||
elementId,
|
elementId,
|
||||||
updates: patch as Partial<TimelineElement>,
|
updates: patch as Partial<TimelineElement>,
|
||||||
})),
|
})),
|
||||||
});
|
}),
|
||||||
};
|
commitElements: (updates) =>
|
||||||
|
editor.timeline.updateElements({
|
||||||
const handleMouseUp = () => {
|
updates: updates.map(({ trackId, elementId, patch }) => ({
|
||||||
const result = latestResultRef.current;
|
trackId,
|
||||||
editor.timeline.discardPreview();
|
elementId,
|
||||||
if (
|
patch: patch as Partial<TimelineElement>,
|
||||||
result &&
|
})),
|
||||||
hasResizeChanges({ members: resizeState.members, result })
|
}),
|
||||||
) {
|
|
||||||
editor.timeline.updateElements({
|
|
||||||
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
patch: patch as Partial<TimelineElement>,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
onSnapPointChange,
|
onSnapPointChange,
|
||||||
]);
|
};
|
||||||
|
|
||||||
|
const configRef = useRef(config);
|
||||||
|
configRef.current = config;
|
||||||
|
|
||||||
|
const controllerRef = useRef<ResizeController | null>(null);
|
||||||
|
if (!controllerRef.current) {
|
||||||
|
controllerRef.current = new ResizeController({ configRef });
|
||||||
|
}
|
||||||
|
const controller = controllerRef.current;
|
||||||
|
|
||||||
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||||
|
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!controller.isResizing) return;
|
||||||
|
return registerCanceller({ fn: () => controller.cancel() });
|
||||||
|
}, [controller.isResizing, controller]);
|
||||||
|
|
||||||
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isResizing: resizeState !== null,
|
isResizing: controller.isResizing,
|
||||||
handleResizeStart,
|
handleResizeStart: controller.onResizeStart,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildResizeMembers({
|
|
||||||
tracks,
|
|
||||||
selectedElements,
|
|
||||||
}: {
|
|
||||||
tracks: SceneTracks;
|
|
||||||
selectedElements: Array<{ trackId: string; elementId: string }>;
|
|
||||||
}): GroupResizeMember[] {
|
|
||||||
const selectedElementIds = new Set(
|
|
||||||
selectedElements.map((selectedElement) => selectedElement.elementId),
|
|
||||||
);
|
|
||||||
const trackMap = new Map(
|
|
||||||
[...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
|
|
||||||
track.id,
|
|
||||||
track,
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
return selectedElements.flatMap(({ trackId, elementId }) => {
|
|
||||||
const track = trackMap.get(trackId);
|
|
||||||
const element = track?.elements.find(
|
|
||||||
(trackElement) => trackElement.id === elementId,
|
|
||||||
);
|
|
||||||
if (!track || !element) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherElements = track.elements.filter(
|
|
||||||
(trackElement) => !selectedElementIds.has(trackElement.id),
|
|
||||||
);
|
|
||||||
const leftNeighborBound = otherElements
|
|
||||||
.filter(
|
|
||||||
(trackElement) =>
|
|
||||||
trackElement.startTime + trackElement.duration <= element.startTime,
|
|
||||||
)
|
|
||||||
.reduce((bound, trackElement) => {
|
|
||||||
return Math.max(bound, trackElement.startTime + trackElement.duration);
|
|
||||||
}, -Infinity);
|
|
||||||
const rightNeighborBound = otherElements
|
|
||||||
.filter(
|
|
||||||
(trackElement) =>
|
|
||||||
trackElement.startTime >= element.startTime + element.duration,
|
|
||||||
)
|
|
||||||
.reduce((bound, trackElement) => {
|
|
||||||
return Math.min(bound, trackElement.startTime);
|
|
||||||
}, Infinity);
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
trackId,
|
|
||||||
elementId,
|
|
||||||
startTime: element.startTime,
|
|
||||||
duration: element.duration,
|
|
||||||
trimStart: element.trimStart,
|
|
||||||
trimEnd: element.trimEnd,
|
|
||||||
sourceDuration: element.sourceDuration,
|
|
||||||
retime: isRetimableElement(element) ? element.retime : undefined,
|
|
||||||
leftNeighborBound,
|
|
||||||
rightNeighborBound,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSnappedResizeDelta({
|
|
||||||
editor,
|
|
||||||
resizeState,
|
|
||||||
rawDeltaTime,
|
|
||||||
zoomLevel,
|
|
||||||
shouldSnap,
|
|
||||||
onSnapPointChange,
|
|
||||||
}: {
|
|
||||||
editor: EditorCore;
|
|
||||||
resizeState: ResizeInteractionState;
|
|
||||||
rawDeltaTime: number;
|
|
||||||
zoomLevel: number;
|
|
||||||
shouldSnap: boolean;
|
|
||||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
|
||||||
}): { deltaTime: number } {
|
|
||||||
if (!shouldSnap) {
|
|
||||||
onSnapPointChange?.(null);
|
|
||||||
return { deltaTime: rawDeltaTime };
|
|
||||||
}
|
|
||||||
|
|
||||||
const tracks = editor.scenes.getActiveScene().tracks;
|
|
||||||
const playheadTime = editor.playback.getCurrentTime();
|
|
||||||
const excludeElementIds = new Set(
|
|
||||||
resizeState.members.map((member) => member.elementId),
|
|
||||||
);
|
|
||||||
const snapPoints = buildTimelineSnapPoints({
|
|
||||||
sources: [
|
|
||||||
() => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
|
|
||||||
() => getPlayheadSnapPoints({ playheadTime }),
|
|
||||||
() =>
|
|
||||||
getAnimationKeyframeSnapPointsForTimeline({
|
|
||||||
tracks,
|
|
||||||
excludeElementIds,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
|
|
||||||
let closestSnapPoint: SnapPoint | null = null;
|
|
||||||
let closestSnapDistance = Infinity;
|
|
||||||
let deltaTime = rawDeltaTime;
|
|
||||||
|
|
||||||
for (const member of resizeState.members) {
|
|
||||||
const baseEdgeTime =
|
|
||||||
resizeState.side === "left"
|
|
||||||
? member.startTime
|
|
||||||
: member.startTime + member.duration;
|
|
||||||
const snapResult = resolveTimelineSnap({
|
|
||||||
targetTime: baseEdgeTime + rawDeltaTime,
|
|
||||||
snapPoints,
|
|
||||||
maxSnapDistance,
|
|
||||||
});
|
|
||||||
if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) {
|
|
||||||
closestSnapDistance = snapResult.snapDistance;
|
|
||||||
closestSnapPoint = snapResult.snapPoint;
|
|
||||||
deltaTime = snapResult.snappedTime - baseEdgeTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSnapPointChange?.(closestSnapPoint);
|
|
||||||
return { deltaTime };
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasResizeChanges({
|
|
||||||
members,
|
|
||||||
result,
|
|
||||||
}: {
|
|
||||||
members: GroupResizeMember[];
|
|
||||||
result: GroupResizeResult;
|
|
||||||
}): boolean {
|
|
||||||
return result.updates.some((update) => {
|
|
||||||
const member = members.find(
|
|
||||||
(candidateMember) => candidateMember.elementId === update.elementId,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
member?.trimStart !== update.patch.trimStart ||
|
|
||||||
member?.trimEnd !== update.patch.trimEnd ||
|
|
||||||
member?.startTime !== update.patch.startTime ||
|
|
||||||
member?.duration !== update.patch.duration
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useCallback, useRef } from "react";
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
import type { MutableRefObject, RefObject } from "react";
|
|
||||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
|
||||||
import { snappedSeekTime } from "opencut-wasm";
|
|
||||||
import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
|
||||||
import { useEditor } from "@/editor/use-editor";
|
import { useEditor } from "@/editor/use-editor";
|
||||||
|
import {
|
||||||
|
SeekController,
|
||||||
|
type SeekConfig,
|
||||||
|
} from "@/timeline/controllers/seek-controller";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
interface UseTimelineSeekProps {
|
interface UseTimelineSeekProps {
|
||||||
playheadRef: RefObject<HTMLDivElement | null>;
|
playheadRef: RefObject<HTMLDivElement | null>;
|
||||||
|
|
@ -17,44 +18,6 @@ interface UseTimelineSeekProps {
|
||||||
seek: (time: MediaTime) => void;
|
seek: (time: MediaTime) => 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({
|
export function useTimelineSeek({
|
||||||
playheadRef,
|
playheadRef,
|
||||||
trackLabelsRef,
|
trackLabelsRef,
|
||||||
|
|
@ -67,133 +30,42 @@ export function useTimelineSeek({
|
||||||
seek,
|
seek,
|
||||||
}: UseTimelineSeekProps) {
|
}: UseTimelineSeekProps) {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const activeProject = editor.project.getActive();
|
const config: SeekConfig = {
|
||||||
|
zoomLevel,
|
||||||
const mouseTrackingRef = useRef({
|
duration,
|
||||||
isMouseDown: false,
|
isSelecting,
|
||||||
downX: 0,
|
getPlayheadEl: () => playheadRef.current,
|
||||||
downY: 0,
|
getTrackLabelsEl: () => trackLabelsRef.current,
|
||||||
downTime: 0,
|
getRulerScrollEl: () => rulerScrollRef.current,
|
||||||
});
|
getTracksScrollEl: () => tracksScrollRef.current,
|
||||||
|
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||||
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
|
clearSelectedElements,
|
||||||
if (event.button !== 0) return;
|
seek,
|
||||||
setMouseTracking({ mouseTrackingRef, event });
|
setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
|
||||||
}, []);
|
|
||||||
|
|
||||||
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 = mediaTime({
|
|
||||||
ticks: Math.round(rawTimeSeconds * TICKS_PER_SECOND),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rate = activeProject?.settings.fps;
|
|
||||||
const time = rate
|
|
||||||
? ((snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) as MediaTime)
|
|
||||||
: rawTime;
|
|
||||||
seek(time);
|
|
||||||
editor.project.setTimelineViewState({
|
editor.project.setTimelineViewState({
|
||||||
viewState: {
|
viewState: {
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
scrollLeft: scrollContainer.scrollLeft,
|
scrollLeft,
|
||||||
playheadTime: time,
|
playheadTime,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
},
|
};
|
||||||
[
|
|
||||||
duration,
|
|
||||||
zoomLevel,
|
|
||||||
rulerScrollRef,
|
|
||||||
tracksScrollRef,
|
|
||||||
seek,
|
|
||||||
editor,
|
|
||||||
activeProject?.settings.fps.numerator,
|
|
||||||
activeProject?.settings.fps.denominator,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleTracksClick = useCallback(
|
const configRef = useRef(config);
|
||||||
(event: React.MouseEvent) => {
|
configRef.current = config;
|
||||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
|
||||||
resetMouseTracking({ mouseTrackingRef });
|
|
||||||
|
|
||||||
if (shouldProcess) {
|
const controllerRef = useRef<SeekController | null>(null);
|
||||||
clearSelectedElements();
|
if (!controllerRef.current) {
|
||||||
handleTimelineSeek({ event, source: "tracks" });
|
controllerRef.current = new SeekController({ configRef });
|
||||||
}
|
}
|
||||||
},
|
const controller = controllerRef.current;
|
||||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRulerClick = useCallback(
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
(event: React.MouseEvent) => {
|
|
||||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
|
||||||
resetMouseTracking({ mouseTrackingRef });
|
|
||||||
|
|
||||||
if (shouldProcess) {
|
|
||||||
clearSelectedElements();
|
|
||||||
handleTimelineSeek({ event, source: "ruler" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleTracksMouseDown,
|
handleTracksMouseDown: controller.onTracksMouseDown,
|
||||||
handleTracksClick,
|
handleTracksClick: controller.onTracksClick,
|
||||||
handleRulerMouseDown,
|
handleRulerMouseDown: controller.onRulerMouseDown,
|
||||||
handleRulerClick,
|
handleRulerClick: controller.onRulerClick,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import {
|
import {
|
||||||
type WheelEvent as ReactWheelEvent,
|
type WheelEvent as ReactWheelEvent,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
useCallback,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
useReducer,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
|
||||||
} from "react";
|
} 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 { 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";
|
||||||
import type { MediaTime } from "@/wasm";
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
interface UseTimelineZoomProps {
|
interface UseTimelineZoomProps {
|
||||||
|
|
@ -41,252 +41,59 @@ export function useTimelineZoom({
|
||||||
rulerScrollRef,
|
rulerScrollRef,
|
||||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
const hasInitializedRef = useRef(false);
|
const config: ZoomConfig = {
|
||||||
const hasRestoredPlayheadRef = useRef(false);
|
minZoom,
|
||||||
const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
getContainerEl: () => containerRef.current,
|
||||||
null,
|
getTracksScrollEl: () => tracksScrollRef.current,
|
||||||
);
|
getRulerScrollEl: () => rulerScrollRef.current,
|
||||||
|
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
|
||||||
|
seek: (time) => editor.playback.seek({ time }),
|
||||||
|
setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
|
||||||
|
editor.project.setTimelineViewState({
|
||||||
|
viewState: {
|
||||||
|
zoomLevel,
|
||||||
|
scrollLeft,
|
||||||
|
playheadTime,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const configRef = useRef(config);
|
||||||
|
configRef.current = config;
|
||||||
|
|
||||||
const [zoomLevel, setZoomLevelRaw] = useState(() => {
|
const controllerRef = useRef<ZoomController | null>(null);
|
||||||
if (initialZoom !== undefined) {
|
if (!controllerRef.current) {
|
||||||
hasInitializedRef.current = true;
|
controllerRef.current = new ZoomController({ configRef, initialZoom });
|
||||||
return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom));
|
}
|
||||||
}
|
const controller = controllerRef.current;
|
||||||
return minZoom;
|
const zoomLevel = controller.zoomLevel;
|
||||||
});
|
|
||||||
const previousZoomRef = useRef(zoomLevel);
|
|
||||||
const hasRestoredScrollRef = useRef(false);
|
|
||||||
const preZoomScrollLeftRef = useRef(0);
|
|
||||||
const prePlayheadAnchorScrollLeftRef = useRef(0);
|
|
||||||
const isInPlayheadAnchorModeRef = useRef(false);
|
|
||||||
|
|
||||||
const setZoomLevel = useCallback(
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||||
(updater: number | ((prev: number) => number)) => {
|
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||||
const scrollElement = tracksScrollRef.current;
|
|
||||||
if (scrollElement) {
|
|
||||||
preZoomScrollLeftRef.current = scrollElement.scrollLeft;
|
|
||||||
}
|
|
||||||
setZoomLevelRaw(updater);
|
|
||||||
},
|
|
||||||
[tracksScrollRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleWheel = useCallback(
|
|
||||||
(event: ReactWheelEvent) => {
|
|
||||||
const isZoomGesture = event.ctrlKey || event.metaKey;
|
|
||||||
const isHorizontalScrollGesture =
|
|
||||||
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
|
||||||
|
|
||||||
if (isHorizontalScrollGesture) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// pinch-zoom (ctrl/meta + wheel)
|
|
||||||
if (isZoomGesture) {
|
|
||||||
const normalizedDelta =
|
|
||||||
event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
|
||||||
const cappedDelta =
|
|
||||||
Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
|
|
||||||
const zoomFactor = Math.exp(-cappedDelta / 300);
|
|
||||||
setZoomLevel((prev) => {
|
|
||||||
const nextZoom = Math.max(
|
|
||||||
minZoom,
|
|
||||||
Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
|
|
||||||
);
|
|
||||||
return nextZoom;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[minZoom, setZoomLevel],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialZoom !== undefined && !hasInitializedRef.current) {
|
controller.reconcileInitialAndMinZoom(minZoom, initialZoom);
|
||||||
hasInitializedRef.current = true;
|
}, [controller, minZoom, initialZoom]);
|
||||||
setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setZoomLevel((prev: number) => {
|
|
||||||
if (prev < minZoom) {
|
|
||||||
return minZoom;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
}, [minZoom, initialZoom, setZoomLevel]);
|
|
||||||
|
|
||||||
const wrappedSetZoomLevel = useCallback(
|
|
||||||
(zoomLevelOrUpdater: number | ((prev: number) => number)) => {
|
|
||||||
setZoomLevel((prev: number) => {
|
|
||||||
const nextZoom =
|
|
||||||
typeof zoomLevelOrUpdater === "function"
|
|
||||||
? zoomLevelOrUpdater(prev)
|
|
||||||
: zoomLevelOrUpdater;
|
|
||||||
const clampedZoom = Math.max(
|
|
||||||
minZoom,
|
|
||||||
Math.min(TIMELINE_ZOOM_MAX, nextZoom),
|
|
||||||
);
|
|
||||||
return clampedZoom;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[minZoom, setZoomLevel],
|
|
||||||
);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const previousZoom = previousZoomRef.current;
|
controller.applyZoomLayout(zoomLevel);
|
||||||
if (previousZoom === zoomLevel) return;
|
}, [controller, zoomLevel]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
|
return controller.restoreInitialScrollIfNeeded(initialScrollLeft);
|
||||||
hasRestoredPlayheadRef.current = true;
|
}, [controller, initialScrollLeft]);
|
||||||
editor.playback.seek({ time: initialPlayheadTime });
|
|
||||||
}
|
|
||||||
}, [initialPlayheadTime, editor]);
|
|
||||||
|
|
||||||
// prevent browser zoom in the timeline
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preventZoom = (event: WheelEvent) => {
|
controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime);
|
||||||
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
|
}, [controller, initialPlayheadTime]);
|
||||||
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();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("wheel", preventZoom, {
|
useEffect(() => controller.bindPreventBrowserZoom(), [controller]);
|
||||||
passive: false,
|
|
||||||
capture: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
useEffect(() => () => controller.destroy(), [controller]);
|
||||||
document.removeEventListener("wheel", preventZoom, { capture: true });
|
|
||||||
};
|
|
||||||
}, [containerRef]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zoomLevel,
|
zoomLevel,
|
||||||
setZoomLevel: wrappedSetZoomLevel,
|
setZoomLevel: controller.setZoomLevel,
|
||||||
handleWheel,
|
handleWheel: controller.handleWheel,
|
||||||
saveScrollPosition,
|
saveScrollPosition: controller.saveScrollPosition,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import type { SnapPoint } from "@/timeline/snapping";
|
import type { SnapPoint } from "@/timeline/snapping";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function getPlayheadSnapPoints({
|
export function getPlayheadSnapPoints({
|
||||||
playheadTime,
|
playheadTime,
|
||||||
}: {
|
}: {
|
||||||
playheadTime: number;
|
playheadTime: MediaTime;
|
||||||
}): SnapPoint[] {
|
}): SnapPoint[] {
|
||||||
return [{ time: playheadTime, type: "playhead" }];
|
return [{ time: playheadTime, type: "playhead" }];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import type { SnapPoint, SnapResult } from "./types";
|
import type { SnapPoint, SnapResult } from "./types";
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export function resolveTimelineSnap({
|
export function resolveTimelineSnap({
|
||||||
targetTime,
|
targetTime,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
maxSnapDistance,
|
maxSnapDistance,
|
||||||
}: {
|
}: {
|
||||||
targetTime: number;
|
targetTime: MediaTime;
|
||||||
snapPoints: SnapPoint[];
|
snapPoints: SnapPoint[];
|
||||||
maxSnapDistance: number;
|
maxSnapDistance: number;
|
||||||
}): SnapResult {
|
}): SnapResult {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { MediaTime } from "@/wasm";
|
||||||
|
|
||||||
export type SnapPointType =
|
export type SnapPointType =
|
||||||
| "element-start"
|
| "element-start"
|
||||||
| "element-end"
|
| "element-end"
|
||||||
|
|
@ -6,14 +8,14 @@ export type SnapPointType =
|
||||||
| "keyframe";
|
| "keyframe";
|
||||||
|
|
||||||
export interface SnapPoint {
|
export interface SnapPoint {
|
||||||
time: number;
|
time: MediaTime;
|
||||||
type: SnapPointType;
|
type: SnapPointType;
|
||||||
elementId?: string;
|
elementId?: string;
|
||||||
trackId?: string;
|
trackId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnapResult {
|
export interface SnapResult {
|
||||||
snappedTime: number;
|
snappedTime: MediaTime;
|
||||||
snapPoint: SnapPoint | null;
|
snapPoint: SnapPoint | null;
|
||||||
snapDistance: number;
|
snapDistance: number;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,23 @@ export interface ElementDragState {
|
||||||
currentMouseY: number;
|
currentMouseY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ElementDragView =
|
||||||
|
| { readonly kind: "idle" }
|
||||||
|
| {
|
||||||
|
readonly kind: "dragging";
|
||||||
|
readonly anchorElementId: string;
|
||||||
|
readonly trackId: string;
|
||||||
|
readonly memberTimeOffsets: ReadonlyMap<string, MediaTime>;
|
||||||
|
readonly startMouseX: number;
|
||||||
|
readonly startMouseY: number;
|
||||||
|
readonly startElementTime: MediaTime;
|
||||||
|
readonly clickOffsetTime: MediaTime;
|
||||||
|
readonly currentTime: MediaTime;
|
||||||
|
readonly currentMouseX: number;
|
||||||
|
readonly currentMouseY: number;
|
||||||
|
readonly dropTarget: DropTarget | null;
|
||||||
|
};
|
||||||
|
|
||||||
export interface DropTarget {
|
export interface DropTarget {
|
||||||
trackIndex: number;
|
trackIndex: number;
|
||||||
isNewTrack: boolean;
|
isNewTrack: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import {
|
import {
|
||||||
|
lastFrameTime as _lastFrameTime,
|
||||||
|
parseTimecode as _parseTimecode,
|
||||||
|
roundToFrame as _roundToFrame,
|
||||||
|
snappedSeekTime as _snappedSeekTime,
|
||||||
TICKS_PER_SECOND as _TICKS_PER_SECOND,
|
TICKS_PER_SECOND as _TICKS_PER_SECOND,
|
||||||
mediaTimeFromSeconds as _mediaTimeFromSeconds,
|
mediaTimeFromSeconds as _mediaTimeFromSeconds,
|
||||||
mediaTimeToSeconds as _mediaTimeToSeconds,
|
mediaTimeToSeconds as _mediaTimeToSeconds,
|
||||||
|
type FrameRate,
|
||||||
|
type TimeCodeFormat,
|
||||||
} from "opencut-wasm";
|
} from "opencut-wasm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -127,3 +133,58 @@ export function clampMediaTime({
|
||||||
if (time > max) return max;
|
if (time > max) return max;
|
||||||
return time;
|
return time;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function roundFrameTime({
|
||||||
|
time,
|
||||||
|
fps,
|
||||||
|
}: {
|
||||||
|
time: MediaTime;
|
||||||
|
fps: FrameRate;
|
||||||
|
}): MediaTime {
|
||||||
|
return (_roundToFrame({ time, rate: fps }) ?? time) as MediaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundFrameTicks({
|
||||||
|
ticks,
|
||||||
|
fps,
|
||||||
|
}: {
|
||||||
|
ticks: number;
|
||||||
|
fps: FrameRate;
|
||||||
|
}): number {
|
||||||
|
return _roundToFrame({ time: ticks, rate: fps }) ?? ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function snapSeekMediaTime({
|
||||||
|
time,
|
||||||
|
duration,
|
||||||
|
fps,
|
||||||
|
}: {
|
||||||
|
time: MediaTime;
|
||||||
|
duration: MediaTime;
|
||||||
|
fps: FrameRate;
|
||||||
|
}): MediaTime {
|
||||||
|
return (_snappedSeekTime({ time, duration, rate: fps }) ?? time) as MediaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lastFrameMediaTime({
|
||||||
|
duration,
|
||||||
|
fps,
|
||||||
|
}: {
|
||||||
|
duration: MediaTime;
|
||||||
|
fps: FrameRate;
|
||||||
|
}): MediaTime {
|
||||||
|
return (_lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMediaTimecode({
|
||||||
|
timeCode,
|
||||||
|
format,
|
||||||
|
fps,
|
||||||
|
}: {
|
||||||
|
timeCode: string;
|
||||||
|
format: TimeCodeFormat;
|
||||||
|
fps: FrameRate;
|
||||||
|
}): MediaTime | null {
|
||||||
|
const parsedTime = _parseTimecode({ timeCode, format, rate: fps });
|
||||||
|
return parsedTime == null ? null : (parsedTime as MediaTime);
|
||||||
|
}
|
||||||
|
|
|
||||||
8
bun.lock
8
bun.lock
|
|
@ -54,7 +54,7 @@
|
||||||
"nanoid": "^5.1.5",
|
"nanoid": "^5.1.5",
|
||||||
"next": "16.1.3",
|
"next": "16.1.3",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"opencut-wasm": "^0.2.8",
|
"opencut-wasm": "^0.2.9",
|
||||||
"pg": "^8.16.2",
|
"pg": "^8.16.2",
|
||||||
"postgres": "^3.4.5",
|
"postgres": "^3.4.5",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|
@ -785,7 +785,7 @@
|
||||||
|
|
||||||
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="],
|
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
||||||
|
|
||||||
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
|
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
|
||||||
|
|
||||||
|
|
@ -875,7 +875,7 @@
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
|
|
||||||
|
|
@ -1359,7 +1359,7 @@
|
||||||
|
|
||||||
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
|
"onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="],
|
||||||
|
|
||||||
"opencut-wasm": ["opencut-wasm@0.2.8", "", {}, "sha512-R1cXB4HuTC5RdbGRYD0Q2n9rWQWDRB7KMwGJnbjrhnnwxBbR5XDu/3tL7hlzb/RZ7VZcaX5OGHZ1QgL6ex/2cQ=="],
|
"opencut-wasm": ["opencut-wasm@0.2.9", "", {}, "sha512-yZilhRRgNA02XY9Bq2yt6FidbnDQS1OYsvtNczxF6jL+nvl3vRboG0owwpQY0SPIbeJoJjJBuOVy0i1Pp6JS/w=="],
|
||||||
|
|
||||||
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
|
"p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="],
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue