refactor: thinner hooks
This commit is contained in:
parent
e6c6193638
commit
56ca09692a
|
|
@ -1,6 +1,7 @@
|
|||
import { getElementKeyframes } from "@/animation";
|
||||
import type { SceneTracks } from "@/timeline";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import { addMediaTime } from "@/wasm";
|
||||
|
||||
export function getAnimationKeyframeSnapPointsForTimeline({
|
||||
tracks,
|
||||
|
|
@ -22,7 +23,7 @@ export function getAnimationKeyframeSnapPointsForTimeline({
|
|||
animations: element.animations,
|
||||
})) {
|
||||
snapPoints.push({
|
||||
time: element.startTime + keyframe.time,
|
||||
time: addMediaTime({ a: element.startTime, b: keyframe.time }),
|
||||
type: "keyframe",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
|
|
|
|||
|
|
@ -1,268 +1,268 @@
|
|||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Check, Copy, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BasePage } from "@/app/base-page";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
function downloadAsset(src: string) {
|
||||
const filename = src.split("/").pop() ?? "asset.svg";
|
||||
const a = document.createElement("a");
|
||||
a.href = src;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function copyAsset(src: string) {
|
||||
const res = await fetch(src);
|
||||
const text = await res.text();
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
const ALL_ASSETS = () => ASSET_SECTIONS.flatMap((s) => s.assets);
|
||||
|
||||
type AssetTheme = "dark" | "light" | "icon";
|
||||
|
||||
interface AssetVariant {
|
||||
src: string;
|
||||
theme: AssetTheme;
|
||||
label: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AssetSection {
|
||||
title: string;
|
||||
description: string;
|
||||
cols: "1" | "2";
|
||||
assets: AssetVariant[];
|
||||
}
|
||||
|
||||
const ASSET_SECTIONS: AssetSection[] = [
|
||||
{
|
||||
title: "Symbol",
|
||||
description:
|
||||
"Use the symbol on its own when the OpenCut name is already present nearby or space is limited.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/symbol.svg",
|
||||
theme: "dark",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/symbol-light.svg",
|
||||
theme: "light",
|
||||
label: "Symbol",
|
||||
width: 400,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Lockup",
|
||||
description:
|
||||
"The full lockup combines the symbol and wordmark. Prefer this in most contexts where you have enough horizontal space.",
|
||||
cols: "2",
|
||||
assets: [
|
||||
{
|
||||
src: "/logos/opencut/logo.svg",
|
||||
theme: "dark",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/logo-light.svg",
|
||||
theme: "light",
|
||||
label: "Logo",
|
||||
width: 1809,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text.svg",
|
||||
theme: "dark",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
{
|
||||
src: "/logos/opencut/text-light.svg",
|
||||
theme: "light",
|
||||
label: "Text",
|
||||
width: 1760,
|
||||
height: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrandPage() {
|
||||
return (
|
||||
<BasePage
|
||||
maxWidth="6xl"
|
||||
title="Brand"
|
||||
description={
|
||||
<>
|
||||
Download OpenCut brand assets for use in your projects.{" "}
|
||||
<Link
|
||||
href="#guidelines"
|
||||
className="underline underline-offset-4"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("guidelines")
|
||||
?.scrollIntoView({ behavior: "smooth" })
|
||||
}
|
||||
>
|
||||
Read the brand guidelines.
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="mx-auto gap-2"
|
||||
onClick={() => {
|
||||
ALL_ASSETS().forEach((asset, i) => {
|
||||
setTimeout(() => downloadAsset(asset.src), i * 200);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
Download all
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-10">
|
||||
{ASSET_SECTIONS.map((section) => (
|
||||
<div key={section.title} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="font-semibold text-lg">{section.title}</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
section.cols === "2"
|
||||
? "grid-cols-1 sm:grid-cols-2"
|
||||
: "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{section.assets.map((variant) => (
|
||||
<AssetCard key={variant.src} variant={variant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div id="guidelines" className="flex flex-col gap-8 text-sm">
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">Usage</h2>
|
||||
<p className="text-muted-foreground text-base leading-relaxed">
|
||||
OpenCut is open source — the code is free to use under its license.
|
||||
That license does not cover the name or logo. You can say you use
|
||||
OpenCut, that your project integrates with OpenCut, or that it was
|
||||
built on top of OpenCut. You cannot name your product OpenCut, imply
|
||||
we made or endorse your product, or use the marks commercially
|
||||
without asking first. For anything unclear, reach out at{" "}
|
||||
<Link
|
||||
href="mailto:brand@opencut.app"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
brand@opencut.app
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="font-semibold text-lg">What's not allowed</h2>
|
||||
<ul className="text-muted-foreground text-base flex flex-col gap-2 leading-relaxed">
|
||||
{[
|
||||
"Using OpenCut in the name of your product, service, or domain.",
|
||||
"Implying that OpenCut made, sponsors, or endorses your work.",
|
||||
"Using the logo or name on merchandise or commercial marketing.",
|
||||
"Modifying the marks.",
|
||||
].map((item) => (
|
||||
<li key={item} className="flex gap-2">
|
||||
<span className="mt-0.5 shrink-0">-</span>
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKER_STYLES: Record<"dark" | "light", CSSProperties> = {
|
||||
light: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#000",
|
||||
},
|
||||
dark: {
|
||||
backgroundImage:
|
||||
"linear-gradient(45deg, #e0e0e0 25%, transparent 25%), linear-gradient(-45deg, #e0e0e0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #e0e0e0 75%), linear-gradient(-45deg, transparent 75%, #e0e0e0 75%)",
|
||||
backgroundSize: "18px 18px",
|
||||
backgroundPosition: "0 0, 0 9px, 9px -9px, -9px 0px",
|
||||
backgroundColor: "#f5f5f5",
|
||||
},
|
||||
};
|
||||
|
||||
function AssetCard({ variant }: { variant: AssetVariant }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
await copyAsset(variant.src);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group relative overflow-hidden"
|
||||
style={
|
||||
variant.theme === "icon" ? undefined : CHECKER_STYLES[variant.theme]
|
||||
}
|
||||
>
|
||||
<div className="flex h-56 items-center justify-center px-12 py-8">
|
||||
<Image
|
||||
src={variant.src}
|
||||
alt={variant.label}
|
||||
width={variant.width}
|
||||
height={variant.height}
|
||||
className="max-h-16 w-auto select-none object-contain"
|
||||
draggable={false}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 size-9"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check /> : <Copy />}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
"use client";
|
||||
|
||||
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 type { MediaTime } from "@/wasm";
|
||||
import {
|
||||
parseMediaTimecode,
|
||||
snapSeekMediaTime,
|
||||
type MediaTime,
|
||||
} from "@/wasm";
|
||||
|
||||
interface EditableTimecodeProps {
|
||||
time: MediaTime;
|
||||
|
|
@ -47,19 +55,20 @@ export function EditableTimecode({
|
|||
};
|
||||
|
||||
const applyEdit = () => {
|
||||
const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps });
|
||||
const parsedTime = parseMediaTimecode({
|
||||
timeCode: inputValue,
|
||||
format,
|
||||
fps,
|
||||
});
|
||||
|
||||
if (parsedTime == null) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const clampedTime = (
|
||||
duration
|
||||
? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ??
|
||||
parsedTime)
|
||||
: parsedTime
|
||||
) as MediaTime;
|
||||
const clampedTime = duration
|
||||
? snapSeekMediaTime({ time: parsedTime, duration, fps })
|
||||
: parsedTime;
|
||||
|
||||
onTimeChange?.({ time: clampedTime });
|
||||
setIsEditing(false);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { clearDragData, setDragData } from "@/timeline/drag-data";
|
||||
import type { TimelineDragData } from "@/timeline/drag";
|
||||
import { cn } from "@/utils/ui";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
|
@ -74,21 +73,23 @@ export function DraggableItem({
|
|||
};
|
||||
}, [isDragging]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
const handleDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setDragImage(emptyImg, 0, 0);
|
||||
|
||||
setDragData({ dataTransfer: e.dataTransfer, dragData });
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
editor.timeline.dragSource.begin({
|
||||
dataTransfer: event.dataTransfer,
|
||||
dragData,
|
||||
});
|
||||
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
setDragPosition({ x: event.clientX, y: event.clientY });
|
||||
setIsDragging(true);
|
||||
|
||||
onDragStart?.({ e });
|
||||
onDragStart?.({ e: event });
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setIsDragging(false);
|
||||
clearDragData();
|
||||
editor.timeline.dragSource.end();
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
import type { EditorCore } from "@/core";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
clampRetimeRate,
|
||||
shouldMaintainPitch,
|
||||
} from "@/retime/rate";
|
||||
import { clampRetimeRate, shouldMaintainPitch } from "@/retime/rate";
|
||||
import type { AudioClipSource } from "@/media/audio";
|
||||
import { createAudioContext, collectAudioClips } from "@/media/audio";
|
||||
import {
|
||||
|
|
@ -56,10 +53,8 @@ export class AudioManager {
|
|||
this.editor.playback.subscribe(this.handlePlaybackChange),
|
||||
this.editor.timeline.subscribe(this.handleTimelineChange),
|
||||
this.editor.media.subscribe(this.handleTimelineChange),
|
||||
this.editor.playback.onSeek(this.handleSeek),
|
||||
);
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("playback-seek", this.handleSeek);
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
|
|
@ -68,9 +63,6 @@ export class AudioManager {
|
|||
unsub();
|
||||
}
|
||||
this.unsubscribers = [];
|
||||
if (typeof window !== "undefined") {
|
||||
window.removeEventListener("playback-seek", this.handleSeek);
|
||||
}
|
||||
this.disposeSinks();
|
||||
this.preparedClipBuffers.clear();
|
||||
this.decodedBuffers.clear();
|
||||
|
|
@ -102,17 +94,14 @@ export class AudioManager {
|
|||
}
|
||||
};
|
||||
|
||||
private handleSeek = (event: Event): void => {
|
||||
const detail = (event as CustomEvent<{ time: number }>).detail;
|
||||
if (!detail) return;
|
||||
|
||||
private handleSeek = (time: number): void => {
|
||||
if (this.editor.playback.getIsScrubbing()) {
|
||||
this.stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.editor.playback.getIsPlaying()) {
|
||||
void this.startPlayback({ time: detail.time / TICKS_PER_SECOND });
|
||||
void this.startPlayback({ time: time / TICKS_PER_SECOND });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -126,7 +115,9 @@ export class AudioManager {
|
|||
|
||||
if (!this.editor.playback.getIsPlaying()) return;
|
||||
|
||||
void this.startPlayback({ time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND });
|
||||
void this.startPlayback({
|
||||
time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND,
|
||||
});
|
||||
};
|
||||
|
||||
private ensureAudioContext(): AudioContext | null {
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import {
|
|||
clampMediaTime,
|
||||
type MediaTime,
|
||||
mediaTimeFromSeconds,
|
||||
roundFrameTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
|
||||
export class PlaybackManager {
|
||||
private isPlaying = false;
|
||||
|
|
@ -16,6 +16,8 @@ export class PlaybackManager {
|
|||
private previousVolume = 1;
|
||||
private isScrubbing = false;
|
||||
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 playbackStartWallTime = 0;
|
||||
private playbackStartTime: MediaTime = ZERO_MEDIA_TIME;
|
||||
|
|
@ -139,6 +141,16 @@ export class PlaybackManager {
|
|||
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 {
|
||||
const maxTime = this.editor.timeline.getTotalDuration();
|
||||
const nextTime = this.clampTimeToTimeline(this.currentTime);
|
||||
|
|
@ -158,6 +170,7 @@ export class PlaybackManager {
|
|||
this.notify();
|
||||
|
||||
if (timeChanged) {
|
||||
this.notifySeek(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 {
|
||||
if (this.playbackTimer) {
|
||||
cancelAnimationFrame(this.playbackTimer);
|
||||
|
|
@ -195,20 +220,20 @@ export class PlaybackManager {
|
|||
a: this.playbackStartTime,
|
||||
b: mediaTimeFromSeconds({ seconds: elapsedSeconds }),
|
||||
});
|
||||
const newTime = (fps
|
||||
? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime)
|
||||
: rawTime) as MediaTime;
|
||||
const newTime = fps ? roundFrameTime({ time: rawTime, fps }) : rawTime;
|
||||
const maxTime = this.editor.timeline.getTotalDuration();
|
||||
|
||||
if (newTime >= maxTime) {
|
||||
this.pause();
|
||||
this.currentTime = maxTime;
|
||||
this.notify();
|
||||
this.notifySeek(maxTime);
|
||||
this.dispatchSeekEvent(maxTime);
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTime = newTime;
|
||||
this.notifyUpdate(newTime);
|
||||
this.dispatchUpdateEvent(newTime);
|
||||
this.playbackTimer = requestAnimationFrame(this.updateTime);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@ import type {
|
|||
RetimeConfig,
|
||||
} from "@/timeline";
|
||||
import { calculateTotalDuration } from "@/timeline";
|
||||
import { TimelineDragSource } from "@/timeline/drag-source";
|
||||
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 {
|
||||
canElementBeHidden,
|
||||
canElementHaveAudio,
|
||||
|
|
@ -27,7 +28,6 @@ import {
|
|||
resolveAnimationTarget,
|
||||
resolveAnimationPathValueAtTime,
|
||||
} from "@/animation";
|
||||
import { lastFrameTime } from "opencut-wasm";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import {
|
||||
AddTrackCommand,
|
||||
|
|
@ -68,6 +68,7 @@ export class TimelineManager {
|
|||
private listeners = new Set<() => void>();
|
||||
private previewOverlay = new Map<string, Partial<TimelineElement>>();
|
||||
private previewTracks: SceneTracks | null = null;
|
||||
public readonly dragSource = new TimelineDragSource();
|
||||
|
||||
constructor(private editor: EditorCore) {}
|
||||
|
||||
|
|
@ -214,7 +215,7 @@ export class TimelineManager {
|
|||
const duration = this.getTotalDuration();
|
||||
const fps = this.editor.project.getActive()?.settings.fps;
|
||||
if (!fps || duration <= 0) return duration;
|
||||
return (lastFrameTime({ duration, rate: fps }) ?? duration) as MediaTime;
|
||||
return lastFrameMediaTime({ duration, fps });
|
||||
}
|
||||
|
||||
getTrackById({ trackId }: { trackId: string }): TimelineTrack | null {
|
||||
|
|
@ -707,11 +708,11 @@ export class TimelineManager {
|
|||
previewElements({
|
||||
updates,
|
||||
}: {
|
||||
updates: Array<{
|
||||
updates: readonly {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
updates: Partial<TimelineElement>;
|
||||
}>;
|
||||
}[];
|
||||
}): void {
|
||||
let changedOverlayCount = 0;
|
||||
for (const { elementId, updates: elementUpdates } of updates) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useRef } from "react";
|
||||
import { hasDragData } from "@/timeline/drag-data";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
|
||||
interface UseFileUploadOptions {
|
||||
accept?: string;
|
||||
|
|
@ -7,19 +7,23 @@ interface UseFileUploadOptions {
|
|||
onFilesSelected?: (files: File[]) => void;
|
||||
}
|
||||
|
||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
return !hasDragData({ dataTransfer }) && dataTransfer.types.includes("Files");
|
||||
}
|
||||
|
||||
export function useFileUpload({
|
||||
accept,
|
||||
multiple,
|
||||
onFilesSelected,
|
||||
}: UseFileUploadOptions = {}) {
|
||||
const editor = useEditor();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function containsFiles(dataTransfer: DataTransfer): boolean {
|
||||
return (
|
||||
!editor.timeline.dragSource.isActive() &&
|
||||
dataTransfer.types.includes("Files")
|
||||
);
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
if (!inputRef.current) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -73,15 +73,13 @@ function TimecodeDisplay() {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) =>
|
||||
setCurrentTime((e as CustomEvent<{ time: MediaTime }>).detail.time);
|
||||
window.addEventListener("playback-update", handler);
|
||||
window.addEventListener("playback-seek", handler);
|
||||
const unsubscribeUpdate = editor.playback.onUpdate(setCurrentTime);
|
||||
const unsubscribeSeek = editor.playback.onSeek(setCurrentTime);
|
||||
return () => {
|
||||
window.removeEventListener("playback-update", handler);
|
||||
window.removeEventListener("playback-seek", handler);
|
||||
unsubscribeUpdate();
|
||||
unsubscribeSeek();
|
||||
};
|
||||
}, []);
|
||||
}, [editor.playback]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,583 @@
|
|||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
getHitElements,
|
||||
hitTest,
|
||||
resolvePreferredHit,
|
||||
} from "@/preview/hit-test";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapPosition,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import type { TCanvasSize } from "@/project/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import type {
|
||||
ElementRef,
|
||||
SceneTracks,
|
||||
TextElement,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
VisualElement,
|
||||
} from "@/timeline";
|
||||
|
||||
const MIN_DRAG_DISTANCE = 0.5;
|
||||
const PRIMARY_POINTER_BUTTON = 0;
|
||||
|
||||
type Point = { readonly x: number; readonly y: number };
|
||||
|
||||
interface CapturedPointerState {
|
||||
readonly pointerId: number;
|
||||
readonly captureTarget: HTMLElement;
|
||||
}
|
||||
|
||||
interface PendingGesture extends CapturedPointerState {
|
||||
readonly kind: "pending";
|
||||
readonly origin: Point;
|
||||
readonly topmostHit: ElementWithBounds | null;
|
||||
readonly selectedHit: ElementWithBounds | null;
|
||||
readonly selectedElements: readonly ElementRef[];
|
||||
}
|
||||
|
||||
interface DragElementSnapshot {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
}
|
||||
|
||||
interface DraggingGesture extends CapturedPointerState {
|
||||
readonly kind: "dragging";
|
||||
readonly origin: Point;
|
||||
readonly bounds: {
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
readonly rotation: number;
|
||||
};
|
||||
readonly elements: readonly DragElementSnapshot[];
|
||||
}
|
||||
|
||||
type GestureSession =
|
||||
| { readonly kind: "idle" }
|
||||
| PendingGesture
|
||||
| DraggingGesture;
|
||||
|
||||
const IDLE_GESTURE: GestureSession = { kind: "idle" };
|
||||
|
||||
export interface EditingTextState {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly element: TextElement;
|
||||
}
|
||||
|
||||
export interface PreviewViewportAdapter {
|
||||
screenToCanvas: ({
|
||||
clientX,
|
||||
clientY,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}) => Point | null;
|
||||
screenPixelsToLogicalThreshold: ({
|
||||
screenPixels,
|
||||
}: {
|
||||
screenPixels: number;
|
||||
}) => Point;
|
||||
}
|
||||
|
||||
export interface InputAdapter {
|
||||
isShiftHeld: () => boolean;
|
||||
}
|
||||
|
||||
export interface SceneReader {
|
||||
getTracks: () => SceneTracks;
|
||||
getCurrentTime: () => number;
|
||||
getMediaAssets: () => MediaAsset[];
|
||||
getCanvasSize: () => TCanvasSize;
|
||||
}
|
||||
|
||||
export interface SelectionApi {
|
||||
getSelected: () => readonly ElementRef[];
|
||||
setSelected: (elements: readonly ElementRef[]) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
export interface TimelinePreviewUpdate {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly updates: Partial<TimelineElement>;
|
||||
}
|
||||
|
||||
export interface TimelineOps {
|
||||
getElementsWithTracks: ({
|
||||
elements,
|
||||
}: {
|
||||
elements: readonly ElementRef[];
|
||||
}) => Array<{ track: TimelineTrack; element: TimelineElement }>;
|
||||
previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
|
||||
commitPreview: () => void;
|
||||
discardPreview: () => void;
|
||||
}
|
||||
|
||||
export interface PlaybackApi {
|
||||
getIsPlaying: () => boolean;
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
}
|
||||
|
||||
export interface PreviewOptions {
|
||||
isMaskMode: () => boolean;
|
||||
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||
}
|
||||
|
||||
export interface PreviewInteractionDeps {
|
||||
viewport: PreviewViewportAdapter;
|
||||
input: InputAdapter;
|
||||
scene: SceneReader;
|
||||
selection: SelectionApi;
|
||||
timeline: TimelineOps;
|
||||
playback: PlaybackApi;
|
||||
preview: PreviewOptions;
|
||||
}
|
||||
|
||||
export interface PreviewInteractionDepsRef {
|
||||
readonly current: PreviewInteractionDeps;
|
||||
}
|
||||
|
||||
function isSameElementRef({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: ElementRef;
|
||||
right: ElementRef;
|
||||
}): boolean {
|
||||
return left.trackId === right.trackId && left.elementId === right.elementId;
|
||||
}
|
||||
|
||||
function buildDragSelection({
|
||||
selectedElements,
|
||||
dragTarget,
|
||||
}: {
|
||||
selectedElements: readonly ElementRef[];
|
||||
dragTarget: ElementWithBounds;
|
||||
}): ElementRef[] {
|
||||
const dragTargetRef = {
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
};
|
||||
|
||||
if (
|
||||
!selectedElements.some((selectedElement) =>
|
||||
isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
)
|
||||
) {
|
||||
return [dragTargetRef];
|
||||
}
|
||||
|
||||
return [
|
||||
dragTargetRef,
|
||||
...selectedElements.filter(
|
||||
(selectedElement) =>
|
||||
!isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function movedPastDragThreshold({
|
||||
current,
|
||||
origin,
|
||||
}: {
|
||||
current: Point;
|
||||
origin: Point;
|
||||
}): boolean {
|
||||
return (
|
||||
Math.abs(current.x - origin.x) > MIN_DRAG_DISTANCE ||
|
||||
Math.abs(current.y - origin.y) > MIN_DRAG_DISTANCE
|
||||
);
|
||||
}
|
||||
|
||||
function toDragElementSnapshots({
|
||||
elementsWithTracks,
|
||||
}: {
|
||||
elementsWithTracks: Array<{ track: TimelineTrack; element: TimelineElement }>;
|
||||
}): DragElementSnapshot[] {
|
||||
const isVisualTrackedElement = (value: {
|
||||
track: TimelineTrack;
|
||||
element: TimelineElement;
|
||||
}): value is { track: TimelineTrack; element: VisualElement } =>
|
||||
isVisualElement(value.element);
|
||||
|
||||
return elementsWithTracks
|
||||
.filter(isVisualTrackedElement)
|
||||
.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialTransform: element.transform,
|
||||
}));
|
||||
}
|
||||
|
||||
export class PreviewInteractionController {
|
||||
private readonly depsRef: PreviewInteractionDepsRef;
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
|
||||
private gesture: GestureSession = IDLE_GESTURE;
|
||||
private editingTextState: EditingTextState | null = null;
|
||||
private wasPlaying: boolean;
|
||||
private unsubscribePlayback: (() => void) | null = null;
|
||||
|
||||
constructor({ depsRef }: { depsRef: PreviewInteractionDepsRef }) {
|
||||
this.depsRef = depsRef;
|
||||
this.wasPlaying = this.deps.playback.getIsPlaying();
|
||||
|
||||
this.onDoubleClick = this.onDoubleClick.bind(this);
|
||||
this.onPointerDown = this.onPointerDown.bind(this);
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
this.commitTextEdit = this.commitTextEdit.bind(this);
|
||||
this.handlePlaybackChange = this.handlePlaybackChange.bind(this);
|
||||
|
||||
this.unsubscribePlayback = this.deps.playback.subscribe(
|
||||
this.handlePlaybackChange,
|
||||
);
|
||||
}
|
||||
|
||||
private get deps(): PreviewInteractionDeps {
|
||||
return this.depsRef.current;
|
||||
}
|
||||
|
||||
get isDragging(): boolean {
|
||||
return this.gesture.kind === "dragging";
|
||||
}
|
||||
|
||||
get editingText(): EditingTextState | null {
|
||||
return this.editingTextState;
|
||||
}
|
||||
|
||||
subscribe({ listener }: { listener: () => void }): () => void {
|
||||
this.subscribers.add(listener);
|
||||
return () => this.subscribers.delete(listener);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unsubscribePlayback?.();
|
||||
this.unsubscribePlayback = null;
|
||||
this.abortActiveGesture();
|
||||
this.editingTextState = null;
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.gesture.kind === "idle") return;
|
||||
this.abortActiveGesture();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private abortActiveGesture(): void {
|
||||
if (this.gesture.kind === "idle") return;
|
||||
|
||||
if (this.gesture.kind === "dragging") {
|
||||
this.deps.timeline.discardPreview();
|
||||
}
|
||||
|
||||
this.releaseCapturedPointer({ pointerState: this.gesture });
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
}
|
||||
|
||||
commitTextEdit(): void {
|
||||
if (!this.editingTextState) return;
|
||||
|
||||
this.editingTextState = null;
|
||||
this.deps.timeline.commitPreview();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onDoubleClick({ clientX, clientY }: ReactMouseEvent): void {
|
||||
if (this.editingTextState || this.deps.preview.isMaskMode()) return;
|
||||
|
||||
const startPos = this.deps.viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const hit = hitTest({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||
});
|
||||
|
||||
if (!hit || hit.element.type !== "text") return;
|
||||
|
||||
this.editingTextState = {
|
||||
trackId: hit.trackId,
|
||||
elementId: hit.elementId,
|
||||
element: hit.element,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onPointerDown({
|
||||
clientX,
|
||||
clientY,
|
||||
currentTarget,
|
||||
pointerId,
|
||||
button,
|
||||
}: ReactPointerEvent): void {
|
||||
if (this.editingTextState) return;
|
||||
if (this.deps.preview.isMaskMode()) return;
|
||||
if (button !== PRIMARY_POINTER_BUTTON) return;
|
||||
|
||||
const startPos = this.deps.viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const hits = getHitElements({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||
});
|
||||
const selectedElements = this.deps.selection.getSelected();
|
||||
|
||||
this.gesture = {
|
||||
kind: "pending",
|
||||
origin: startPos,
|
||||
pointerId,
|
||||
captureTarget: currentTarget as HTMLElement,
|
||||
topmostHit: hits[0] ?? null,
|
||||
selectedHit: resolvePreferredHit({
|
||||
hits,
|
||||
preferredElements: [...selectedElements],
|
||||
}),
|
||||
selectedElements,
|
||||
};
|
||||
|
||||
currentTarget.setPointerCapture(pointerId);
|
||||
}
|
||||
|
||||
onPointerMove({ clientX, clientY }: ReactPointerEvent): void {
|
||||
const currentPos = this.deps.viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!currentPos) return;
|
||||
|
||||
if (this.gesture.kind === "pending") {
|
||||
const pending = this.gesture;
|
||||
if (
|
||||
!movedPastDragThreshold({
|
||||
current: currentPos,
|
||||
origin: pending.origin,
|
||||
})
|
||||
) {
|
||||
this.clearSnapLines();
|
||||
return;
|
||||
}
|
||||
|
||||
this.beginDragFromPending({ pending });
|
||||
}
|
||||
|
||||
if (this.gesture.kind !== "dragging") return;
|
||||
|
||||
this.updateDragPreview({
|
||||
drag: this.gesture,
|
||||
currentPos,
|
||||
});
|
||||
}
|
||||
|
||||
onPointerUp({ type }: ReactPointerEvent): void {
|
||||
if (this.gesture.kind === "dragging") {
|
||||
const drag = this.gesture;
|
||||
|
||||
if (type === "pointercancel") {
|
||||
this.deps.timeline.discardPreview();
|
||||
} else {
|
||||
this.deps.timeline.commitPreview();
|
||||
}
|
||||
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: drag });
|
||||
this.notify();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.gesture.kind !== "pending") return;
|
||||
|
||||
const pending = this.gesture;
|
||||
|
||||
if (type !== "pointercancel") {
|
||||
const clickTarget = pending.topmostHit;
|
||||
if (!clickTarget) {
|
||||
this.deps.selection.clearSelection();
|
||||
} else {
|
||||
this.deps.selection.setSelected([
|
||||
{
|
||||
trackId: clickTarget.trackId,
|
||||
elementId: clickTarget.elementId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: pending });
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.subscribers) listener();
|
||||
}
|
||||
|
||||
private clearSnapLines(): void {
|
||||
this.deps.preview.onSnapLinesChange?.([]);
|
||||
}
|
||||
|
||||
private releaseCapturedPointer({
|
||||
pointerState,
|
||||
}: {
|
||||
pointerState: CapturedPointerState | null;
|
||||
}): void {
|
||||
if (!pointerState) return;
|
||||
|
||||
if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
||||
}
|
||||
|
||||
private getVisibleElementsWithBounds(): ElementWithBounds[] {
|
||||
return getVisibleElementsWithBounds({
|
||||
tracks: this.deps.scene.getTracks(),
|
||||
currentTime: this.deps.scene.getCurrentTime(),
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
mediaAssets: this.deps.scene.getMediaAssets(),
|
||||
});
|
||||
}
|
||||
|
||||
private handlePlaybackChange(): void {
|
||||
const isPlaying = this.deps.playback.getIsPlaying();
|
||||
if (isPlaying && !this.wasPlaying && this.editingTextState) {
|
||||
this.commitTextEdit();
|
||||
}
|
||||
this.wasPlaying = isPlaying;
|
||||
}
|
||||
|
||||
private beginDragFromPending({ pending }: { pending: PendingGesture }): void {
|
||||
const dragTarget = pending.selectedHit ?? pending.topmostHit;
|
||||
if (!dragTarget) {
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: pending });
|
||||
return;
|
||||
}
|
||||
|
||||
const dragSelection = buildDragSelection({
|
||||
selectedElements: pending.selectedElements,
|
||||
dragTarget,
|
||||
});
|
||||
const draggableElements = toDragElementSnapshots({
|
||||
elementsWithTracks: this.deps.timeline.getElementsWithTracks({
|
||||
elements: dragSelection,
|
||||
}),
|
||||
});
|
||||
|
||||
if (draggableElements.length === 0) {
|
||||
this.gesture = IDLE_GESTURE;
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer({ pointerState: pending });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pending.selectedHit === null) {
|
||||
this.deps.selection.setSelected([
|
||||
{
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
this.gesture = {
|
||||
kind: "dragging",
|
||||
origin: pending.origin,
|
||||
pointerId: pending.pointerId,
|
||||
captureTarget: pending.captureTarget,
|
||||
bounds: {
|
||||
width: dragTarget.bounds.width,
|
||||
height: dragTarget.bounds.height,
|
||||
rotation: dragTarget.bounds.rotation,
|
||||
},
|
||||
elements: draggableElements,
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private updateDragPreview({
|
||||
drag,
|
||||
currentPos,
|
||||
}: {
|
||||
drag: DraggingGesture;
|
||||
currentPos: Point;
|
||||
}): void {
|
||||
const firstElement = drag.elements[0];
|
||||
if (!firstElement) return;
|
||||
|
||||
const deltaX = currentPos.x - drag.origin.x;
|
||||
const deltaY = currentPos.y - drag.origin.y;
|
||||
|
||||
const proposedPosition = {
|
||||
x: firstElement.initialTransform.position.x + deltaX,
|
||||
y: firstElement.initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
const shouldSnap = !this.deps.input.isShiftHeld();
|
||||
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedPosition, activeLines } = shouldSnap
|
||||
? snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
elementSize: drag.bounds,
|
||||
rotation: drag.bounds.rotation,
|
||||
snapThreshold,
|
||||
})
|
||||
: {
|
||||
snappedPosition: proposedPosition,
|
||||
activeLines: [] as SnapLine[],
|
||||
};
|
||||
|
||||
this.deps.preview.onSnapLinesChange?.(activeLines);
|
||||
|
||||
const deltaSnappedX =
|
||||
snappedPosition.x - firstElement.initialTransform.position.x;
|
||||
const deltaSnappedY =
|
||||
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||
|
||||
this.deps.timeline.previewElements(
|
||||
drag.elements.map(({ trackId, elementId, initialTransform }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: {
|
||||
x: initialTransform.position.x + deltaSnappedX,
|
||||
y: initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,753 @@
|
|||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { MediaAsset } from "@/media/types";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type Corner,
|
||||
type Edge,
|
||||
type ElementBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
MIN_SCALE,
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
snapScaleAxes,
|
||||
type ScaleEdgePreference,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
hasKeyframesForPath,
|
||||
resolveTransformAtTime,
|
||||
setChannel,
|
||||
} from "@/animation";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type {
|
||||
ElementRef,
|
||||
SceneTracks,
|
||||
TimelineElement,
|
||||
VisualElement,
|
||||
} from "@/timeline";
|
||||
|
||||
type Point = { readonly x: number; readonly y: number };
|
||||
type CanvasSize = { readonly width: number; readonly height: number };
|
||||
type HandleType = Corner | Edge | "rotation";
|
||||
|
||||
interface CapturedPointerState {
|
||||
readonly pointerId: number;
|
||||
readonly captureTarget: HTMLElement;
|
||||
}
|
||||
|
||||
interface CornerScaleSession extends CapturedPointerState {
|
||||
readonly kind: "corner-scale";
|
||||
readonly corner: Corner;
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialDistance: number;
|
||||
readonly initialBoundsCx: number;
|
||||
readonly initialBoundsCy: number;
|
||||
readonly baseWidth: number;
|
||||
readonly baseHeight: number;
|
||||
readonly shouldClearScaleAnimation: boolean;
|
||||
readonly animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
interface EdgeScaleSession extends CapturedPointerState {
|
||||
readonly kind: "edge-scale";
|
||||
readonly edge: Edge;
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialBoundsCx: number;
|
||||
readonly initialBoundsCy: number;
|
||||
readonly baseWidth: number;
|
||||
readonly baseHeight: number;
|
||||
readonly rotationRad: number;
|
||||
readonly shouldClearScaleAnimation: boolean;
|
||||
readonly animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
interface RotationSession extends CapturedPointerState {
|
||||
readonly kind: "rotation";
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly initialTransform: Transform;
|
||||
readonly initialAngle: number;
|
||||
readonly initialBoundsCx: number;
|
||||
readonly initialBoundsCy: number;
|
||||
}
|
||||
|
||||
type TransformSession =
|
||||
| { readonly kind: "idle" }
|
||||
| CornerScaleSession
|
||||
| EdgeScaleSession
|
||||
| RotationSession;
|
||||
|
||||
const IDLE_SESSION: TransformSession = { kind: "idle" };
|
||||
|
||||
interface VisualSelectionContext {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly element: VisualElement;
|
||||
readonly bounds: ElementBounds;
|
||||
readonly resolvedTransform: Transform;
|
||||
}
|
||||
|
||||
export interface PreviewViewportAdapter {
|
||||
screenToCanvas: ({
|
||||
clientX,
|
||||
clientY,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}) => Point | null;
|
||||
screenPixelsToLogicalThreshold: ({
|
||||
screenPixels,
|
||||
}: {
|
||||
screenPixels: number;
|
||||
}) => Point;
|
||||
}
|
||||
|
||||
export interface InputAdapter {
|
||||
isShiftHeld: () => boolean;
|
||||
}
|
||||
|
||||
export interface SceneReader {
|
||||
getSelectedElements: () => readonly ElementRef[];
|
||||
getTracks: () => SceneTracks;
|
||||
getCurrentTime: () => number;
|
||||
getMediaAssets: () => MediaAsset[];
|
||||
getCanvasSize: () => CanvasSize;
|
||||
}
|
||||
|
||||
export interface TimelinePreviewUpdate {
|
||||
readonly trackId: string;
|
||||
readonly elementId: string;
|
||||
readonly updates: Partial<TimelineElement>;
|
||||
}
|
||||
|
||||
export interface TimelineOps {
|
||||
previewElements: (updates: readonly TimelinePreviewUpdate[]) => void;
|
||||
commitPreview: () => void;
|
||||
discardPreview: () => void;
|
||||
}
|
||||
|
||||
export interface PreviewOptions {
|
||||
onSnapLinesChange?: (lines: SnapLine[]) => void;
|
||||
}
|
||||
|
||||
export interface TransformHandleDeps {
|
||||
viewport: PreviewViewportAdapter;
|
||||
input: InputAdapter;
|
||||
scene: SceneReader;
|
||||
timeline: TimelineOps;
|
||||
preview: PreviewOptions;
|
||||
}
|
||||
|
||||
export interface TransformHandleDepsRef {
|
||||
readonly current: TransformHandleDeps;
|
||||
}
|
||||
|
||||
function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
|
||||
return edge === "right"
|
||||
? { right: true }
|
||||
: edge === "left"
|
||||
? { left: true }
|
||||
: { bottom: true };
|
||||
}
|
||||
|
||||
function clampScaleNonZero(scale: number): number {
|
||||
if (Math.abs(scale) < MIN_SCALE) {
|
||||
return scale < 0 ? -MIN_SCALE : MIN_SCALE;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
function getCornerDistance({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
corner: Corner;
|
||||
}): number {
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
|
||||
|
||||
const rotatedX = localX * cos - localY * sin;
|
||||
const rotatedY = localX * sin + localY * cos;
|
||||
return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
|
||||
}
|
||||
|
||||
function buildSelectedWithBounds({
|
||||
selectedElements,
|
||||
elementsWithBounds,
|
||||
}: {
|
||||
selectedElements: readonly ElementRef[];
|
||||
elementsWithBounds: readonly ElementWithBounds[];
|
||||
}): ElementWithBounds | null {
|
||||
if (selectedElements.length !== 1) return null;
|
||||
|
||||
return (
|
||||
elementsWithBounds.find(
|
||||
(entry) =>
|
||||
entry.trackId === selectedElements[0].trackId &&
|
||||
entry.elementId === selectedElements[0].elementId,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function buildCornerScaleAnimationReset({
|
||||
animations,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
}): {
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
} {
|
||||
const shouldClearScaleAnimation =
|
||||
hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
}) ||
|
||||
hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
});
|
||||
|
||||
return {
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale: shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: setChannel({
|
||||
animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
channel: undefined,
|
||||
}),
|
||||
propertyPath: "transform.scaleY",
|
||||
channel: undefined,
|
||||
})
|
||||
: animations,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEdgeScaleAnimationReset({
|
||||
animations,
|
||||
edge,
|
||||
}: {
|
||||
animations: ElementAnimations | undefined;
|
||||
edge: Edge;
|
||||
}): {
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
} {
|
||||
const propertyPath =
|
||||
edge === "right" || edge === "left"
|
||||
? "transform.scaleX"
|
||||
: "transform.scaleY";
|
||||
|
||||
const shouldClearScaleAnimation = hasKeyframesForPath({
|
||||
animations,
|
||||
propertyPath,
|
||||
});
|
||||
|
||||
return {
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale: shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations,
|
||||
propertyPath,
|
||||
channel: undefined,
|
||||
})
|
||||
: animations,
|
||||
};
|
||||
}
|
||||
|
||||
export class TransformHandleController {
|
||||
private readonly depsRef: TransformHandleDepsRef;
|
||||
private readonly subscribers = new Set<() => void>();
|
||||
|
||||
private session: TransformSession = IDLE_SESSION;
|
||||
|
||||
constructor({ depsRef }: { depsRef: TransformHandleDepsRef }) {
|
||||
this.depsRef = depsRef;
|
||||
|
||||
this.onCornerPointerDown = this.onCornerPointerDown.bind(this);
|
||||
this.onEdgePointerDown = this.onEdgePointerDown.bind(this);
|
||||
this.onRotationPointerDown = this.onRotationPointerDown.bind(this);
|
||||
this.onPointerMove = this.onPointerMove.bind(this);
|
||||
this.onPointerUp = this.onPointerUp.bind(this);
|
||||
}
|
||||
|
||||
private get deps(): TransformHandleDeps {
|
||||
return this.depsRef.current;
|
||||
}
|
||||
|
||||
get selectedWithBounds(): ElementWithBounds | null {
|
||||
return buildSelectedWithBounds({
|
||||
selectedElements: this.deps.scene.getSelectedElements(),
|
||||
elementsWithBounds: this.getVisibleElementsWithBounds(),
|
||||
});
|
||||
}
|
||||
|
||||
get activeHandle(): HandleType | null {
|
||||
switch (this.session.kind) {
|
||||
case "corner-scale":
|
||||
return this.session.corner;
|
||||
case "edge-scale":
|
||||
return this.session.edge;
|
||||
case "rotation":
|
||||
return "rotation";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this.session.kind !== "idle";
|
||||
}
|
||||
|
||||
subscribe(fn: () => void): () => void {
|
||||
this.subscribers.add(fn);
|
||||
return () => this.subscribers.delete(fn);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.session.kind !== "idle") {
|
||||
const session = this.session;
|
||||
this.session = IDLE_SESSION;
|
||||
this.deps.timeline.discardPreview();
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer(session);
|
||||
}
|
||||
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.session.kind === "idle") return;
|
||||
|
||||
const session = this.session;
|
||||
this.session = IDLE_SESSION;
|
||||
this.deps.timeline.discardPreview();
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer(session);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onCornerPointerDown({
|
||||
event,
|
||||
corner,
|
||||
}: {
|
||||
event: ReactPointerEvent;
|
||||
corner: Corner;
|
||||
}): void {
|
||||
const context = this.getSelectedVisualContext();
|
||||
if (!context) return;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
const { shouldClearScaleAnimation, animationsWithoutScale } =
|
||||
buildCornerScaleAnimationReset({
|
||||
animations: context.element.animations,
|
||||
});
|
||||
|
||||
this.session = {
|
||||
kind: "corner-scale",
|
||||
corner,
|
||||
trackId: context.trackId,
|
||||
elementId: context.elementId,
|
||||
initialTransform: context.resolvedTransform,
|
||||
initialDistance: getCornerDistance({
|
||||
bounds: context.bounds,
|
||||
corner,
|
||||
}),
|
||||
initialBoundsCx: context.bounds.cx,
|
||||
initialBoundsCy: context.bounds.cy,
|
||||
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
|
||||
baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
pointerId: event.pointerId,
|
||||
captureTarget: this.capturePointer({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
pointerId: event.pointerId,
|
||||
}),
|
||||
};
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onRotationPointerDown({ event }: { event: ReactPointerEvent }): void {
|
||||
const context = this.getSelectedVisualContext();
|
||||
if (!context) return;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
const position = this.deps.viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
|
||||
const deltaX = position.x - context.bounds.cx;
|
||||
const deltaY = position.y - context.bounds.cy;
|
||||
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
|
||||
this.session = {
|
||||
kind: "rotation",
|
||||
trackId: context.trackId,
|
||||
elementId: context.elementId,
|
||||
initialTransform: context.resolvedTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx: context.bounds.cx,
|
||||
initialBoundsCy: context.bounds.cy,
|
||||
pointerId: event.pointerId,
|
||||
captureTarget: this.capturePointer({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
pointerId: event.pointerId,
|
||||
}),
|
||||
};
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onEdgePointerDown({
|
||||
event,
|
||||
edge,
|
||||
}: {
|
||||
event: ReactPointerEvent;
|
||||
edge: Edge;
|
||||
}): void {
|
||||
const context = this.getSelectedVisualContext();
|
||||
if (!context) return;
|
||||
|
||||
event.stopPropagation();
|
||||
|
||||
const { shouldClearScaleAnimation, animationsWithoutScale } =
|
||||
buildEdgeScaleAnimationReset({
|
||||
animations: context.element.animations,
|
||||
edge,
|
||||
});
|
||||
|
||||
this.session = {
|
||||
kind: "edge-scale",
|
||||
edge,
|
||||
trackId: context.trackId,
|
||||
elementId: context.elementId,
|
||||
initialTransform: context.resolvedTransform,
|
||||
initialBoundsCx: context.bounds.cx,
|
||||
initialBoundsCy: context.bounds.cy,
|
||||
baseWidth: context.bounds.width / context.resolvedTransform.scaleX,
|
||||
baseHeight: context.bounds.height / context.resolvedTransform.scaleY,
|
||||
rotationRad: (context.bounds.rotation * Math.PI) / 180,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
pointerId: event.pointerId,
|
||||
captureTarget: this.capturePointer({
|
||||
target: event.currentTarget as HTMLElement,
|
||||
pointerId: event.pointerId,
|
||||
}),
|
||||
};
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
onPointerMove({ event }: { event: ReactPointerEvent }): void {
|
||||
if (this.session.kind === "idle") return;
|
||||
|
||||
const position = this.deps.viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
|
||||
switch (this.session.kind) {
|
||||
case "corner-scale":
|
||||
this.previewCornerScale({
|
||||
session: this.session,
|
||||
position,
|
||||
});
|
||||
return;
|
||||
case "edge-scale":
|
||||
this.previewEdgeScale({
|
||||
session: this.session,
|
||||
position,
|
||||
});
|
||||
return;
|
||||
case "rotation":
|
||||
this.previewRotation({
|
||||
session: this.session,
|
||||
position,
|
||||
});
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp(): void {
|
||||
if (this.session.kind === "idle") return;
|
||||
|
||||
const session = this.session;
|
||||
this.session = IDLE_SESSION;
|
||||
this.deps.timeline.commitPreview();
|
||||
this.clearSnapLines();
|
||||
this.releaseCapturedPointer(session);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const fn of this.subscribers) fn();
|
||||
}
|
||||
|
||||
private clearSnapLines(): void {
|
||||
this.deps.preview.onSnapLinesChange?.([]);
|
||||
}
|
||||
|
||||
private capturePointer({
|
||||
target,
|
||||
pointerId,
|
||||
}: {
|
||||
target: HTMLElement;
|
||||
pointerId: number;
|
||||
}): HTMLElement {
|
||||
target.setPointerCapture(pointerId);
|
||||
return target;
|
||||
}
|
||||
|
||||
private releaseCapturedPointer(pointerState: CapturedPointerState): void {
|
||||
if (!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
||||
}
|
||||
|
||||
private getVisibleElementsWithBounds(): ElementWithBounds[] {
|
||||
return getVisibleElementsWithBounds({
|
||||
tracks: this.deps.scene.getTracks(),
|
||||
currentTime: this.deps.scene.getCurrentTime(),
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
mediaAssets: this.deps.scene.getMediaAssets(),
|
||||
});
|
||||
}
|
||||
|
||||
private getSelectedVisualContext(): VisualSelectionContext | null {
|
||||
const selectedWithBounds = this.selectedWithBounds;
|
||||
if (!selectedWithBounds) return null;
|
||||
if (!isVisualElement(selectedWithBounds.element)) return null;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: this.deps.scene.getCurrentTime(),
|
||||
elementStartTime: selectedWithBounds.element.startTime,
|
||||
elementDuration: selectedWithBounds.element.duration,
|
||||
});
|
||||
|
||||
return {
|
||||
trackId: selectedWithBounds.trackId,
|
||||
elementId: selectedWithBounds.elementId,
|
||||
element: selectedWithBounds.element,
|
||||
bounds: selectedWithBounds.bounds,
|
||||
resolvedTransform: resolveTransformAtTime({
|
||||
baseTransform: selectedWithBounds.element.transform,
|
||||
animations: selectedWithBounds.element.animations,
|
||||
localTime,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private previewCornerScale({
|
||||
session,
|
||||
position,
|
||||
}: {
|
||||
session: CornerScaleSession;
|
||||
position: Point;
|
||||
}): void {
|
||||
const deltaX = position.x - session.initialBoundsCx;
|
||||
const deltaY = position.y - session.initialBoundsCy;
|
||||
const currentDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
||||
const scaleFactor = currentDistance / session.initialDistance;
|
||||
|
||||
// Use actual element dimensions (base * current scale) so snapping is
|
||||
// computed from the rendered geometry when scaleX != scaleY.
|
||||
const effectiveWidth = session.baseWidth * session.initialTransform.scaleX;
|
||||
const effectiveHeight =
|
||||
session.baseHeight * session.initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedScale, activeLines } = this.deps.input.isShiftHeld()
|
||||
? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
|
||||
: snapScale({
|
||||
proposedScale: scaleFactor,
|
||||
position: session.initialTransform.position,
|
||||
baseWidth: effectiveWidth,
|
||||
baseHeight: effectiveHeight,
|
||||
rotation: session.initialTransform.rotate,
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
this.deps.preview.onSnapLinesChange?.(activeLines);
|
||||
|
||||
this.deps.timeline.previewElements([
|
||||
{
|
||||
trackId: session.trackId,
|
||||
elementId: session.elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
scaleX: clampScaleNonZero(
|
||||
session.initialTransform.scaleX * snappedScale,
|
||||
),
|
||||
scaleY: clampScaleNonZero(
|
||||
session.initialTransform.scaleY * snappedScale,
|
||||
),
|
||||
},
|
||||
...(session.shouldClearScaleAnimation && {
|
||||
animations: session.animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private previewEdgeScale({
|
||||
session,
|
||||
position,
|
||||
}: {
|
||||
session: EdgeScaleSession;
|
||||
position: Point;
|
||||
}): void {
|
||||
const deltaX = position.x - session.initialBoundsCx;
|
||||
const deltaY = position.y - session.initialBoundsCy;
|
||||
const xProjection =
|
||||
deltaX * Math.cos(session.rotationRad) +
|
||||
deltaY * Math.sin(session.rotationRad);
|
||||
const yProjection =
|
||||
-deltaX * Math.sin(session.rotationRad) +
|
||||
deltaY * Math.cos(session.rotationRad);
|
||||
const projection =
|
||||
session.edge === "right"
|
||||
? xProjection
|
||||
: session.edge === "left"
|
||||
? -xProjection
|
||||
: yProjection;
|
||||
|
||||
const baseAxisHalf =
|
||||
session.edge === "right" || session.edge === "left"
|
||||
? session.baseWidth / 2
|
||||
: session.baseHeight / 2;
|
||||
const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
|
||||
|
||||
const proposedScaleX =
|
||||
session.edge === "right" || session.edge === "left"
|
||||
? proposedScale
|
||||
: session.initialTransform.scaleX;
|
||||
const proposedScaleY =
|
||||
session.edge === "bottom"
|
||||
? proposedScale
|
||||
: session.initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = this.deps.viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { x: xSnap, y: ySnap } = this.deps.input.isShiftHeld()
|
||||
? {
|
||||
x: {
|
||||
snappedScale: proposedScaleX,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
y: {
|
||||
snappedScale: proposedScaleY,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
}
|
||||
: snapScaleAxes({
|
||||
proposedScaleX,
|
||||
proposedScaleY,
|
||||
position: session.initialTransform.position,
|
||||
baseWidth: session.baseWidth,
|
||||
baseHeight: session.baseHeight,
|
||||
rotation: session.initialTransform.rotate,
|
||||
canvasSize: this.deps.scene.getCanvasSize(),
|
||||
snapThreshold,
|
||||
preferredEdges: getPreferredEdge({ edge: session.edge }),
|
||||
});
|
||||
|
||||
const relevantSnap =
|
||||
session.edge === "right" || session.edge === "left" ? xSnap : ySnap;
|
||||
this.deps.preview.onSnapLinesChange?.(relevantSnap.activeLines);
|
||||
|
||||
this.deps.timeline.previewElements([
|
||||
{
|
||||
trackId: session.trackId,
|
||||
elementId: session.elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
scaleX:
|
||||
session.edge === "right" || session.edge === "left"
|
||||
? xSnap.snappedScale
|
||||
: session.initialTransform.scaleX,
|
||||
scaleY:
|
||||
session.edge === "bottom"
|
||||
? ySnap.snappedScale
|
||||
: session.initialTransform.scaleY,
|
||||
},
|
||||
...(session.shouldClearScaleAnimation && {
|
||||
animations: session.animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private previewRotation({
|
||||
session,
|
||||
position,
|
||||
}: {
|
||||
session: RotationSession;
|
||||
position: Point;
|
||||
}): void {
|
||||
const deltaX = position.x - session.initialBoundsCx;
|
||||
const deltaY = position.y - session.initialBoundsCy;
|
||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
let deltaAngle = currentAngle - session.initialAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
|
||||
const newRotate = session.initialTransform.rotate + deltaAngle;
|
||||
const { snappedRotation } = this.deps.input.isShiftHeld()
|
||||
? { snappedRotation: newRotate }
|
||||
: snapRotation({ proposedRotation: newRotate });
|
||||
|
||||
this.deps.timeline.previewElements([
|
||||
{
|
||||
trackId: session.trackId,
|
||||
elementId: session.elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...session.initialTransform,
|
||||
rotate: snappedRotation,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +1,17 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { ElementRef, TextElement } from "@/timeline";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
getHitElements,
|
||||
hitTest,
|
||||
resolvePreferredHit,
|
||||
} from "@/preview/hit-test";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapPosition,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import type { SnapLine } from "@/preview/preview-snap";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
import {
|
||||
PreviewInteractionController,
|
||||
type PreviewInteractionDeps,
|
||||
type PreviewInteractionDepsRef,
|
||||
} from "@/preview/controllers/preview-interaction-controller";
|
||||
|
||||
export type OnSnapLinesChange = (lines: SnapLine[]) => void;
|
||||
|
||||
const MIN_DRAG_DISTANCE = 0.5;
|
||||
|
||||
interface CapturedPointerState {
|
||||
pointerId: number;
|
||||
captureTarget: HTMLElement;
|
||||
}
|
||||
|
||||
interface PendingGestureState extends CapturedPointerState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
topmostHit: ElementWithBounds | null;
|
||||
selectedHit: ElementWithBounds | null;
|
||||
selectedElements: ElementRef[];
|
||||
}
|
||||
|
||||
interface DragState extends CapturedPointerState {
|
||||
startX: number;
|
||||
startY: number;
|
||||
bounds: {
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
elements: Array<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
}>;
|
||||
}
|
||||
|
||||
function isSameElementRef({
|
||||
left,
|
||||
right,
|
||||
}: {
|
||||
left: ElementRef;
|
||||
right: ElementRef;
|
||||
}): boolean {
|
||||
return left.trackId === right.trackId && left.elementId === right.elementId;
|
||||
}
|
||||
|
||||
function buildDragSelection({
|
||||
selectedElements,
|
||||
dragTarget,
|
||||
}: {
|
||||
selectedElements: ElementRef[];
|
||||
dragTarget: ElementWithBounds;
|
||||
}): ElementRef[] {
|
||||
const dragTargetRef = {
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
};
|
||||
|
||||
if (
|
||||
!selectedElements.some((selectedElement) =>
|
||||
isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
)
|
||||
) {
|
||||
return [dragTargetRef];
|
||||
}
|
||||
|
||||
return [
|
||||
dragTargetRef,
|
||||
...selectedElements.filter(
|
||||
(selectedElement) =>
|
||||
!isSameElementRef({ left: selectedElement, right: dragTargetRef }),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function usePreviewInteraction({
|
||||
onSnapLinesChange,
|
||||
isMaskMode = false,
|
||||
|
|
@ -102,355 +22,74 @@ export function usePreviewInteraction({
|
|||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const viewport = usePreviewViewport();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [editingText, setEditingText] = useState<{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TextElement;
|
||||
originalOpacity: number;
|
||||
} | null>(null);
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const pendingGestureRef = useRef<PendingGestureState | null>(null);
|
||||
const wasPlayingRef = useRef(editor.playback.getIsPlaying());
|
||||
const editingTextRef = useRef(editingText);
|
||||
editingTextRef.current = editingText;
|
||||
|
||||
const releaseCapturedPointer = useCallback(
|
||||
(pointerState: CapturedPointerState | null) => {
|
||||
if (!pointerState) return;
|
||||
|
||||
if (
|
||||
!pointerState.captureTarget.hasPointerCapture(pointerState.pointerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
pointerState.captureTarget.releasePointerCapture(pointerState.pointerId);
|
||||
const deps: PreviewInteractionDeps = {
|
||||
viewport: {
|
||||
screenToCanvas: viewport.screenToCanvas,
|
||||
screenPixelsToLogicalThreshold: viewport.screenPixelsToLogicalThreshold,
|
||||
},
|
||||
[],
|
||||
);
|
||||
input: {
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
},
|
||||
scene: {
|
||||
getTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getCurrentTime: () => editor.playback.getCurrentTime(),
|
||||
getMediaAssets: () => editor.media.getAssets(),
|
||||
getCanvasSize: () => editor.project.getActive().settings.canvasSize,
|
||||
},
|
||||
selection: {
|
||||
getSelected: () => editor.selection.getSelectedElements(),
|
||||
setSelected: (elements) =>
|
||||
editor.selection.setSelectedElements({ elements: [...elements] }),
|
||||
clearSelection: () => editor.selection.clearSelection(),
|
||||
},
|
||||
timeline: {
|
||||
getElementsWithTracks: ({ elements }) =>
|
||||
editor.timeline.getElementsWithTracks({ elements: [...elements] }),
|
||||
previewElements: (updates) =>
|
||||
editor.timeline.previewElements({ updates: [...updates] }),
|
||||
commitPreview: () => editor.timeline.commitPreview(),
|
||||
discardPreview: () => editor.timeline.discardPreview(),
|
||||
},
|
||||
playback: {
|
||||
getIsPlaying: () => editor.playback.getIsPlaying(),
|
||||
subscribe: (listener) => editor.playback.subscribe(listener),
|
||||
},
|
||||
preview: {
|
||||
isMaskMode: () => isMaskMode,
|
||||
onSnapLinesChange,
|
||||
},
|
||||
};
|
||||
|
||||
const commitTextEdit = useCallback(() => {
|
||||
const current = editingTextRef.current;
|
||||
if (!current) return;
|
||||
editingTextRef.current = null;
|
||||
editor.timeline.commitPreview();
|
||||
setEditingText(null);
|
||||
}, [editor.timeline]);
|
||||
const depsRef = useRef<PreviewInteractionDeps>(deps);
|
||||
depsRef.current = deps;
|
||||
|
||||
const controllerRef = useRef<PreviewInteractionController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new PreviewInteractionController({
|
||||
depsRef: depsRef as PreviewInteractionDepsRef,
|
||||
});
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(
|
||||
() => controller.subscribe({ listener: rerender }),
|
||||
[controller],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = editor.playback.subscribe(() => {
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
|
||||
commitTextEdit();
|
||||
}
|
||||
wasPlayingRef.current = isPlaying;
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [editor.playback, commitTextEdit]);
|
||||
if (!controller.isDragging) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller.isDragging, controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState) return;
|
||||
|
||||
editor.timeline.discardPreview();
|
||||
dragStateRef.current = null;
|
||||
pendingGestureRef.current = null;
|
||||
setIsDragging(false);
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(dragState);
|
||||
},
|
||||
});
|
||||
}, [editor.timeline, isDragging, onSnapLinesChange, releaseCapturedPointer]);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
({ clientX, clientY }: React.MouseEvent) => {
|
||||
if (editingText || isMaskMode) return;
|
||||
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const startPos = viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const hit = hitTest({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds,
|
||||
});
|
||||
|
||||
if (!hit || hit.element.type !== "text") return;
|
||||
|
||||
const textElement = hit.element as TextElement;
|
||||
setEditingText({
|
||||
trackId: hit.trackId,
|
||||
elementId: hit.elementId,
|
||||
element: textElement,
|
||||
originalOpacity: textElement.opacity,
|
||||
});
|
||||
},
|
||||
[editor, editingText, isMaskMode, viewport],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
({
|
||||
clientX,
|
||||
clientY,
|
||||
currentTarget,
|
||||
pointerId,
|
||||
button,
|
||||
}: React.PointerEvent) => {
|
||||
if (editingText) return;
|
||||
if (isMaskMode) return;
|
||||
if (button !== 0) return;
|
||||
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const mediaAssets = editor.media.getAssets();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const startPos = viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!startPos) return;
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
|
||||
const hits = getHitElements({
|
||||
canvasX: startPos.x,
|
||||
canvasY: startPos.y,
|
||||
elementsWithBounds,
|
||||
});
|
||||
const selectedElements = editor.selection.getSelectedElements();
|
||||
const topmostHit = hits[0] ?? null;
|
||||
|
||||
pendingGestureRef.current = {
|
||||
startX: startPos.x,
|
||||
startY: startPos.y,
|
||||
pointerId,
|
||||
captureTarget: currentTarget as HTMLElement,
|
||||
topmostHit,
|
||||
selectedHit: resolvePreferredHit({
|
||||
hits,
|
||||
preferredElements: selectedElements,
|
||||
}),
|
||||
selectedElements,
|
||||
};
|
||||
currentTarget.setPointerCapture(pointerId);
|
||||
},
|
||||
[editor, editingText, isMaskMode, viewport],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
({ clientX, clientY }: React.PointerEvent) => {
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
const currentPos = viewport.screenToCanvas({
|
||||
clientX,
|
||||
clientY,
|
||||
});
|
||||
if (!currentPos) return;
|
||||
|
||||
let dragState = dragStateRef.current;
|
||||
|
||||
if (!dragState) {
|
||||
const pendingGesture = pendingGestureRef.current;
|
||||
if (!pendingGesture) return;
|
||||
|
||||
const deltaX = currentPos.x - pendingGesture.startX;
|
||||
const deltaY = currentPos.y - pendingGesture.startY;
|
||||
const hasMovement =
|
||||
Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
|
||||
Math.abs(deltaY) > MIN_DRAG_DISTANCE;
|
||||
|
||||
if (!hasMovement) {
|
||||
onSnapLinesChange?.([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const dragTarget = pendingGesture.selectedHit ?? pendingGesture.topmostHit;
|
||||
if (!dragTarget) {
|
||||
pendingGestureRef.current = null;
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(pendingGesture);
|
||||
return;
|
||||
}
|
||||
|
||||
const dragSelection = buildDragSelection({
|
||||
selectedElements: pendingGesture.selectedElements,
|
||||
dragTarget,
|
||||
});
|
||||
const elementsWithTracks = editor.timeline.getElementsWithTracks({
|
||||
elements: dragSelection,
|
||||
});
|
||||
const draggableElements = elementsWithTracks.filter(({ element }) =>
|
||||
isVisualElement(element),
|
||||
);
|
||||
|
||||
if (draggableElements.length === 0) {
|
||||
pendingGestureRef.current = null;
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(pendingGesture);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingGesture.selectedHit === null) {
|
||||
editor.selection.setSelectedElements({
|
||||
elements: [
|
||||
{
|
||||
trackId: dragTarget.trackId,
|
||||
elementId: dragTarget.elementId,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
dragState = {
|
||||
startX: pendingGesture.startX,
|
||||
startY: pendingGesture.startY,
|
||||
pointerId: pendingGesture.pointerId,
|
||||
captureTarget: pendingGesture.captureTarget,
|
||||
bounds: {
|
||||
width: dragTarget.bounds.width,
|
||||
height: dragTarget.bounds.height,
|
||||
rotation: dragTarget.bounds.rotation,
|
||||
},
|
||||
elements: draggableElements.map(({ track, element }) => ({
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
initialTransform: (element as { transform: Transform }).transform,
|
||||
})),
|
||||
};
|
||||
dragStateRef.current = dragState;
|
||||
pendingGestureRef.current = null;
|
||||
setIsDragging(true);
|
||||
}
|
||||
|
||||
const deltaX = currentPos.x - dragState.startX;
|
||||
const deltaY = currentPos.y - dragState.startY;
|
||||
const firstElement = dragState.elements[0];
|
||||
const proposedPosition = {
|
||||
x: firstElement.initialTransform.position.x + deltaX,
|
||||
y: firstElement.initialTransform.position.y + deltaY,
|
||||
};
|
||||
|
||||
const shouldSnap = !isShiftHeldRef.current;
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedPosition, activeLines } = shouldSnap
|
||||
? snapPosition({
|
||||
proposedPosition,
|
||||
canvasSize,
|
||||
elementSize: dragState.bounds,
|
||||
rotation: dragState.bounds.rotation,
|
||||
snapThreshold,
|
||||
})
|
||||
: {
|
||||
snappedPosition: proposedPosition,
|
||||
activeLines: [] as SnapLine[],
|
||||
};
|
||||
|
||||
onSnapLinesChange?.(activeLines);
|
||||
|
||||
const deltaSnappedX =
|
||||
snappedPosition.x - firstElement.initialTransform.position.x;
|
||||
const deltaSnappedY =
|
||||
snappedPosition.y - firstElement.initialTransform.position.y;
|
||||
|
||||
const updates = dragState.elements.map(
|
||||
({ trackId, elementId, initialTransform }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
position: {
|
||||
x: initialTransform.position.x + deltaSnappedX,
|
||||
y: initialTransform.position.y + deltaSnappedY,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
editor.timeline.previewElements({ updates });
|
||||
},
|
||||
[editor, isShiftHeldRef, onSnapLinesChange, releaseCapturedPointer, viewport],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
({ type }: React.PointerEvent) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (dragState) {
|
||||
if (type === "pointercancel") {
|
||||
editor.timeline.discardPreview();
|
||||
} else {
|
||||
editor.timeline.commitPreview();
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
pendingGestureRef.current = null;
|
||||
setIsDragging(false);
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(dragState);
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingGesture = pendingGestureRef.current;
|
||||
if (!pendingGesture) return;
|
||||
|
||||
if (type !== "pointercancel") {
|
||||
const clickTarget = pendingGesture.topmostHit;
|
||||
if (!clickTarget) {
|
||||
editor.selection.clearSelection();
|
||||
} else {
|
||||
editor.selection.setSelectedElements({
|
||||
elements: [
|
||||
{
|
||||
trackId: clickTarget.trackId,
|
||||
elementId: clickTarget.elementId,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pendingGestureRef.current = null;
|
||||
onSnapLinesChange?.([]);
|
||||
releaseCapturedPointer(pendingGesture);
|
||||
},
|
||||
[editor, onSnapLinesChange, releaseCapturedPointer],
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
onDoubleClick: handleDoubleClick,
|
||||
editingText,
|
||||
commitTextEdit,
|
||||
onPointerDown: controller.onPointerDown,
|
||||
onPointerMove: controller.onPointerMove,
|
||||
onPointerUp: controller.onPointerUp,
|
||||
onDoubleClick: controller.onDoubleClick,
|
||||
editingText: controller.editingText,
|
||||
commitTextEdit: controller.commitTextEdit,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,629 +1,84 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { usePreviewViewport } from "@/preview/components/preview-viewport";
|
||||
import type { OnSnapLinesChange } from "@/preview/hooks/use-preview-interaction";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import {
|
||||
getVisibleElementsWithBounds,
|
||||
type ElementWithBounds,
|
||||
} from "@/preview/element-bounds";
|
||||
import {
|
||||
MIN_SCALE,
|
||||
SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
snapRotation,
|
||||
snapScale,
|
||||
snapScaleAxes,
|
||||
type ScaleEdgePreference,
|
||||
type SnapLine,
|
||||
} from "@/preview/preview-snap";
|
||||
import { isVisualElement } from "@/timeline/element-utils";
|
||||
import {
|
||||
getElementLocalTime,
|
||||
hasKeyframesForPath,
|
||||
resolveTransformAtTime,
|
||||
setChannel,
|
||||
} from "@/animation";
|
||||
import type { Transform } from "@/rendering";
|
||||
import type { ElementAnimations } from "@/animation/types";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
|
||||
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
type Edge = "right" | "left" | "bottom";
|
||||
type HandleType = Corner | Edge | "rotation";
|
||||
|
||||
function getPreferredEdge({ edge }: { edge: Edge }): ScaleEdgePreference {
|
||||
return edge === "right"
|
||||
? { right: true }
|
||||
: edge === "left"
|
||||
? { left: true }
|
||||
: { bottom: true };
|
||||
}
|
||||
|
||||
interface ScaleState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialDistance: number;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
interface RotationState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialAngle: number;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
}
|
||||
|
||||
interface EdgeScaleState {
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
initialTransform: Transform;
|
||||
initialBoundsCx: number;
|
||||
initialBoundsCy: number;
|
||||
baseWidth: number;
|
||||
baseHeight: number;
|
||||
edge: Edge;
|
||||
rotationRad: number;
|
||||
shouldClearScaleAnimation: boolean;
|
||||
animationsWithoutScale: ElementAnimations | undefined;
|
||||
}
|
||||
|
||||
function clampScaleNonZero(scale: number): number {
|
||||
if (Math.abs(scale) < MIN_SCALE) {
|
||||
return scale < 0 ? -MIN_SCALE : MIN_SCALE;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
function getCornerDistance({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rotation: number;
|
||||
};
|
||||
corner: Corner;
|
||||
}): number {
|
||||
const halfWidth = bounds.width / 2;
|
||||
const halfHeight = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight;
|
||||
|
||||
const rotatedX = localX * cos - localY * sin;
|
||||
const rotatedY = localX * sin + localY * cos;
|
||||
return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
|
||||
}
|
||||
import {
|
||||
TransformHandleController,
|
||||
type TransformHandleDeps,
|
||||
} from "@/preview/controllers/transform-handle-controller";
|
||||
|
||||
export function useTransformHandles({
|
||||
onSnapLinesChange,
|
||||
}: {
|
||||
onSnapLinesChange?: OnSnapLinesChange;
|
||||
}) {
|
||||
const viewport = usePreviewViewport();
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const viewport = usePreviewViewport();
|
||||
const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
|
||||
const scaleStateRef = useRef<ScaleState | null>(null);
|
||||
const rotationStateRef = useRef<RotationState | null>(null);
|
||||
const edgeScaleStateRef = useRef<EdgeScaleState | null>(null);
|
||||
const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedElements = useEditor((e) => e.selection.getSelectedElements());
|
||||
const tracks = useEditor(
|
||||
(e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks,
|
||||
);
|
||||
const currentTime = useEditor((e) => e.playback.getCurrentTime());
|
||||
const currentTimeRef = useRef(currentTime);
|
||||
currentTimeRef.current = currentTime;
|
||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||
const canvasSize = useEditor(
|
||||
(e) => e.project.getActive().settings.canvasSize,
|
||||
);
|
||||
const deps: TransformHandleDeps = {
|
||||
viewport,
|
||||
input: {
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
},
|
||||
scene: {
|
||||
getSelectedElements: () => selectedElements,
|
||||
getTracks: () => tracks,
|
||||
getCurrentTime: () => currentTime,
|
||||
getMediaAssets: () => mediaAssets,
|
||||
getCanvasSize: () => canvasSize,
|
||||
},
|
||||
timeline: {
|
||||
previewElements: (updates) =>
|
||||
editor.timeline.previewElements({ updates }),
|
||||
commitPreview: () => editor.timeline.commitPreview(),
|
||||
discardPreview: () => editor.timeline.discardPreview(),
|
||||
},
|
||||
preview: {
|
||||
onSnapLinesChange,
|
||||
},
|
||||
};
|
||||
|
||||
const elementsWithBounds = getVisibleElementsWithBounds({
|
||||
tracks,
|
||||
currentTime,
|
||||
canvasSize,
|
||||
mediaAssets,
|
||||
});
|
||||
const depsRef = useRef<TransformHandleDeps>(deps);
|
||||
depsRef.current = deps;
|
||||
|
||||
const selectedWithBounds: ElementWithBounds | null =
|
||||
selectedElements.length === 1
|
||||
? (elementsWithBounds.find(
|
||||
(entry) =>
|
||||
entry.trackId === selectedElements[0].trackId &&
|
||||
entry.elementId === selectedElements[0].elementId,
|
||||
) ?? null)
|
||||
: null;
|
||||
const controllerRef = useRef<TransformHandleController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new TransformHandleController({ depsRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const hasVisualSelection =
|
||||
selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
|
||||
|
||||
const clearActiveHandleState = useCallback(() => {
|
||||
scaleStateRef.current = null;
|
||||
rotationStateRef.current = null;
|
||||
edgeScaleStateRef.current = null;
|
||||
setActiveHandle(null);
|
||||
onSnapLinesChange?.([]);
|
||||
}, [onSnapLinesChange]);
|
||||
|
||||
const releaseCapturedPointer = useCallback(() => {
|
||||
const capture = captureRef.current;
|
||||
if (!capture) return;
|
||||
|
||||
if (capture.element.hasPointerCapture(capture.pointerId)) {
|
||||
capture.element.releasePointerCapture(capture.pointerId);
|
||||
}
|
||||
|
||||
captureRef.current = null;
|
||||
}, []);
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeHandle) return;
|
||||
if (!controller.isActive) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller, controller.isActive]);
|
||||
|
||||
return registerCanceller({
|
||||
fn: () => {
|
||||
editor.timeline.discardPreview();
|
||||
clearActiveHandleState();
|
||||
releaseCapturedPointer();
|
||||
},
|
||||
});
|
||||
}, [
|
||||
activeHandle,
|
||||
clearActiveHandleState,
|
||||
editor.timeline,
|
||||
releaseCapturedPointer,
|
||||
]);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
const handleCornerPointerDown = useCallback(
|
||||
({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const initialDistance = getCornerDistance({ bounds, corner });
|
||||
const baseWidth = bounds.width / resolvedTransform.scaleX;
|
||||
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
||||
const shouldClearScaleAnimation =
|
||||
hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
}) ||
|
||||
hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
});
|
||||
const animationsWithoutScale = shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: setChannel({
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
channel: undefined,
|
||||
}),
|
||||
propertyPath: "transform.scaleY",
|
||||
channel: undefined,
|
||||
})
|
||||
: element.animations;
|
||||
|
||||
scaleStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: resolvedTransform,
|
||||
initialDistance,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
};
|
||||
setActiveHandle(corner);
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithBounds],
|
||||
);
|
||||
|
||||
const handleRotationPointerDown = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const position = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
const deltaX = position.x - bounds.cx;
|
||||
const deltaY = position.y - bounds.cy;
|
||||
const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
|
||||
rotationStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: resolvedTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
};
|
||||
setActiveHandle("rotation");
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithBounds, viewport],
|
||||
);
|
||||
|
||||
const handleEdgePointerDown = useCallback(
|
||||
({ event, edge }: { event: React.PointerEvent; edge: Edge }) => {
|
||||
if (!selectedWithBounds) return;
|
||||
event.stopPropagation();
|
||||
|
||||
const { bounds, trackId, elementId, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return;
|
||||
|
||||
const localTime = getElementLocalTime({
|
||||
timelineTime: currentTimeRef.current,
|
||||
elementStartTime: element.startTime,
|
||||
elementDuration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const baseWidth = bounds.width / resolvedTransform.scaleX;
|
||||
const baseHeight = bounds.height / resolvedTransform.scaleY;
|
||||
const rotationRad = (bounds.rotation * Math.PI) / 180;
|
||||
|
||||
const propertyPath =
|
||||
edge === "right" || edge === "left"
|
||||
? "transform.scaleX"
|
||||
: "transform.scaleY";
|
||||
const shouldClearScaleAnimation = hasKeyframesForPath({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
});
|
||||
const animationsWithoutScale = shouldClearScaleAnimation
|
||||
? setChannel({
|
||||
animations: element.animations,
|
||||
propertyPath,
|
||||
channel: undefined,
|
||||
})
|
||||
: element.animations;
|
||||
|
||||
edgeScaleStateRef.current = {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform: resolvedTransform,
|
||||
initialBoundsCx: bounds.cx,
|
||||
initialBoundsCy: bounds.cy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
edge,
|
||||
rotationRad,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
};
|
||||
setActiveHandle(edge);
|
||||
const captureTarget = event.currentTarget as HTMLElement;
|
||||
captureTarget.setPointerCapture(event.pointerId);
|
||||
captureRef.current = {
|
||||
element: captureTarget,
|
||||
pointerId: event.pointerId,
|
||||
};
|
||||
},
|
||||
[selectedWithBounds],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
({ event }: { event: React.PointerEvent }) => {
|
||||
if (
|
||||
!scaleStateRef.current &&
|
||||
!rotationStateRef.current &&
|
||||
!edgeScaleStateRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
const position = viewport.screenToCanvas({
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
});
|
||||
if (!position) return;
|
||||
|
||||
if (
|
||||
scaleStateRef.current &&
|
||||
activeHandle &&
|
||||
activeHandle !== "rotation"
|
||||
) {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialDistance,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
} = scaleStateRef.current;
|
||||
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const currentDistance =
|
||||
Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1;
|
||||
const scaleFactor = currentDistance / initialDistance;
|
||||
|
||||
// Use actual element dimensions (base * current scale) so snap
|
||||
// computes the correct edges when scaleX ≠ scaleY
|
||||
const effectiveWidth = baseWidth * initialTransform.scaleX;
|
||||
const effectiveHeight = baseHeight * initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { snappedScale: snappedFactor, activeLines } =
|
||||
isShiftHeldRef.current
|
||||
? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] }
|
||||
: snapScale({
|
||||
proposedScale: scaleFactor,
|
||||
position: initialTransform.position,
|
||||
baseWidth: effectiveWidth,
|
||||
baseHeight: effectiveHeight,
|
||||
rotation: initialTransform.rotate,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
});
|
||||
|
||||
onSnapLinesChange?.(activeLines);
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
scaleX: clampScaleNonZero(
|
||||
initialTransform.scaleX * snappedFactor,
|
||||
),
|
||||
scaleY: clampScaleNonZero(
|
||||
initialTransform.scaleY * snappedFactor,
|
||||
),
|
||||
},
|
||||
...(shouldClearScaleAnimation && {
|
||||
animations: animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
edgeScaleStateRef.current &&
|
||||
(activeHandle === "right" ||
|
||||
activeHandle === "left" ||
|
||||
activeHandle === "bottom")
|
||||
) {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
edge,
|
||||
rotationRad,
|
||||
shouldClearScaleAnimation,
|
||||
animationsWithoutScale,
|
||||
} = edgeScaleStateRef.current;
|
||||
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const xProjection =
|
||||
deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad);
|
||||
const yProjection =
|
||||
-deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad);
|
||||
const projection =
|
||||
edge === "right"
|
||||
? xProjection
|
||||
: edge === "left"
|
||||
? -xProjection
|
||||
: yProjection;
|
||||
|
||||
const baseAxisHalf =
|
||||
edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2;
|
||||
const proposedScale = clampScaleNonZero(projection / baseAxisHalf);
|
||||
|
||||
const proposedScaleX =
|
||||
edge === "right" || edge === "left"
|
||||
? proposedScale
|
||||
: initialTransform.scaleX;
|
||||
const proposedScaleY =
|
||||
edge === "bottom" ? proposedScale : initialTransform.scaleY;
|
||||
|
||||
const snapThreshold = viewport.screenPixelsToLogicalThreshold({
|
||||
screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS,
|
||||
});
|
||||
const { x: xSnap, y: ySnap } = isShiftHeldRef.current
|
||||
? {
|
||||
x: {
|
||||
snappedScale: proposedScaleX,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
y: {
|
||||
snappedScale: proposedScaleY,
|
||||
snapDistance: Infinity,
|
||||
activeLines: [] as SnapLine[],
|
||||
},
|
||||
}
|
||||
: snapScaleAxes({
|
||||
proposedScaleX,
|
||||
proposedScaleY,
|
||||
position: initialTransform.position,
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
rotation: initialTransform.rotate,
|
||||
canvasSize,
|
||||
snapThreshold,
|
||||
preferredEdges: getPreferredEdge({ edge }),
|
||||
});
|
||||
|
||||
const relevantSnap =
|
||||
edge === "right" || edge === "left" ? xSnap : ySnap;
|
||||
onSnapLinesChange?.(relevantSnap.activeLines);
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: {
|
||||
...initialTransform,
|
||||
scaleX:
|
||||
edge === "right" || edge === "left"
|
||||
? xSnap.snappedScale
|
||||
: initialTransform.scaleX,
|
||||
scaleY:
|
||||
edge === "bottom"
|
||||
? ySnap.snappedScale
|
||||
: initialTransform.scaleY,
|
||||
},
|
||||
...(shouldClearScaleAnimation && {
|
||||
animations: animationsWithoutScale,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (rotationStateRef.current && activeHandle === "rotation") {
|
||||
const {
|
||||
trackId,
|
||||
elementId,
|
||||
initialTransform,
|
||||
initialAngle,
|
||||
initialBoundsCx,
|
||||
initialBoundsCy,
|
||||
} = rotationStateRef.current;
|
||||
|
||||
const deltaX = position.x - initialBoundsCx;
|
||||
const deltaY = position.y - initialBoundsCy;
|
||||
const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
|
||||
let deltaAngle = currentAngle - initialAngle;
|
||||
if (deltaAngle > 180) deltaAngle -= 360;
|
||||
if (deltaAngle < -180) deltaAngle += 360;
|
||||
const newRotate = initialTransform.rotate + deltaAngle;
|
||||
const { snappedRotation } = isShiftHeldRef.current
|
||||
? { snappedRotation: newRotate }
|
||||
: snapRotation({ proposedRotation: newRotate });
|
||||
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
updates: {
|
||||
transform: { ...initialTransform, rotate: snappedRotation },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
canvasSize,
|
||||
editor,
|
||||
isShiftHeldRef,
|
||||
onSnapLinesChange,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (
|
||||
scaleStateRef.current ||
|
||||
rotationStateRef.current ||
|
||||
edgeScaleStateRef.current
|
||||
) {
|
||||
editor.timeline.commitPreview();
|
||||
clearActiveHandleState();
|
||||
}
|
||||
releaseCapturedPointer();
|
||||
}, [clearActiveHandleState, editor, releaseCapturedPointer]);
|
||||
const selectedWithBounds = controller.selectedWithBounds;
|
||||
const hasVisualSelection = selectedWithBounds !== null;
|
||||
|
||||
return {
|
||||
selectedWithBounds,
|
||||
hasVisualSelection,
|
||||
activeHandle,
|
||||
handleCornerPointerDown,
|
||||
handleEdgePointerDown,
|
||||
handleRotationPointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
activeHandle: controller.activeHandle,
|
||||
handleCornerPointerDown: controller.onCornerPointerDown,
|
||||
handleEdgePointerDown: controller.onEdgePointerDown,
|
||||
handleRotationPointerDown: controller.onRotationPointerDown,
|
||||
handlePointerMove: controller.onPointerMove,
|
||||
handlePointerUp: controller.onPointerUp,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
} from "mediabunny";
|
||||
|
||||
interface VideoSinkData {
|
||||
input: Input;
|
||||
sink: CanvasSink;
|
||||
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
|
||||
currentFrame: WrappedCanvas | null;
|
||||
|
|
@ -20,6 +21,7 @@ export class VideoCache {
|
|||
private sinks = new Map<string, VideoSinkData>();
|
||||
private initPromises = new Map<string, Promise<void>>();
|
||||
private frameChain = new Map<string, Promise<unknown>>();
|
||||
private seekGenerations = new Map<string, number>();
|
||||
|
||||
async getFrameAt({
|
||||
mediaId,
|
||||
|
|
@ -35,11 +37,20 @@ export class VideoCache {
|
|||
const sinkData = this.sinks.get(mediaId);
|
||||
if (!sinkData) return null;
|
||||
|
||||
const generation = (this.seekGenerations.get(mediaId) ?? 0) + 1;
|
||||
this.seekGenerations.set(mediaId, generation);
|
||||
|
||||
const previous = this.frameChain.get(mediaId) ?? Promise.resolve();
|
||||
const current = previous.then(() =>
|
||||
this.resolveFrame({ sinkData, time }),
|
||||
const current = previous.then(() => {
|
||||
if (this.seekGenerations.get(mediaId) !== generation) {
|
||||
return sinkData.currentFrame ?? null;
|
||||
}
|
||||
return this.resolveFrame({ sinkData, time });
|
||||
});
|
||||
this.frameChain.set(
|
||||
mediaId,
|
||||
current.catch(() => {}),
|
||||
);
|
||||
this.frameChain.set(mediaId, current.catch(() => {}));
|
||||
return current;
|
||||
}
|
||||
|
||||
|
|
@ -172,18 +183,7 @@ export class VideoCache {
|
|||
|
||||
if (frame) {
|
||||
sinkData.currentFrame = frame;
|
||||
|
||||
// Aggressively fetch next frame immediately to fill buffer
|
||||
// This matches the mediaplayer example which fetches 2 frames on start
|
||||
try {
|
||||
const { value: next } = await sinkData.iterator.next();
|
||||
if (next) {
|
||||
sinkData.nextFrame = next;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to pre-fetch next frame on seek:", e);
|
||||
}
|
||||
|
||||
this.startPrefetch({ sinkData });
|
||||
return frame;
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -262,12 +262,12 @@ export class VideoCache {
|
|||
mediaId: string;
|
||||
file: File;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const input = new Input({
|
||||
source: new BlobSource(file),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
const input = new Input({
|
||||
source: new BlobSource(file),
|
||||
formats: ALL_FORMATS,
|
||||
});
|
||||
|
||||
try {
|
||||
const videoTrack = await input.getPrimaryVideoTrack();
|
||||
if (!videoTrack) {
|
||||
throw new Error("No video track found");
|
||||
|
|
@ -284,6 +284,7 @@ export class VideoCache {
|
|||
});
|
||||
|
||||
this.sinks.set(mediaId, {
|
||||
input,
|
||||
sink,
|
||||
iterator: null,
|
||||
currentFrame: null,
|
||||
|
|
@ -293,6 +294,7 @@ export class VideoCache {
|
|||
prefetchPromise: null,
|
||||
});
|
||||
} catch (error) {
|
||||
input.dispose();
|
||||
console.error(`Failed to initialize video sink for ${mediaId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -305,10 +307,13 @@ export class VideoCache {
|
|||
void sinkData.iterator.return();
|
||||
}
|
||||
|
||||
sinkData.input.dispose();
|
||||
this.sinks.delete(mediaId);
|
||||
}
|
||||
|
||||
this.initPromises.delete(mediaId);
|
||||
this.frameChain.delete(mediaId);
|
||||
this.seekGenerations.delete(mediaId);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import type { BookmarkDragState } from "../hooks/use-bookmark-drag";
|
|||
import { DEFAULT_TIMELINE_BOOKMARK_COLOR } from "@/timeline/components/theme";
|
||||
import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "@/timeline/components/layout";
|
||||
import { DEFAULT_FPS } from "@/fps/defaults";
|
||||
import { snappedSeekTime } from "opencut-wasm";
|
||||
import {
|
||||
ArrowTurnBackwardIcon,
|
||||
Delete02Icon,
|
||||
|
|
@ -30,6 +29,7 @@ import {
|
|||
type MediaTime,
|
||||
mediaTimeFromSeconds,
|
||||
mediaTimeToSeconds,
|
||||
snapSeekMediaTime,
|
||||
subMediaTime,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
|
|
@ -51,7 +51,7 @@ function seekToBookmarkTime({
|
|||
const activeProject = editor.project.getActive();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { TIMELINE_DRAG_THRESHOLD_PX } from "@/timeline/components/interaction";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import { getMouseTimeFromClientX } from "@/timeline/drag-utils";
|
||||
import {
|
||||
buildTimelineSnapPoints,
|
||||
|
|
@ -21,7 +20,7 @@ import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
|||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
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 {
|
||||
isDragging: boolean;
|
||||
|
|
@ -117,7 +116,7 @@ export function useBookmarkDrag({
|
|||
maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
|
||||
});
|
||||
return {
|
||||
snappedTime: result.snappedTime as MediaTime,
|
||||
snappedTime: result.snappedTime,
|
||||
snapPoint: result.snapPoint,
|
||||
};
|
||||
},
|
||||
|
|
@ -165,12 +164,10 @@ export function useBookmarkDrag({
|
|||
});
|
||||
const clampedTime =
|
||||
mouseTime > duration ? duration : mouseTime;
|
||||
const frameSnappedTime = (
|
||||
roundToFrame({
|
||||
time: clampedTime,
|
||||
rate: activeProject.settings.fps,
|
||||
}) ?? clampedTime
|
||||
) as MediaTime;
|
||||
const frameSnappedTime = roundFrameTime({
|
||||
time: clampedTime,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const { snappedTime: initialTime } = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: bookmarkTime,
|
||||
|
|
@ -199,10 +196,10 @@ export function useBookmarkDrag({
|
|||
});
|
||||
const clampedTime =
|
||||
mouseTime > duration ? duration : mouseTime;
|
||||
const frameSnappedTime = (
|
||||
roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ??
|
||||
clampedTime
|
||||
) as MediaTime;
|
||||
const frameSnappedTime = roundFrameTime({
|
||||
time: clampedTime,
|
||||
fps: activeProject.settings.fps,
|
||||
});
|
||||
const snapResult = getSnapResult({
|
||||
rawTime: frameSnappedTime,
|
||||
excludeBookmarkTime: dragState.bookmarkTime,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import type { Bookmark } from "@/timeline";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export function getBookmarkSnapPoints({
|
||||
bookmarks,
|
||||
excludeBookmarkTime,
|
||||
}: {
|
||||
bookmarks: Bookmark[];
|
||||
excludeBookmarkTime?: number;
|
||||
excludeBookmarkTime?: MediaTime;
|
||||
}): SnapPoint[] {
|
||||
return bookmarks.flatMap((bookmark) => {
|
||||
if (excludeBookmarkTime != null && bookmark.time === excludeBookmarkTime) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { Bookmark } from "@/timeline";
|
||||
import type { FrameRate } from "opencut-wasm";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import { addMediaTime, type MediaTime } from "@/wasm";
|
||||
import { addMediaTime, roundFrameTime, type MediaTime } from "@/wasm";
|
||||
|
||||
function bookmarkTimeEqual({
|
||||
bookmarkTime,
|
||||
|
|
@ -114,7 +113,7 @@ export function getFrameTime({
|
|||
time: MediaTime;
|
||||
fps: FrameRate;
|
||||
}): MediaTime {
|
||||
return (roundToFrame({ time, rate: fps }) ?? time) as MediaTime;
|
||||
return roundFrameTime({ time, fps });
|
||||
}
|
||||
|
||||
export function getBookmarkAtTime({
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
type ReactNode,
|
||||
} from "react";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
import type { ElementDragState, DropTarget } from "@/timeline";
|
||||
import type { ElementDragView, DropTarget } from "@/timeline";
|
||||
import { TimelineTrackContent } from "./timeline-track";
|
||||
import { TimelinePlayhead } from "./timeline-playhead";
|
||||
import { SelectionBox } from "@/selection/selection-box";
|
||||
|
|
@ -288,20 +288,15 @@ export function Timeline() {
|
|||
isReady: tracks.length > 0,
|
||||
});
|
||||
|
||||
const {
|
||||
dragState,
|
||||
dragDropTarget,
|
||||
handleElementMouseDown,
|
||||
handleElementClick,
|
||||
lastMouseXRef,
|
||||
} = useElementInteraction({
|
||||
const { dragView, handleElementMouseDown, handleElementClick } =
|
||||
useElementInteraction({
|
||||
zoomLevel,
|
||||
timelineRef,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
const isElementDragging = dragView.kind === "dragging";
|
||||
|
||||
const {
|
||||
dragState: bookmarkDragState,
|
||||
|
|
@ -392,10 +387,19 @@ export function Timeline() {
|
|||
contentWidth: dynamicTimelineWidth,
|
||||
});
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: isElementDragging,
|
||||
getMouseClientX: () =>
|
||||
dragView.kind === "dragging" ? dragView.currentMouseX : 0,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: dynamicTimelineWidth,
|
||||
});
|
||||
|
||||
const showSnapIndicator =
|
||||
snappingEnabled &&
|
||||
currentSnapPoint !== null &&
|
||||
(dragState.isDragging || bookmarkDragState.isDragging || isResizing);
|
||||
(isElementDragging || bookmarkDragState.isDragging || isResizing);
|
||||
|
||||
const {
|
||||
handleTracksMouseDown,
|
||||
|
|
@ -458,9 +462,9 @@ export function Timeline() {
|
|||
headerHeight={timelineHeaderHeight}
|
||||
/>
|
||||
<DragLine
|
||||
dropTarget={dragDropTarget}
|
||||
dropTarget={isElementDragging ? dragView.dropTarget : null}
|
||||
tracks={tracks}
|
||||
isVisible={dragState.isDragging}
|
||||
isVisible={isElementDragging}
|
||||
headerHeight={timelineHeaderHeight}
|
||||
/>
|
||||
|
||||
|
|
@ -541,9 +545,7 @@ export function Timeline() {
|
|||
<TimelineTrackRows
|
||||
mainTrackId={mainTrackId}
|
||||
zoomLevel={zoomLevel}
|
||||
dragState={dragState}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
lastMouseXRef={lastMouseXRef}
|
||||
dragView={dragView}
|
||||
onResizeStart={handleResizeStart}
|
||||
onElementMouseDown={handleElementMouseDown}
|
||||
onElementClick={handleElementClick}
|
||||
|
|
@ -719,9 +721,7 @@ function TrackLabelsPanel({
|
|||
function TimelineTrackRows({
|
||||
mainTrackId,
|
||||
zoomLevel,
|
||||
dragState,
|
||||
tracksScrollRef,
|
||||
lastMouseXRef,
|
||||
dragView,
|
||||
onResizeStart,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
|
|
@ -733,9 +733,7 @@ function TimelineTrackRows({
|
|||
}: {
|
||||
mainTrackId: string | null;
|
||||
zoomLevel: number;
|
||||
dragState: ElementDragState;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
lastMouseXRef: React.RefObject<number>;
|
||||
dragView: ElementDragView;
|
||||
onResizeStart: React.ComponentProps<
|
||||
typeof TimelineTrackContent
|
||||
>["onResizeStart"];
|
||||
|
|
@ -777,8 +775,16 @@ function TimelineTrackRows({
|
|||
[tracks, expandedElementIds],
|
||||
);
|
||||
|
||||
const draggingElementIds = useMemo(
|
||||
() =>
|
||||
dragView.kind === "dragging"
|
||||
? dragView.memberTimeOffsets
|
||||
: (null as ReadonlyMap<string, MediaTime> | null),
|
||||
[dragView],
|
||||
);
|
||||
const sortedTracks = useMemo(() => {
|
||||
const draggingElementIds = new Set(dragState.dragElementIds);
|
||||
if (!draggingElementIds)
|
||||
return tracks.map((track, index) => ({ track, index }));
|
||||
return [...tracks]
|
||||
.map((track, index) => ({ track, index }))
|
||||
.sort((a, b) => {
|
||||
|
|
@ -792,7 +798,7 @@ function TimelineTrackRows({
|
|||
if (bHasDragged) return -1;
|
||||
return 0;
|
||||
});
|
||||
}, [tracks, dragState.dragElementIds]);
|
||||
}, [tracks, draggingElementIds]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -812,10 +818,7 @@ function TimelineTrackRows({
|
|||
<TimelineTrackContent
|
||||
track={track}
|
||||
zoomLevel={zoomLevel}
|
||||
dragState={dragState}
|
||||
rulerScrollRef={tracksScrollRef}
|
||||
tracksScrollRef={tracksScrollRef}
|
||||
lastMouseXRef={lastMouseXRef}
|
||||
dragView={dragView}
|
||||
onResizeStart={onResizeStart}
|
||||
onElementMouseDown={onElementMouseDown}
|
||||
onElementClick={onElementClick}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
import type {
|
||||
TimelineElement as TimelineElementType,
|
||||
TimelineTrack,
|
||||
ElementDragState,
|
||||
ElementDragView,
|
||||
VideoElement,
|
||||
ImageElement,
|
||||
AudioElement,
|
||||
|
|
@ -49,12 +49,7 @@ import {
|
|||
import { buildWaveformGainSamples } from "@/timeline/audio-state";
|
||||
import { getTimelinePixelsPerSecond } from "@/timeline";
|
||||
import { buildWaveformSourceKey } from "@/media/waveform-summary";
|
||||
import {
|
||||
addMediaTime,
|
||||
type MediaTime,
|
||||
TICKS_PER_SECOND,
|
||||
ZERO_MEDIA_TIME,
|
||||
} from "@/wasm";
|
||||
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
||||
import {
|
||||
getActionDefinition,
|
||||
type TAction,
|
||||
|
|
@ -219,7 +214,7 @@ interface TimelineElementProps {
|
|||
event: React.MouseEvent,
|
||||
element: TimelineElementType,
|
||||
) => void;
|
||||
dragState: ElementDragState;
|
||||
dragView: ElementDragView;
|
||||
isDropTarget?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +226,7 @@ export function TimelineElement({
|
|||
onResizeStart,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
dragState,
|
||||
dragView,
|
||||
isDropTarget = false,
|
||||
}: TimelineElementProps) {
|
||||
const mediaAssets = useEditor((e) => e.media.getAssets());
|
||||
|
|
@ -257,16 +252,18 @@ export function TimelineElement({
|
|||
selected.elementId === element.id && selected.trackId === track.id,
|
||||
);
|
||||
|
||||
const isBeingDragged = dragState.dragElementIds.includes(element.id);
|
||||
const isDragging = dragView.kind === "dragging";
|
||||
const dragTimeOffset = isDragging
|
||||
? dragView.memberTimeOffsets.get(element.id)
|
||||
: undefined;
|
||||
const isBeingDragged = dragTimeOffset !== undefined;
|
||||
const dragOffsetY =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? dragState.currentMouseY - dragState.startMouseY
|
||||
isDragging && isBeingDragged
|
||||
? dragView.currentMouseY - dragView.startMouseY
|
||||
: 0;
|
||||
const dragTimeOffset =
|
||||
dragState.dragTimeOffsets[element.id] ?? ZERO_MEDIA_TIME;
|
||||
const elementStartTime =
|
||||
isBeingDragged && dragState.isDragging
|
||||
? addMediaTime({ a: dragState.currentTime, b: dragTimeOffset })
|
||||
isDragging && isBeingDragged
|
||||
? addMediaTime({ a: dragView.currentTime, b: dragTimeOffset })
|
||||
: renderElement.startTime;
|
||||
const displayedStartTime = elementStartTime;
|
||||
const displayedDuration = renderElement.duration;
|
||||
|
|
@ -388,7 +385,7 @@ export function TimelineElement({
|
|||
? `${baseTrackHeight + expansionHeight}px`
|
||||
: "100%",
|
||||
transform:
|
||||
isBeingDragged && dragState.isDragging
|
||||
isDragging && isBeingDragged
|
||||
? `translate3d(0, ${dragOffsetY}px, 0)`
|
||||
: undefined,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -5,18 +5,12 @@ import { TimelineElement } from "./timeline-element";
|
|||
import type { TimelineTrack } from "@/timeline";
|
||||
import type { TimelineElement as TimelineElementType } from "@/timeline";
|
||||
import { TIMELINE_LAYERS } from "./layers";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
||||
import type { ElementDragState } from "@/timeline";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import type { ElementDragView } from "@/timeline";
|
||||
|
||||
interface TimelineTrackContentProps {
|
||||
track: TimelineTrack;
|
||||
zoomLevel: number;
|
||||
dragState: ElementDragState;
|
||||
rulerScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: React.RefObject<HTMLDivElement | null>;
|
||||
lastMouseXRef: React.RefObject<number>;
|
||||
dragView: ElementDragView;
|
||||
onResizeStart: (params: {
|
||||
event: React.MouseEvent;
|
||||
element: TimelineElementType;
|
||||
|
|
@ -42,10 +36,7 @@ interface TimelineTrackContentProps {
|
|||
export function TimelineTrackContent({
|
||||
track,
|
||||
zoomLevel,
|
||||
dragState,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
lastMouseXRef,
|
||||
dragView,
|
||||
onResizeStart,
|
||||
onElementMouseDown,
|
||||
onElementClick,
|
||||
|
|
@ -55,15 +46,6 @@ export function TimelineTrackContent({
|
|||
targetElementId = null,
|
||||
}: TimelineTrackContentProps) {
|
||||
const { isElementSelected } = useElementSelection();
|
||||
const duration = useEditor((e) => e.timeline.getTotalDuration());
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: dragState.isDragging,
|
||||
getMouseClientX: () => lastMouseXRef.current ?? 0,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
|
|
@ -120,7 +102,7 @@ export function TimelineTrackContent({
|
|||
onElementClick={(event, element) =>
|
||||
onElementClick({ event, element, track })
|
||||
}
|
||||
dragState={dragState}
|
||||
dragView={dragView}
|
||||
isDropTarget={element.id === targetElementId}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,577 @@
|
|||
import type { DragEvent } from "react";
|
||||
import { processMediaAssets } from "@/media/processing";
|
||||
import { showMediaUploadToast } from "@/media/upload-toast";
|
||||
import {
|
||||
DEFAULT_NEW_ELEMENT_DURATION,
|
||||
toElementDurationTicks,
|
||||
} from "@/timeline/creation";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import 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({
|
||||
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 { SnapPoint } from "@/timeline/snapping";
|
||||
import { addMediaTime } from "@/wasm";
|
||||
|
||||
export function getElementEdgeSnapPoints({
|
||||
tracks,
|
||||
|
|
@ -25,7 +26,7 @@ export function getElementEdgeSnapPoints({
|
|||
trackId: track.id,
|
||||
},
|
||||
{
|
||||
time: element.startTime + element.duration,
|
||||
time: addMediaTime({ a: element.startTime, b: element.duration }),
|
||||
type: "element-end",
|
||||
elementId: element.id,
|
||||
trackId: track.id,
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ export function snapGroupEdges({
|
|||
) {
|
||||
closestSnapDistance = memberStartSnap.snapDistance;
|
||||
snappedAnchorStartTime = subMediaTime({
|
||||
a: memberStartSnap.snappedTime as MediaTime,
|
||||
a: memberStartSnap.snappedTime,
|
||||
b: member.timeOffset,
|
||||
});
|
||||
snapPoint = memberStartSnap.snapPoint;
|
||||
|
|
@ -84,7 +84,7 @@ export function snapGroupEdges({
|
|||
closestSnapDistance = memberEndSnap.snapDistance;
|
||||
snappedAnchorStartTime = subMediaTime({
|
||||
a: subMediaTime({
|
||||
a: memberEndSnap.snappedTime as MediaTime,
|
||||
a: memberEndSnap.snappedTime,
|
||||
b: member.duration,
|
||||
}),
|
||||
b: member.timeOffset,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import { roundToFrame } from "opencut-wasm";
|
||||
import {
|
||||
getSourceSpanAtClipTime,
|
||||
getTimelineDurationForSourceSpan,
|
||||
} 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 {
|
||||
ComputeGroupResizeArgs,
|
||||
GroupResizeMember,
|
||||
|
|
@ -18,31 +29,54 @@ export function computeGroupResize({
|
|||
deltaTime,
|
||||
fps,
|
||||
}: ComputeGroupResizeArgs): GroupResizeResult {
|
||||
const minDuration = Math.round(
|
||||
(TICKS_PER_SECOND * fps.denominator) / fps.numerator,
|
||||
);
|
||||
const minimumDeltaTime = Math.max(
|
||||
...members.map((member) =>
|
||||
getMinimumAllowedDeltaTime({
|
||||
if (members.length === 0) {
|
||||
return { deltaTime: ZERO_MEDIA_TIME, updates: [] };
|
||||
}
|
||||
|
||||
const minDuration = mediaTime({
|
||||
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,
|
||||
side,
|
||||
minDuration,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const maximumDeltaTime = Math.min(
|
||||
...members.map((member) =>
|
||||
getMaximumAllowedDeltaTime({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
const memberMaximum = getMaximumAllowedDeltaTime({
|
||||
member,
|
||||
side,
|
||||
minDuration,
|
||||
});
|
||||
if (memberMaximum !== null) {
|
||||
maximumDeltaTime =
|
||||
maximumDeltaTime === null
|
||||
? memberMaximum
|
||||
: minMediaTime({ a: maximumDeltaTime, b: memberMaximum });
|
||||
}
|
||||
}
|
||||
|
||||
const clampedDeltaTime =
|
||||
minimumDeltaTime > maximumDeltaTime
|
||||
? minimumDeltaTime
|
||||
: Math.min(maximumDeltaTime, Math.max(minimumDeltaTime, deltaTime));
|
||||
maximumDeltaTime === null
|
||||
? maxMediaTime({ a: minimumDeltaTime, b: deltaTime })
|
||||
: clampMediaTime({
|
||||
time: deltaTime,
|
||||
min: minimumDeltaTime,
|
||||
max: maximumDeltaTime,
|
||||
});
|
||||
|
||||
// Snap the drag delta to a frame exactly once, then derive every patch
|
||||
// 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
|
||||
// approach) couldn't preserve this because the individual rounds don't
|
||||
// compose when `sourceDuration` isn't frame-aligned.
|
||||
const snappedDeltaTime =
|
||||
roundToFrame({ time: clampedDeltaTime, rate: fps }) ?? clampedDeltaTime;
|
||||
const snappedDeltaTime = mediaTime({
|
||||
ticks: roundFrameTicks({ ticks: clampedDeltaTime, fps }),
|
||||
});
|
||||
// Re-clamp after rounding. Bounds derived from other elements are
|
||||
// frame-aligned, so this is normally a no-op; at the source-extent limit
|
||||
// the bound may not be frame-aligned, and honouring the bound takes
|
||||
// precedence over frame alignment (you can't extend past real content).
|
||||
const finalDeltaTime =
|
||||
minimumDeltaTime > maximumDeltaTime
|
||||
? minimumDeltaTime
|
||||
: Math.min(
|
||||
maximumDeltaTime,
|
||||
Math.max(minimumDeltaTime, snappedDeltaTime),
|
||||
);
|
||||
maximumDeltaTime === null
|
||||
? maxMediaTime({ a: minimumDeltaTime, b: snappedDeltaTime })
|
||||
: clampMediaTime({
|
||||
time: snappedDeltaTime,
|
||||
min: minimumDeltaTime,
|
||||
max: maximumDeltaTime,
|
||||
});
|
||||
|
||||
return {
|
||||
deltaTime: Object.is(finalDeltaTime, -0) ? 0 : finalDeltaTime,
|
||||
deltaTime: Object.is(finalDeltaTime, -0) ? ZERO_MEDIA_TIME : finalDeltaTime,
|
||||
updates: members.map((member) =>
|
||||
buildResizeUpdate({
|
||||
member,
|
||||
|
|
@ -84,7 +120,7 @@ function buildResizeUpdate({
|
|||
}: {
|
||||
member: GroupResizeMember;
|
||||
side: ResizeSide;
|
||||
deltaTime: number;
|
||||
deltaTime: MediaTime;
|
||||
}): GroupResizeUpdate {
|
||||
const sourceDelta = getSourceDeltaForClipDelta({
|
||||
member,
|
||||
|
|
@ -96,10 +132,13 @@ function buildResizeUpdate({
|
|||
trackId: member.trackId,
|
||||
elementId: member.elementId,
|
||||
patch: {
|
||||
trimStart: Math.max(0, member.trimStart + sourceDelta),
|
||||
trimStart: maxMediaTime({
|
||||
a: ZERO_MEDIA_TIME,
|
||||
b: addMediaTime({ a: member.trimStart, b: sourceDelta }),
|
||||
}),
|
||||
trimEnd: member.trimEnd,
|
||||
startTime: member.startTime + deltaTime,
|
||||
duration: member.duration - deltaTime,
|
||||
startTime: addMediaTime({ a: member.startTime, b: deltaTime }),
|
||||
duration: subMediaTime({ a: member.duration, b: deltaTime }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -109,9 +148,12 @@ function buildResizeUpdate({
|
|||
elementId: member.elementId,
|
||||
patch: {
|
||||
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,
|
||||
duration: member.duration + deltaTime,
|
||||
duration: addMediaTime({ a: member.duration, b: deltaTime }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -123,29 +165,37 @@ function getMinimumAllowedDeltaTime({
|
|||
}: {
|
||||
member: GroupResizeMember;
|
||||
side: ResizeSide;
|
||||
minDuration: number;
|
||||
}): number {
|
||||
minDuration: MediaTime;
|
||||
}): MediaTime {
|
||||
if (side === "right") {
|
||||
return minDuration - member.duration;
|
||||
return subMediaTime({ a: minDuration, b: member.duration });
|
||||
}
|
||||
|
||||
const leftNeighborFloor = Number.isFinite(member.leftNeighborBound)
|
||||
? member.leftNeighborBound - member.startTime
|
||||
: -member.startTime;
|
||||
const leftNeighborFloor =
|
||||
member.leftNeighborBound !== null
|
||||
? subMediaTime({ a: member.leftNeighborBound, b: member.startTime })
|
||||
: subMediaTime({ a: ZERO_MEDIA_TIME, b: member.startTime });
|
||||
if (member.sourceDuration == null) {
|
||||
return leftNeighborFloor;
|
||||
}
|
||||
|
||||
const maximumSourceExtension =
|
||||
getDurationForVisibleSourceSpan({
|
||||
const maximumSourceExtension = subMediaTime({
|
||||
a: getDurationForVisibleSourceSpan({
|
||||
member,
|
||||
sourceSpan:
|
||||
getVisibleSourceSpanForDuration({
|
||||
sourceSpan: addMediaTime({
|
||||
a: getVisibleSourceSpanForDuration({
|
||||
member,
|
||||
duration: member.duration,
|
||||
}) + member.trimStart,
|
||||
}) - member.duration;
|
||||
return Math.max(leftNeighborFloor, -maximumSourceExtension);
|
||||
}),
|
||||
b: member.trimStart,
|
||||
}),
|
||||
}),
|
||||
b: member.duration,
|
||||
});
|
||||
return maxMediaTime({
|
||||
a: leftNeighborFloor,
|
||||
b: subMediaTime({ a: ZERO_MEDIA_TIME, b: maximumSourceExtension }),
|
||||
});
|
||||
}
|
||||
|
||||
function getMaximumAllowedDeltaTime({
|
||||
|
|
@ -155,26 +205,38 @@ function getMaximumAllowedDeltaTime({
|
|||
}: {
|
||||
member: GroupResizeMember;
|
||||
side: ResizeSide;
|
||||
minDuration: number;
|
||||
}): number {
|
||||
minDuration: MediaTime;
|
||||
}): MediaTime | null {
|
||||
if (side === "left") {
|
||||
return member.duration - minDuration;
|
||||
return subMediaTime({ a: member.duration, b: minDuration });
|
||||
}
|
||||
|
||||
const rightNeighborCeiling = Number.isFinite(member.rightNeighborBound)
|
||||
? member.rightNeighborBound - (member.startTime + member.duration)
|
||||
: Infinity;
|
||||
const rightNeighborCeiling =
|
||||
member.rightNeighborBound === null
|
||||
? null
|
||||
: subMediaTime({
|
||||
a: member.rightNeighborBound,
|
||||
b: addMediaTime({ a: member.startTime, b: member.duration }),
|
||||
});
|
||||
if (member.sourceDuration == null) {
|
||||
return rightNeighborCeiling;
|
||||
}
|
||||
|
||||
const maximumVisibleSourceSpan =
|
||||
getSourceDuration({ member }) - member.trimStart;
|
||||
const maximumVisibleSourceSpan = subMediaTime({
|
||||
a: getSourceDuration({ member }),
|
||||
b: member.trimStart,
|
||||
});
|
||||
const maximumDuration = getDurationForVisibleSourceSpan({
|
||||
member,
|
||||
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({
|
||||
|
|
@ -182,8 +244,8 @@ function getSourceDeltaForClipDelta({
|
|||
clipDelta,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
clipDelta: number;
|
||||
}): number {
|
||||
clipDelta: MediaTime;
|
||||
}): MediaTime {
|
||||
if (!member.retime) {
|
||||
return clipDelta;
|
||||
}
|
||||
|
|
@ -206,15 +268,17 @@ function getVisibleSourceSpanForDuration({
|
|||
duration,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
duration: number;
|
||||
}): number {
|
||||
duration: MediaTime;
|
||||
}): MediaTime {
|
||||
if (!member.retime) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
return getSourceSpanAtClipTime({
|
||||
clipTime: duration,
|
||||
retime: member.retime,
|
||||
return roundMediaTime({
|
||||
time: getSourceSpanAtClipTime({
|
||||
clipTime: duration,
|
||||
retime: member.retime,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -223,8 +287,8 @@ function getDurationForVisibleSourceSpan({
|
|||
sourceSpan,
|
||||
}: {
|
||||
member: GroupResizeMember;
|
||||
sourceSpan: number;
|
||||
}): number {
|
||||
sourceSpan: MediaTime;
|
||||
}): MediaTime {
|
||||
if (!member.retime) {
|
||||
return sourceSpan;
|
||||
}
|
||||
|
|
@ -237,17 +301,19 @@ function getDurationForVisibleSourceSpan({
|
|||
});
|
||||
}
|
||||
|
||||
function getSourceDuration({ member }: { member: GroupResizeMember }): number {
|
||||
if (typeof member.sourceDuration === "number") {
|
||||
function getSourceDuration({ member }: { member: GroupResizeMember }): MediaTime {
|
||||
if (member.sourceDuration != null) {
|
||||
return member.sourceDuration;
|
||||
}
|
||||
|
||||
return (
|
||||
member.trimStart +
|
||||
getVisibleSourceSpanForDuration({
|
||||
return addMediaTime({
|
||||
a: addMediaTime({
|
||||
a: member.trimStart,
|
||||
b: getVisibleSourceSpanForDuration({
|
||||
member,
|
||||
duration: member.duration,
|
||||
}) +
|
||||
member.trimEnd
|
||||
);
|
||||
}),
|
||||
}),
|
||||
b: member.trimEnd,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,37 @@
|
|||
import type { FrameRate } from "opencut-wasm";
|
||||
import type { ElementRef, RetimeConfig } from "@/timeline/types";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export type ResizeSide = "left" | "right";
|
||||
|
||||
export interface GroupResizeMember extends ElementRef {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
sourceDuration?: number;
|
||||
startTime: MediaTime;
|
||||
duration: MediaTime;
|
||||
trimStart: MediaTime;
|
||||
trimEnd: MediaTime;
|
||||
sourceDuration?: MediaTime;
|
||||
retime?: RetimeConfig;
|
||||
leftNeighborBound: number;
|
||||
rightNeighborBound: number;
|
||||
leftNeighborBound: MediaTime | null;
|
||||
rightNeighborBound: MediaTime | null;
|
||||
}
|
||||
|
||||
export interface GroupResizeUpdate extends ElementRef {
|
||||
patch: {
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: MediaTime;
|
||||
trimEnd: MediaTime;
|
||||
startTime: MediaTime;
|
||||
duration: MediaTime;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GroupResizeResult {
|
||||
deltaTime: number;
|
||||
deltaTime: MediaTime;
|
||||
updates: GroupResizeUpdate[];
|
||||
}
|
||||
|
||||
export interface ComputeGroupResizeArgs {
|
||||
members: GroupResizeMember[];
|
||||
side: ResizeSide;
|
||||
deltaTime: number;
|
||||
deltaTime: MediaTime;
|
||||
fps: FrameRate;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,17 @@
|
|||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useEffect, useReducer, useRef, type RefObject } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import {
|
||||
buildMoveGroup,
|
||||
resolveGroupMove,
|
||||
snapGroupEdges,
|
||||
type GroupMoveResult,
|
||||
type MoveGroup,
|
||||
} from "@/timeline/group-move";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import {
|
||||
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 type {
|
||||
DropTarget,
|
||||
ElementRef,
|
||||
ElementDragState,
|
||||
SceneTracks,
|
||||
TimelineElement,
|
||||
TimelineTrack,
|
||||
} from "@/timeline";
|
||||
import {
|
||||
ElementInteractionController,
|
||||
type ElementInteractionDeps,
|
||||
type ElementInteractionDepsRef,
|
||||
} from "@/timeline/controllers/element-interaction-controller";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
|
||||
interface UseElementInteractionProps {
|
||||
zoomLevel: number;
|
||||
timelineRef: RefObject<HTMLDivElement | null>;
|
||||
tracksContainerRef: RefObject<HTMLDivElement | null>;
|
||||
tracksScrollRef: RefObject<HTMLDivElement | null>;
|
||||
headerRef?: RefObject<HTMLElement | null>;
|
||||
|
|
@ -51,133 +19,8 @@ interface UseElementInteractionProps {
|
|||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
const MOUSE_BUTTON_RIGHT = 2;
|
||||
|
||||
const initialDragState: ElementDragState = {
|
||||
isDragging: false,
|
||||
elementId: null,
|
||||
dragElementIds: [],
|
||||
dragTimeOffsets: {},
|
||||
trackId: null,
|
||||
startMouseX: 0,
|
||||
startMouseY: 0,
|
||||
startElementTime: 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({
|
||||
zoomLevel,
|
||||
timelineRef,
|
||||
tracksContainerRef,
|
||||
tracksScrollRef,
|
||||
headerRef,
|
||||
|
|
@ -186,597 +29,65 @@ export function useElementInteraction({
|
|||
}: UseElementInteractionProps) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const sceneTracks = editor.scenes.getActiveScene().tracks;
|
||||
const {
|
||||
selectedElements,
|
||||
isElementSelected,
|
||||
selectElement,
|
||||
handleElementClick: handleSelectionClick,
|
||||
} = useElementSelection();
|
||||
const selection = useElementSelection();
|
||||
|
||||
const [dragState, setDragState] =
|
||||
useState<ElementDragState>(initialDragState);
|
||||
const [dragDropTarget, setDragDropTarget] = useState<DropTarget | null>(null);
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
const pendingDragRef = useRef<PendingDragState | null>(null);
|
||||
const moveGroupRef = useRef<MoveGroup | null>(null);
|
||||
const newTrackIdsRef = useRef<string[]>([]);
|
||||
const groupMoveResultRef = useRef<GroupMoveResult | null>(null);
|
||||
const lastMouseXRef = useRef(0);
|
||||
const mouseDownLocationRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const startDrag = useCallback(
|
||||
({
|
||||
elementId,
|
||||
dragElementIds,
|
||||
dragTimeOffsets,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startMouseY,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
initialCurrentTime,
|
||||
initialCurrentMouseY,
|
||||
}: StartDragParams) => {
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
elementId,
|
||||
dragElementIds,
|
||||
dragTimeOffsets,
|
||||
trackId,
|
||||
startMouseX,
|
||||
startMouseY,
|
||||
startElementTime,
|
||||
clickOffsetTime,
|
||||
currentTime: initialCurrentTime,
|
||||
currentMouseY: initialCurrentMouseY,
|
||||
});
|
||||
const deps: ElementInteractionDeps = {
|
||||
viewport: {
|
||||
getZoomLevel: () => zoomLevel,
|
||||
getTracksScrollEl: () => tracksScrollRef.current,
|
||||
getTracksContainerEl: () => tracksContainerRef.current,
|
||||
getHeaderEl: () => headerRef?.current ?? null,
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
moveGroupRef.current = null;
|
||||
newTrackIdsRef.current = [];
|
||||
groupMoveResultRef.current = null;
|
||||
setDragState(initialDragState);
|
||||
setDragDropTarget(null);
|
||||
}, []);
|
||||
|
||||
const cancelCurrentDrag = useCallback(() => {
|
||||
pendingDragRef.current = null;
|
||||
mouseDownLocationRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
endDrag();
|
||||
onSnapPointChange?.(null);
|
||||
}, [endDrag, onSnapPointChange]);
|
||||
|
||||
const resolveGroupDragMove = useCallback(
|
||||
({
|
||||
group,
|
||||
snappedTime,
|
||||
dropTarget,
|
||||
}: {
|
||||
group: MoveGroup;
|
||||
snappedTime: 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,
|
||||
},
|
||||
});
|
||||
input: {
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
},
|
||||
[sceneTracks],
|
||||
);
|
||||
scene: {
|
||||
getTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getActiveFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
},
|
||||
selection: {
|
||||
getSelected: () => selection.selectedElements,
|
||||
isSelected: selection.isElementSelected,
|
||||
select: selection.selectElement,
|
||||
handleClick: selection.handleElementClick,
|
||||
clearKeyframeSelection: () => editor.selection.clearKeyframeSelection(),
|
||||
},
|
||||
playback: {
|
||||
getCurrentTime: () => editor.playback.getCurrentTime(),
|
||||
},
|
||||
timeline: {
|
||||
moveElements: (args) => editor.timeline.moveElements(args),
|
||||
},
|
||||
snap: {
|
||||
isEnabled: () => snappingEnabled,
|
||||
onChange: onSnapPointChange,
|
||||
},
|
||||
};
|
||||
|
||||
const depsRef = useRef<ElementInteractionDeps>(deps);
|
||||
depsRef.current = deps;
|
||||
|
||||
const controllerRef = useRef<ElementInteractionController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new ElementInteractionController({
|
||||
depsRef: depsRef as ElementInteractionDepsRef,
|
||||
});
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState.isDragging && !isPendingDrag) return;
|
||||
if (!controller.isActive) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller.isActive, controller]);
|
||||
|
||||
return registerCanceller({ fn: cancelCurrentDrag });
|
||||
}, [dragState.isDragging, isPendingDrag, cancelCurrentDrag]);
|
||||
|
||||
const getDragSnapResult = useCallback(
|
||||
({
|
||||
frameSnappedTime,
|
||||
group,
|
||||
}: {
|
||||
frameSnappedTime: 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],
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
dragState,
|
||||
dragDropTarget,
|
||||
handleElementMouseDown,
|
||||
handleElementClick,
|
||||
lastMouseXRef,
|
||||
dragView: controller.view,
|
||||
handleElementMouseDown: controller.onElementMouseDown,
|
||||
handleElementClick: controller.onElementClick,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,48 +1,16 @@
|
|||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from "react";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { getKeyframeById } from "@/animation";
|
||||
import { useKeyframeSelection } from "./use-keyframe-selection";
|
||||
import { roundToFrame, snappedSeekTime } from "opencut-wasm";
|
||||
import { timelineTimeToSnappedPixels } from "@/timeline";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import {
|
||||
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";
|
||||
export interface KeyframeDragState {
|
||||
isDragging: boolean;
|
||||
draggingKeyframeIds: Set<string>;
|
||||
deltaTime: MediaTime;
|
||||
}
|
||||
import {
|
||||
KeyframeDragController,
|
||||
type KeyframeDragConfig,
|
||||
type KeyframeDragState,
|
||||
} from "@/timeline/controllers/keyframe-drag-controller";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
const initialDragState: KeyframeDragState = {
|
||||
isDragging: false,
|
||||
draggingKeyframeIds: new Set(),
|
||||
deltaTime: ZERO_MEDIA_TIME,
|
||||
};
|
||||
|
||||
interface PendingKeyframeDrag {
|
||||
keyframeRefs: SelectedKeyframeRef[];
|
||||
startMouseX: number;
|
||||
}
|
||||
export type { KeyframeDragState };
|
||||
|
||||
export function useKeyframeDrag({
|
||||
zoomLevel,
|
||||
|
|
@ -62,286 +30,44 @@ export function useKeyframeDrag({
|
|||
selectKeyframeRange,
|
||||
} = useKeyframeSelection();
|
||||
|
||||
const [dragState, setDragState] =
|
||||
useState<KeyframeDragState>(initialDragState);
|
||||
const [isPendingDrag, setIsPendingDrag] = useState(false);
|
||||
|
||||
const pendingDragRef = useRef<PendingKeyframeDrag | null>(null);
|
||||
const mouseDownXRef = useRef<number | null>(null);
|
||||
|
||||
const activeProject = editor.project.getActive();
|
||||
const fps = activeProject.settings.fps;
|
||||
|
||||
const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel;
|
||||
|
||||
const endDrag = useCallback(() => {
|
||||
setDragState(initialDragState);
|
||||
}, []);
|
||||
|
||||
const cancelDrag = useCallback(() => {
|
||||
pendingDragRef.current = null;
|
||||
mouseDownXRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
endDrag();
|
||||
}, [endDrag]);
|
||||
|
||||
const commitDrag = useCallback(
|
||||
({
|
||||
keyframeRefs,
|
||||
deltaTime,
|
||||
}: {
|
||||
keyframeRefs: SelectedKeyframeRef[];
|
||||
deltaTime: 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,
|
||||
const config: KeyframeDragConfig = {
|
||||
zoomLevel,
|
||||
element,
|
||||
displayedStartTime,
|
||||
getFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
selectedKeyframes,
|
||||
element.id,
|
||||
commitDrag,
|
||||
endDrag,
|
||||
]);
|
||||
isKeyframeSelected,
|
||||
setKeyframeSelection,
|
||||
toggleKeyframeSelection,
|
||||
selectKeyframeRange,
|
||||
executeCommand: (command) => editor.command.execute({ command }),
|
||||
seek: ({ 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(() => {
|
||||
if (!isPendingDrag) return;
|
||||
if (!controller.isActive) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller.isActive, controller]);
|
||||
|
||||
const handleMouseUp = () => {
|
||||
pendingDragRef.current = null;
|
||||
setIsPendingDrag(false);
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => document.removeEventListener("mouseup", handleMouseUp);
|
||||
}, [isPendingDrag]);
|
||||
|
||||
const handleKeyframeMouseDown = useCallback(
|
||||
({
|
||||
event,
|
||||
keyframes,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
}) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
mouseDownXRef.current = event.clientX;
|
||||
|
||||
const anySelected = keyframes.some((keyframe) =>
|
||||
isKeyframeSelected({ keyframe }),
|
||||
);
|
||||
|
||||
const isModifierKey = event.shiftKey || event.metaKey || event.ctrlKey;
|
||||
if (!anySelected && !isModifierKey) {
|
||||
setKeyframeSelection({ keyframes });
|
||||
}
|
||||
|
||||
const keyframeRefsToTrack = anySelected ? selectedKeyframes : keyframes;
|
||||
|
||||
pendingDragRef.current = {
|
||||
keyframeRefs: keyframeRefsToTrack,
|
||||
startMouseX: event.clientX,
|
||||
};
|
||||
setIsPendingDrag(true);
|
||||
},
|
||||
[isKeyframeSelected, selectedKeyframes, setKeyframeSelection],
|
||||
);
|
||||
|
||||
const handleKeyframeClick = useCallback(
|
||||
({
|
||||
event,
|
||||
keyframes,
|
||||
orderedKeyframes,
|
||||
indicatorTime,
|
||||
}: {
|
||||
event: ReactMouseEvent;
|
||||
keyframes: SelectedKeyframeRef[];
|
||||
orderedKeyframes: SelectedKeyframeRef[];
|
||||
indicatorTime: 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],
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
keyframeDragState: dragState,
|
||||
handleKeyframeMouseDown,
|
||||
handleKeyframeClick,
|
||||
getVisualOffsetPx,
|
||||
keyframeDragState: controller.keyframeDragState,
|
||||
handleKeyframeMouseDown: controller.onKeyframeMouseDown,
|
||||
handleKeyframeClick: controller.onKeyframeClick,
|
||||
getVisualOffsetPx: controller.getVisualOffsetPx,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +1,9 @@
|
|||
import { useState, useCallback, type RefObject } from "react";
|
||||
import { useEffect, useReducer, useRef, type RefObject } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { processMediaAssets } from "@/media/processing";
|
||||
import { toast } from "sonner";
|
||||
import { showMediaUploadToast } from "@/media/upload-toast";
|
||||
import { DEFAULT_NEW_ELEMENT_DURATION } from "@/timeline/creation";
|
||||
import { mediaTimeFromSeconds, type MediaTime } from "@/wasm";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { roundToFrame } from "opencut-wasm";
|
||||
import {
|
||||
buildTextElement,
|
||||
buildGraphicElement,
|
||||
buildStickerElement,
|
||||
buildElementFromMedia,
|
||||
buildEffectElement,
|
||||
} from "@/timeline/element-utils";
|
||||
import { AddTrackCommand, InsertElementCommand } from "@/commands/timeline";
|
||||
import { BatchCommand } from "@/commands";
|
||||
import { computeDropTarget } from "@/timeline/components/drop-target";
|
||||
import { getDragData, hasDragData } from "@/timeline/drag-data";
|
||||
import type { TrackType, DropTarget, ElementType } from "@/timeline";
|
||||
import type {
|
||||
MediaDragData,
|
||||
GraphicDragData,
|
||||
StickerDragData,
|
||||
EffectDragData,
|
||||
} from "@/timeline/drag";
|
||||
DragDropController,
|
||||
type DragDropConfig,
|
||||
} from "@/timeline/controllers/drag-drop-controller";
|
||||
|
||||
interface UseTimelineDragDropProps {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
|
|
@ -40,609 +19,47 @@ export function useTimelineDragDrop({
|
|||
zoomLevel,
|
||||
}: UseTimelineDragDropProps) {
|
||||
const editor = useEditor();
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [dropTarget, setDropTarget] = useState<DropTarget | null>(null);
|
||||
const [dragElementType, setElementType] = useState<ElementType | null>(null);
|
||||
|
||||
const getSnappedTime = useCallback(
|
||||
({ time }: { time: MediaTime }) => {
|
||||
const projectFps = editor.project.getActive().settings.fps;
|
||||
return (roundToFrame({ time, rate: projectFps }) ?? time) as MediaTime;
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
const config: DragDropConfig = {
|
||||
zoomLevel,
|
||||
getContainerEl: () => containerRef.current,
|
||||
getHeaderEl: () => headerRef?.current ?? null,
|
||||
getTracksScrollEl: () => tracksScrollRef?.current ?? null,
|
||||
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
getActiveProjectId: () =>
|
||||
editor.project.getActiveOrNull()?.metadata.id ?? null,
|
||||
getSceneTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
|
||||
getMediaAssets: () => editor.media.getAssets(),
|
||||
dragSource: editor.timeline.dragSource,
|
||||
addMediaAsset: (args) => editor.media.addMediaAsset(args),
|
||||
executeCommand: (command) => editor.command.execute({ command }),
|
||||
insertElement: (args) => editor.timeline.insertElement(args),
|
||||
addClipEffect: (args) => editor.timeline.addClipEffect(args),
|
||||
};
|
||||
|
||||
const getElementType = useCallback(
|
||||
({ dataTransfer }: { dataTransfer: DataTransfer }): ElementType | null => {
|
||||
const dragData = getDragData({ dataTransfer });
|
||||
if (!dragData) return null;
|
||||
const configRef = useRef<DragDropConfig>(config);
|
||||
configRef.current = config;
|
||||
|
||||
if (dragData.type === "text") return "text";
|
||||
if (dragData.type === "graphic") return "graphic";
|
||||
if (dragData.type === "sticker") return "sticker";
|
||||
if (dragData.type === "effect") return "effect";
|
||||
if (dragData.type === "media") {
|
||||
return dragData.mediaType;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const controllerRef = useRef<DragDropController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new DragDropController({ configRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const getElementDuration = useCallback(
|
||||
({
|
||||
elementType,
|
||||
mediaId,
|
||||
}: {
|
||||
elementType: ElementType;
|
||||
mediaId?: string;
|
||||
}): 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,
|
||||
],
|
||||
);
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
isDragOver,
|
||||
dropTarget,
|
||||
dragElementType,
|
||||
isDragOver: controller.isDragOver,
|
||||
dropTarget: controller.dropTarget,
|
||||
dragElementType: controller.dragElementType,
|
||||
dragProps: {
|
||||
onDragEnter: handleDragEnter,
|
||||
onDragOver: handleDragOver,
|
||||
onDragLeave: handleDragLeave,
|
||||
onDrop: handleDrop,
|
||||
onDragEnter: controller.onDragEnter,
|
||||
onDragOver: controller.onDragOver,
|
||||
onDragLeave: controller.onDragLeave,
|
||||
onDrop: controller.onDrop,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,13 @@
|
|||
import { snappedSeekTime } from "opencut-wasm";
|
||||
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 { useEffect, useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useEdgeAutoScroll } from "@/timeline/hooks/use-edge-auto-scroll";
|
||||
import { timelineTimeToPixels } from "@/timeline";
|
||||
import {
|
||||
buildTimelineSnapPoints,
|
||||
getTimelineSnapThresholdInTicks,
|
||||
resolveTimelineSnap,
|
||||
} from "@/timeline/snapping";
|
||||
import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import {
|
||||
getCenteredLineLeft,
|
||||
timelineTimeToPixels,
|
||||
timelineTimeToSnappedPixels,
|
||||
} from "@/timeline";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
PlayheadController,
|
||||
type PlayheadConfig,
|
||||
} from "@/timeline/controllers/playhead-controller";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
interface UseTimelinePlayheadProps {
|
||||
zoomLevel: number;
|
||||
|
|
@ -35,272 +25,81 @@ export function useTimelinePlayhead({
|
|||
playheadRef,
|
||||
}: UseTimelinePlayheadProps) {
|
||||
const editor = useEditor();
|
||||
const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
|
||||
const activeProject = editor.project.getActive();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
// isScrubbing drives useEdgeAutoScroll — the controller sets it on the editor,
|
||||
// so this reactive read naturally reflects whether scrubbing is active.
|
||||
const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
|
||||
|
||||
const zoomLevelRef = useRef(zoomLevel);
|
||||
const durationRef = useRef(duration);
|
||||
const isScrubbingRef = useRef(isScrubbing);
|
||||
const isPlayingRef = useRef(false);
|
||||
const config: PlayheadConfig = {
|
||||
zoomLevel,
|
||||
duration: editor.timeline.getTotalDuration(),
|
||||
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
getIsPlaying: () => editor.playback.getIsPlaying(),
|
||||
getRulerEl: () => rulerRef.current,
|
||||
getRulerScrollEl: () => rulerScrollRef.current,
|
||||
getTracksScrollEl: () => tracksScrollRef.current,
|
||||
getPlayheadEl: () => playheadRef?.current ?? null,
|
||||
getSceneTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getSceneBookmarks: () => editor.scenes.getActiveScene()?.bookmarks ?? [],
|
||||
seek: (time) => editor.playback.seek({ time }),
|
||||
setScrubbing: (scrubbing) =>
|
||||
editor.playback.setScrubbing({ isScrubbing: scrubbing }),
|
||||
setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
playheadTime,
|
||||
},
|
||||
}),
|
||||
};
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
useEffect(() => {
|
||||
zoomLevelRef.current = zoomLevel;
|
||||
durationRef.current = duration;
|
||||
isScrubbingRef.current = isScrubbing;
|
||||
isPlayingRef.current = editor.playback.getIsPlaying();
|
||||
}, [zoomLevel, duration, isScrubbing, editor.playback]);
|
||||
|
||||
const seek = useCallback(
|
||||
({ time }: { time: 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],
|
||||
);
|
||||
const ctrlRef = useRef<PlayheadController | null>(null);
|
||||
if (!ctrlRef.current) {
|
||||
ctrlRef.current = new PlayheadController({ configRef });
|
||||
}
|
||||
const ctrl = ctrlRef.current;
|
||||
|
||||
// Scroll → keep playhead position in sync with scroll offset.
|
||||
useEffect(() => {
|
||||
const scrollEl = rulerScrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
const handler = () =>
|
||||
ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
|
||||
scrollEl.addEventListener("scroll", handler, { passive: true });
|
||||
return () => scrollEl.removeEventListener("scroll", handler);
|
||||
}, [ctrl, editor.playback, rulerScrollRef]);
|
||||
|
||||
const handleScroll = () => {
|
||||
updatePlayheadLeft(editor.playback.getCurrentTime());
|
||||
};
|
||||
|
||||
scrollEl.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => scrollEl.removeEventListener("scroll", handleScroll);
|
||||
}, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
|
||||
|
||||
// Playback events → update playhead position and auto-scroll during playback.
|
||||
useEffect(() => {
|
||||
const handlePlaybackTime = (time: MediaTime) => {
|
||||
updatePlayheadLeft(time);
|
||||
|
||||
if (!isPlayingRef.current || isScrubbingRef.current) return;
|
||||
const rulerViewport = rulerScrollRef.current;
|
||||
const tracksViewport = tracksScrollRef.current;
|
||||
if (!rulerViewport || !tracksViewport) return;
|
||||
|
||||
const playheadPixels = timelineTimeToPixels({
|
||||
time,
|
||||
zoomLevel: zoomLevelRef.current,
|
||||
});
|
||||
const viewportWidth = rulerViewport.clientWidth;
|
||||
const scrollMinimum = 0;
|
||||
const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
|
||||
|
||||
const needsScroll =
|
||||
playheadPixels < rulerViewport.scrollLeft ||
|
||||
playheadPixels > rulerViewport.scrollLeft + viewportWidth;
|
||||
|
||||
if (needsScroll) {
|
||||
const desiredScroll = Math.max(
|
||||
scrollMinimum,
|
||||
Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
|
||||
);
|
||||
rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
|
||||
}
|
||||
};
|
||||
const 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);
|
||||
const handler = (time: MediaTime) => ctrl.handlePlaybackUpdate(time);
|
||||
ctrl.updatePlayheadLeft(editor.playback.getCurrentTime());
|
||||
const unsubscribeUpdate = editor.playback.onUpdate(handler);
|
||||
const unsubscribeSeek = editor.playback.onSeek(handler);
|
||||
return () => {
|
||||
window.removeEventListener("playback-update", handlePlaybackUpdate);
|
||||
window.removeEventListener("playback-seek", handlePlaybackUpdate);
|
||||
unsubscribeUpdate();
|
||||
unsubscribeSeek();
|
||||
};
|
||||
}, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
|
||||
}, [ctrl, editor.playback]);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: isScrubbing,
|
||||
getMouseClientX: () => ctrl.getLastMouseClientX(),
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: timelineTimeToPixels({
|
||||
time: editor.timeline.getTotalDuration(),
|
||||
zoomLevel,
|
||||
}),
|
||||
});
|
||||
|
||||
useEffect(() => () => ctrl.destroy(), [ctrl]);
|
||||
|
||||
return {
|
||||
handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
|
||||
handleRulerMouseDown: handleRulerMouseDownEvent,
|
||||
handlePlayheadMouseDown: ctrl.onPlayheadMouseDown,
|
||||
handleRulerMouseDown: ctrl.onRulerMouseDown,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,332 +1,82 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { EditorCore } from "@/core";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { TICKS_PER_SECOND } from "@/wasm";
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { useShiftKey } from "@/hooks/use-shift-key";
|
||||
import { useElementSelection } from "@/timeline/hooks/element/use-element-selection";
|
||||
import { useTimelineStore } from "@/timeline/timeline-store";
|
||||
import {
|
||||
computeGroupResize,
|
||||
type GroupResizeMember,
|
||||
type GroupResizeResult,
|
||||
type ResizeSide,
|
||||
} from "@/timeline/group-resize";
|
||||
import {
|
||||
buildTimelineSnapPoints,
|
||||
getTimelineSnapThresholdInTicks,
|
||||
resolveTimelineSnap,
|
||||
type SnapPoint,
|
||||
} from "@/timeline/snapping";
|
||||
import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
|
||||
import { getPlayheadSnapPoints } from "@/timeline/playhead-snap-source";
|
||||
import type { SceneTracks, TimelineElement, TimelineTrack } from "@/timeline";
|
||||
import { isRetimableElement } from "@/timeline";
|
||||
import { registerCanceller } from "@/editor/cancel-interaction";
|
||||
import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
|
||||
import {
|
||||
ResizeController,
|
||||
type ResizeConfig,
|
||||
} from "@/timeline/controllers/resize-controller";
|
||||
import type { ResizeSide } from "@/timeline/group-resize";
|
||||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import type { TimelineElement } from "@/timeline";
|
||||
|
||||
interface ResizeInteractionState {
|
||||
side: ResizeSide;
|
||||
startX: number;
|
||||
members: GroupResizeMember[];
|
||||
export type { ResizeSide };
|
||||
|
||||
interface UseTimelineResizeProps {
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}
|
||||
|
||||
export function useTimelineResize({
|
||||
zoomLevel,
|
||||
onSnapPointChange,
|
||||
}: {
|
||||
zoomLevel: number;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}) {
|
||||
}: UseTimelineResizeProps) {
|
||||
const editor = useEditor();
|
||||
const isShiftHeldRef = useShiftKey();
|
||||
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
|
||||
const { selectedElements } = useElementSelection();
|
||||
const [resizeState, setResizeState] = useState<ResizeInteractionState | null>(
|
||||
null,
|
||||
);
|
||||
const latestResultRef = useRef<GroupResizeResult | null>(null);
|
||||
|
||||
const cancelResize = useCallback(() => {
|
||||
editor.timeline.discardPreview();
|
||||
setResizeState(null);
|
||||
latestResultRef.current = null;
|
||||
onSnapPointChange?.(null);
|
||||
}, [editor.timeline, onSnapPointChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
return registerCanceller({ fn: cancelResize });
|
||||
}, [resizeState, cancelResize]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
({
|
||||
event,
|
||||
element,
|
||||
track,
|
||||
side,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
element: TimelineElement;
|
||||
track: TimelineTrack;
|
||||
side: ResizeSide;
|
||||
}) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const elementRef = {
|
||||
trackId: track.id,
|
||||
elementId: element.id,
|
||||
};
|
||||
const activeSelection = selectedElements.some(
|
||||
(selectedElement) =>
|
||||
selectedElement.trackId === track.id &&
|
||||
selectedElement.elementId === element.id,
|
||||
)
|
||||
? selectedElements
|
||||
: [elementRef];
|
||||
const members = buildResizeMembers({
|
||||
tracks: editor.scenes.getActiveScene().tracks,
|
||||
selectedElements: activeSelection,
|
||||
});
|
||||
if (members.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.timeline.discardPreview();
|
||||
latestResultRef.current = null;
|
||||
setResizeState({
|
||||
side,
|
||||
startX: event.clientX,
|
||||
members,
|
||||
});
|
||||
},
|
||||
[selectedElements, editor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeState) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = ({ clientX }: MouseEvent) => {
|
||||
const deltaX = clientX - resizeState.startX;
|
||||
const rawDeltaTime = Math.round(
|
||||
(deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) *
|
||||
TICKS_PER_SECOND,
|
||||
);
|
||||
const snappedDeltaTime = getSnappedResizeDelta({
|
||||
editor,
|
||||
resizeState,
|
||||
rawDeltaTime,
|
||||
zoomLevel,
|
||||
shouldSnap: snappingEnabled && !isShiftHeldRef.current,
|
||||
onSnapPointChange,
|
||||
});
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
const result = computeGroupResize({
|
||||
members: resizeState.members,
|
||||
side: resizeState.side,
|
||||
deltaTime: snappedDeltaTime.deltaTime,
|
||||
fps,
|
||||
});
|
||||
|
||||
latestResultRef.current = result;
|
||||
const config: ResizeConfig = {
|
||||
zoomLevel,
|
||||
snappingEnabled,
|
||||
isShiftHeld: () => isShiftHeldRef.current,
|
||||
getSceneTracks: () => editor.scenes.getActiveScene().tracks,
|
||||
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
|
||||
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
selectedElements,
|
||||
discardPreview: () => editor.timeline.discardPreview(),
|
||||
previewElements: (updates) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
||||
updates: updates.map(({ trackId, elementId, patch }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
updates: patch as Partial<TimelineElement>,
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const result = latestResultRef.current;
|
||||
editor.timeline.discardPreview();
|
||||
if (
|
||||
result &&
|
||||
hasResizeChanges({ members: resizeState.members, result })
|
||||
) {
|
||||
editor.timeline.updateElements({
|
||||
updates: result.updates.map(({ trackId, elementId, patch }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
patch: 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,
|
||||
}),
|
||||
commitElements: (updates) =>
|
||||
editor.timeline.updateElements({
|
||||
updates: updates.map(({ trackId, elementId, patch }) => ({
|
||||
trackId,
|
||||
elementId,
|
||||
patch: patch as Partial<TimelineElement>,
|
||||
})),
|
||||
}),
|
||||
onSnapPointChange,
|
||||
]);
|
||||
};
|
||||
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
const controllerRef = useRef<ResizeController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new ResizeController({ configRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!controller.isResizing) return;
|
||||
return registerCanceller({ fn: () => controller.cancel() });
|
||||
}, [controller.isResizing, controller]);
|
||||
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
isResizing: resizeState !== null,
|
||||
handleResizeStart,
|
||||
isResizing: controller.isResizing,
|
||||
handleResizeStart: controller.onResizeStart,
|
||||
};
|
||||
}
|
||||
|
||||
function buildResizeMembers({
|
||||
tracks,
|
||||
selectedElements,
|
||||
}: {
|
||||
tracks: SceneTracks;
|
||||
selectedElements: Array<{ trackId: string; elementId: string }>;
|
||||
}): GroupResizeMember[] {
|
||||
const selectedElementIds = new Set(
|
||||
selectedElements.map((selectedElement) => selectedElement.elementId),
|
||||
);
|
||||
const trackMap = new Map(
|
||||
[...tracks.overlay, tracks.main, ...tracks.audio].map((track) => [
|
||||
track.id,
|
||||
track,
|
||||
]),
|
||||
);
|
||||
|
||||
return selectedElements.flatMap(({ trackId, elementId }) => {
|
||||
const track = trackMap.get(trackId);
|
||||
const element = track?.elements.find(
|
||||
(trackElement) => trackElement.id === elementId,
|
||||
);
|
||||
if (!track || !element) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const otherElements = track.elements.filter(
|
||||
(trackElement) => !selectedElementIds.has(trackElement.id),
|
||||
);
|
||||
const leftNeighborBound = otherElements
|
||||
.filter(
|
||||
(trackElement) =>
|
||||
trackElement.startTime + trackElement.duration <= element.startTime,
|
||||
)
|
||||
.reduce((bound, trackElement) => {
|
||||
return Math.max(bound, trackElement.startTime + trackElement.duration);
|
||||
}, -Infinity);
|
||||
const rightNeighborBound = otherElements
|
||||
.filter(
|
||||
(trackElement) =>
|
||||
trackElement.startTime >= element.startTime + element.duration,
|
||||
)
|
||||
.reduce((bound, trackElement) => {
|
||||
return Math.min(bound, trackElement.startTime);
|
||||
}, Infinity);
|
||||
|
||||
return [
|
||||
{
|
||||
trackId,
|
||||
elementId,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
sourceDuration: element.sourceDuration,
|
||||
retime: isRetimableElement(element) ? element.retime : undefined,
|
||||
leftNeighborBound,
|
||||
rightNeighborBound,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function getSnappedResizeDelta({
|
||||
editor,
|
||||
resizeState,
|
||||
rawDeltaTime,
|
||||
zoomLevel,
|
||||
shouldSnap,
|
||||
onSnapPointChange,
|
||||
}: {
|
||||
editor: EditorCore;
|
||||
resizeState: ResizeInteractionState;
|
||||
rawDeltaTime: number;
|
||||
zoomLevel: number;
|
||||
shouldSnap: boolean;
|
||||
onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
|
||||
}): { deltaTime: number } {
|
||||
if (!shouldSnap) {
|
||||
onSnapPointChange?.(null);
|
||||
return { deltaTime: rawDeltaTime };
|
||||
}
|
||||
|
||||
const tracks = editor.scenes.getActiveScene().tracks;
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const excludeElementIds = new Set(
|
||||
resizeState.members.map((member) => member.elementId),
|
||||
);
|
||||
const snapPoints = buildTimelineSnapPoints({
|
||||
sources: [
|
||||
() => getElementEdgeSnapPoints({ tracks, excludeElementIds }),
|
||||
() => getPlayheadSnapPoints({ playheadTime }),
|
||||
() =>
|
||||
getAnimationKeyframeSnapPointsForTimeline({
|
||||
tracks,
|
||||
excludeElementIds,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const maxSnapDistance = getTimelineSnapThresholdInTicks({ zoomLevel });
|
||||
let closestSnapPoint: SnapPoint | null = null;
|
||||
let closestSnapDistance = Infinity;
|
||||
let deltaTime = rawDeltaTime;
|
||||
|
||||
for (const member of resizeState.members) {
|
||||
const baseEdgeTime =
|
||||
resizeState.side === "left"
|
||||
? member.startTime
|
||||
: member.startTime + member.duration;
|
||||
const snapResult = resolveTimelineSnap({
|
||||
targetTime: baseEdgeTime + rawDeltaTime,
|
||||
snapPoints,
|
||||
maxSnapDistance,
|
||||
});
|
||||
if (snapResult.snapPoint && snapResult.snapDistance < closestSnapDistance) {
|
||||
closestSnapDistance = snapResult.snapDistance;
|
||||
closestSnapPoint = snapResult.snapPoint;
|
||||
deltaTime = snapResult.snappedTime - baseEdgeTime;
|
||||
}
|
||||
}
|
||||
|
||||
onSnapPointChange?.(closestSnapPoint);
|
||||
return { deltaTime };
|
||||
}
|
||||
|
||||
function hasResizeChanges({
|
||||
members,
|
||||
result,
|
||||
}: {
|
||||
members: GroupResizeMember[];
|
||||
result: GroupResizeResult;
|
||||
}): boolean {
|
||||
return result.updates.some((update) => {
|
||||
const member = members.find(
|
||||
(candidateMember) => candidateMember.elementId === update.elementId,
|
||||
);
|
||||
return (
|
||||
member?.trimStart !== update.patch.trimStart ||
|
||||
member?.trimEnd !== update.patch.trimEnd ||
|
||||
member?.startTime !== update.patch.startTime ||
|
||||
member?.duration !== update.patch.duration
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useCallback, useRef } from "react";
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
|
||||
import { snappedSeekTime } from "opencut-wasm";
|
||||
import { mediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import {
|
||||
SeekController,
|
||||
type SeekConfig,
|
||||
} from "@/timeline/controllers/seek-controller";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
interface UseTimelineSeekProps {
|
||||
playheadRef: RefObject<HTMLDivElement | null>;
|
||||
|
|
@ -17,44 +18,6 @@ interface UseTimelineSeekProps {
|
|||
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({
|
||||
playheadRef,
|
||||
trackLabelsRef,
|
||||
|
|
@ -67,133 +30,42 @@ export function useTimelineSeek({
|
|||
seek,
|
||||
}: UseTimelineSeekProps) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
const mouseTrackingRef = useRef({
|
||||
isMouseDown: false,
|
||||
downX: 0,
|
||||
downY: 0,
|
||||
downTime: 0,
|
||||
});
|
||||
|
||||
const handleTracksMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const handleRulerMouseDown = useCallback((event: React.MouseEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
setMouseTracking({ mouseTrackingRef, event });
|
||||
}, []);
|
||||
|
||||
const shouldProcessTimelineClick = useCallback(
|
||||
({ event }: { event: React.MouseEvent }) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current;
|
||||
const deltaX = Math.abs(event.clientX - downX);
|
||||
const deltaY = Math.abs(event.clientY - downY);
|
||||
const deltaTime = event.timeStamp - downTime;
|
||||
const isPlayhead = !!playheadRef.current?.contains(target);
|
||||
const isTrackLabels = !!trackLabelsRef.current?.contains(target);
|
||||
const shouldBlockForDrag = deltaX > 5 || deltaY > 5 || deltaTime > 500;
|
||||
|
||||
if (!isMouseDown) return false;
|
||||
if (shouldBlockForDrag) return false;
|
||||
if (isSelecting) return false;
|
||||
if (isPlayhead) return false;
|
||||
if (isTrackLabels) {
|
||||
clearSelectedElements();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[isSelecting, clearSelectedElements, playheadRef, trackLabelsRef],
|
||||
);
|
||||
|
||||
const handleTimelineSeek = useCallback(
|
||||
({
|
||||
event,
|
||||
source,
|
||||
}: {
|
||||
event: React.MouseEvent;
|
||||
source: "ruler" | "tracks";
|
||||
}) => {
|
||||
const scrollContainer =
|
||||
source === "ruler" ? rulerScrollRef.current : tracksScrollRef.current;
|
||||
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const rect = scrollContainer.getBoundingClientRect();
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const scrollLeft = scrollContainer.scrollLeft;
|
||||
|
||||
const rawTimeSeconds = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
duration / TICKS_PER_SECOND,
|
||||
(mouseX + scrollLeft) / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
|
||||
),
|
||||
);
|
||||
const rawTime = 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);
|
||||
const config: SeekConfig = {
|
||||
zoomLevel,
|
||||
duration,
|
||||
isSelecting,
|
||||
getPlayheadEl: () => playheadRef.current,
|
||||
getTrackLabelsEl: () => trackLabelsRef.current,
|
||||
getRulerScrollEl: () => rulerScrollRef.current,
|
||||
getTracksScrollEl: () => tracksScrollRef.current,
|
||||
getActiveProjectFps: () => editor.project.getActive()?.settings.fps ?? null,
|
||||
clearSelectedElements,
|
||||
seek,
|
||||
setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollContainer.scrollLeft,
|
||||
playheadTime: time,
|
||||
scrollLeft,
|
||||
playheadTime,
|
||||
},
|
||||
});
|
||||
},
|
||||
[
|
||||
duration,
|
||||
zoomLevel,
|
||||
rulerScrollRef,
|
||||
tracksScrollRef,
|
||||
seek,
|
||||
editor,
|
||||
activeProject?.settings.fps.numerator,
|
||||
activeProject?.settings.fps.denominator,
|
||||
],
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
const handleTracksClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "tracks" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
const controllerRef = useRef<SeekController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new SeekController({ configRef });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
|
||||
const handleRulerClick = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
const shouldProcess = shouldProcessTimelineClick({ event });
|
||||
resetMouseTracking({ mouseTrackingRef });
|
||||
|
||||
if (shouldProcess) {
|
||||
clearSelectedElements();
|
||||
handleTimelineSeek({ event, source: "ruler" });
|
||||
}
|
||||
},
|
||||
[shouldProcessTimelineClick, handleTimelineSeek, clearSelectedElements],
|
||||
);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
handleTracksMouseDown,
|
||||
handleTracksClick,
|
||||
handleRulerMouseDown,
|
||||
handleRulerClick,
|
||||
handleTracksMouseDown: controller.onTracksMouseDown,
|
||||
handleTracksClick: controller.onTracksClick,
|
||||
handleRulerMouseDown: controller.onRulerMouseDown,
|
||||
handleRulerClick: controller.onRulerClick,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import {
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD } from "@/timeline/components/interaction";
|
||||
import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN } from "@/timeline/scale";
|
||||
import { timelineTimeToPixels } from "@/timeline/pixel-utils";
|
||||
import { useEditor } from "@/editor/use-editor";
|
||||
import { zoomToSlider } from "@/timeline/zoom-utils";
|
||||
import { TIMELINE_ZOOM_MIN } from "@/timeline/scale";
|
||||
import {
|
||||
ZoomController,
|
||||
type ZoomConfig,
|
||||
} from "@/timeline/controllers/zoom-controller";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
interface UseTimelineZoomProps {
|
||||
|
|
@ -41,252 +41,59 @@ export function useTimelineZoom({
|
|||
rulerScrollRef,
|
||||
}: UseTimelineZoomProps): UseTimelineZoomReturn {
|
||||
const editor = useEditor();
|
||||
const hasInitializedRef = useRef(false);
|
||||
const hasRestoredPlayheadRef = useRef(false);
|
||||
const scrollSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const config: ZoomConfig = {
|
||||
minZoom,
|
||||
getContainerEl: () => containerRef.current,
|
||||
getTracksScrollEl: () => tracksScrollRef.current,
|
||||
getRulerScrollEl: () => rulerScrollRef.current,
|
||||
getCurrentPlayheadTime: () => editor.playback.getCurrentTime(),
|
||||
seek: (time) => editor.playback.seek({ time }),
|
||||
setTimelineViewState: ({ zoomLevel, scrollLeft, playheadTime }) =>
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft,
|
||||
playheadTime,
|
||||
},
|
||||
}),
|
||||
};
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
const [zoomLevel, setZoomLevelRaw] = useState(() => {
|
||||
if (initialZoom !== undefined) {
|
||||
hasInitializedRef.current = true;
|
||||
return Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom));
|
||||
}
|
||||
return minZoom;
|
||||
});
|
||||
const previousZoomRef = useRef(zoomLevel);
|
||||
const hasRestoredScrollRef = useRef(false);
|
||||
const preZoomScrollLeftRef = useRef(0);
|
||||
const prePlayheadAnchorScrollLeftRef = useRef(0);
|
||||
const isInPlayheadAnchorModeRef = useRef(false);
|
||||
const controllerRef = useRef<ZoomController | null>(null);
|
||||
if (!controllerRef.current) {
|
||||
controllerRef.current = new ZoomController({ configRef, initialZoom });
|
||||
}
|
||||
const controller = controllerRef.current;
|
||||
const zoomLevel = controller.zoomLevel;
|
||||
|
||||
const setZoomLevel = useCallback(
|
||||
(updater: number | ((prev: number) => number)) => {
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (scrollElement) {
|
||||
preZoomScrollLeftRef.current = scrollElement.scrollLeft;
|
||||
}
|
||||
setZoomLevelRaw(updater);
|
||||
},
|
||||
[tracksScrollRef],
|
||||
);
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(event: ReactWheelEvent) => {
|
||||
const isZoomGesture = event.ctrlKey || event.metaKey;
|
||||
const isHorizontalScrollGesture =
|
||||
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
||||
|
||||
if (isHorizontalScrollGesture) {
|
||||
return;
|
||||
}
|
||||
|
||||
// pinch-zoom (ctrl/meta + wheel)
|
||||
if (isZoomGesture) {
|
||||
const normalizedDelta =
|
||||
event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
||||
const cappedDelta =
|
||||
Math.sign(normalizedDelta) * Math.min(Math.abs(normalizedDelta), 30);
|
||||
const zoomFactor = Math.exp(-cappedDelta / 300);
|
||||
setZoomLevel((prev) => {
|
||||
const nextZoom = Math.max(
|
||||
minZoom,
|
||||
Math.min(TIMELINE_ZOOM_MAX, prev * zoomFactor),
|
||||
);
|
||||
return nextZoom;
|
||||
});
|
||||
return;
|
||||
}
|
||||
},
|
||||
[minZoom, setZoomLevel],
|
||||
);
|
||||
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => controller.subscribe(rerender), [controller]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialZoom !== undefined && !hasInitializedRef.current) {
|
||||
hasInitializedRef.current = true;
|
||||
setZoomLevel(Math.max(minZoom, Math.min(TIMELINE_ZOOM_MAX, initialZoom)));
|
||||
return;
|
||||
}
|
||||
setZoomLevel((prev: 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],
|
||||
);
|
||||
controller.reconcileInitialAndMinZoom(minZoom, initialZoom);
|
||||
}, [controller, minZoom, initialZoom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previousZoom = previousZoomRef.current;
|
||||
if (previousZoom === zoomLevel) return;
|
||||
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (!scrollElement) {
|
||||
previousZoomRef.current = zoomLevel;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentScrollLeft = preZoomScrollLeftRef.current;
|
||||
const playheadTime = editor.playback.getCurrentTime();
|
||||
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
|
||||
const previousSliderPercent = zoomToSlider({
|
||||
zoomLevel: previousZoom,
|
||||
minZoom,
|
||||
});
|
||||
const isCrossingThresholdUp =
|
||||
previousSliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
const isCrossingThresholdDown =
|
||||
previousSliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
|
||||
sliderPercent < TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
|
||||
|
||||
const syncScroll = (scrollLeft: number) => {
|
||||
scrollElement.scrollLeft = scrollLeft;
|
||||
if (rulerScrollRef.current) {
|
||||
rulerScrollRef.current.scrollLeft = scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
const clampScrollLeft = (scrollLeft: number) => {
|
||||
const maxScrollLeft =
|
||||
scrollElement.scrollWidth - scrollElement.clientWidth;
|
||||
return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
|
||||
};
|
||||
|
||||
if (isCrossingThresholdUp) {
|
||||
prePlayheadAnchorScrollLeftRef.current = currentScrollLeft;
|
||||
isInPlayheadAnchorModeRef.current = true;
|
||||
}
|
||||
|
||||
if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
|
||||
const playheadPixelsBefore = timelineTimeToPixels({
|
||||
time: playheadTime,
|
||||
zoomLevel: previousZoom,
|
||||
});
|
||||
const playheadPixelsAfter = timelineTimeToPixels({
|
||||
time: playheadTime,
|
||||
zoomLevel,
|
||||
});
|
||||
|
||||
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
|
||||
const newScrollLeft = playheadPixelsAfter - viewportOffset;
|
||||
|
||||
syncScroll(clampScrollLeft(newScrollLeft));
|
||||
} else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
|
||||
syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
|
||||
isInPlayheadAnchorModeRef.current = false;
|
||||
}
|
||||
|
||||
previousZoomRef.current = zoomLevel;
|
||||
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollElement.scrollLeft,
|
||||
playheadTime,
|
||||
},
|
||||
});
|
||||
}, [zoomLevel, editor, tracksScrollRef, rulerScrollRef, minZoom]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: tracksScrollRef is a stable ref
|
||||
const saveScrollPosition = useCallback(() => {
|
||||
if (scrollSaveTimeoutRef.current) {
|
||||
clearTimeout(scrollSaveTimeoutRef.current);
|
||||
}
|
||||
scrollSaveTimeoutRef.current = setTimeout(() => {
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (scrollElement) {
|
||||
editor.project.setTimelineViewState({
|
||||
viewState: {
|
||||
zoomLevel,
|
||||
scrollLeft: scrollElement.scrollLeft,
|
||||
playheadTime: editor.playback.getCurrentTime(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
}, [zoomLevel, editor]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refs are stable
|
||||
useEffect(() => {
|
||||
if (initialScrollLeft === undefined) return;
|
||||
if (hasRestoredScrollRef.current) return;
|
||||
const scrollElement = tracksScrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const restoreScroll = () => {
|
||||
scrollElement.scrollLeft = initialScrollLeft;
|
||||
if (rulerScrollRef.current) {
|
||||
rulerScrollRef.current.scrollLeft = initialScrollLeft;
|
||||
}
|
||||
hasRestoredScrollRef.current = true;
|
||||
};
|
||||
|
||||
if (scrollElement.scrollWidth > 0) {
|
||||
restoreScroll();
|
||||
} else {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (scrollElement.scrollWidth > 0) {
|
||||
restoreScroll();
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(scrollElement);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [initialScrollLeft]);
|
||||
controller.applyZoomLayout(zoomLevel);
|
||||
}, [controller, zoomLevel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPlayheadTime !== undefined && !hasRestoredPlayheadRef.current) {
|
||||
hasRestoredPlayheadRef.current = true;
|
||||
editor.playback.seek({ time: initialPlayheadTime });
|
||||
}
|
||||
}, [initialPlayheadTime, editor]);
|
||||
return controller.restoreInitialScrollIfNeeded(initialScrollLeft);
|
||||
}, [controller, initialScrollLeft]);
|
||||
|
||||
// prevent browser zoom in the timeline
|
||||
useEffect(() => {
|
||||
const preventZoom = (event: WheelEvent) => {
|
||||
const isZoomKeyPressed = event.ctrlKey || event.metaKey;
|
||||
const isInContainer = containerRef.current?.contains(
|
||||
event.target as Node,
|
||||
);
|
||||
// only check isInContainer, not isInTimeline state - the state check
|
||||
// causes race conditions where the closure captures stale state
|
||||
if (isZoomKeyPressed && isInContainer) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
controller.restoreInitialPlayheadIfNeeded(initialPlayheadTime);
|
||||
}, [controller, initialPlayheadTime]);
|
||||
|
||||
document.addEventListener("wheel", preventZoom, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
useEffect(() => controller.bindPreventBrowserZoom(), [controller]);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("wheel", preventZoom, { capture: true });
|
||||
};
|
||||
}, [containerRef]);
|
||||
useEffect(() => () => controller.destroy(), [controller]);
|
||||
|
||||
return {
|
||||
zoomLevel,
|
||||
setZoomLevel: wrappedSetZoomLevel,
|
||||
handleWheel,
|
||||
saveScrollPosition,
|
||||
setZoomLevel: controller.setZoomLevel,
|
||||
handleWheel: controller.handleWheel,
|
||||
saveScrollPosition: controller.saveScrollPosition,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import type { SnapPoint } from "@/timeline/snapping";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export function getPlayheadSnapPoints({
|
||||
playheadTime,
|
||||
}: {
|
||||
playheadTime: number;
|
||||
playheadTime: MediaTime;
|
||||
}): SnapPoint[] {
|
||||
return [{ time: playheadTime, type: "playhead" }];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import type { SnapPoint, SnapResult } from "./types";
|
||||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export function resolveTimelineSnap({
|
||||
targetTime,
|
||||
snapPoints,
|
||||
maxSnapDistance,
|
||||
}: {
|
||||
targetTime: number;
|
||||
targetTime: MediaTime;
|
||||
snapPoints: SnapPoint[];
|
||||
maxSnapDistance: number;
|
||||
}): SnapResult {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { MediaTime } from "@/wasm";
|
||||
|
||||
export type SnapPointType =
|
||||
| "element-start"
|
||||
| "element-end"
|
||||
|
|
@ -6,14 +8,14 @@ export type SnapPointType =
|
|||
| "keyframe";
|
||||
|
||||
export interface SnapPoint {
|
||||
time: number;
|
||||
time: MediaTime;
|
||||
type: SnapPointType;
|
||||
elementId?: string;
|
||||
trackId?: string;
|
||||
}
|
||||
|
||||
export interface SnapResult {
|
||||
snappedTime: number;
|
||||
snappedTime: MediaTime;
|
||||
snapPoint: SnapPoint | null;
|
||||
snapDistance: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -284,6 +284,23 @@ export interface ElementDragState {
|
|||
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 {
|
||||
trackIndex: number;
|
||||
isNewTrack: boolean;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import {
|
||||
lastFrameTime as _lastFrameTime,
|
||||
parseTimecode as _parseTimecode,
|
||||
roundToFrame as _roundToFrame,
|
||||
snappedSeekTime as _snappedSeekTime,
|
||||
TICKS_PER_SECOND as _TICKS_PER_SECOND,
|
||||
mediaTimeFromSeconds as _mediaTimeFromSeconds,
|
||||
mediaTimeToSeconds as _mediaTimeToSeconds,
|
||||
type FrameRate,
|
||||
type TimeCodeFormat,
|
||||
} from "opencut-wasm";
|
||||
|
||||
/**
|
||||
|
|
@ -127,3 +133,58 @@ export function clampMediaTime({
|
|||
if (time > max) return max;
|
||||
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",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.4",
|
||||
"opencut-wasm": "^0.2.8",
|
||||
"opencut-wasm": "^0.2.9",
|
||||
"pg": "^8.16.2",
|
||||
"postgres": "^3.4.5",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
|
@ -875,7 +875,7 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue