fix: add progress logging during silent render pipeline stages (#1220)

* fix: add progress logging during silent render pipeline stages

The render pipeline only updates progress at stage boundaries (5%, 10%,
25%), leaving multi-minute gaps with zero log output on low-memory
hardware. This adds log.info calls at key sub-steps within the three
silent stages:

- Probe stage (5%): browser launch, session initialization, duration
  discovery, media asset discovery, audio volume automation, video
  visibility window detection
- Video extraction (10%): per-video extraction progress
- Calibration (25%): browser launch, session initialization,
  per-frame calibration progress, final cost estimate

Also adds 30-second heartbeat timers for the two initializeSession
calls (probe and calibration) that can individually take minutes on
constrained hardware.

Closes #1218

* fix: resolve CI failures in typecheck, runtime seek test, and timeline test

- Make handleGsapMaterializeKeyframes optional in DomEditSessionSlice
  and use optional chaining at the call site (not yet wired)
- Update GSAP adapter seek test to expect nudge+seek pattern
  (totalTime with suppressEvents:true followed by actual seek)
- Fix Timeline canvas height test to use TRACK_H constant (48)
  instead of stale hardcoded value (72)

* refactor: extract helpers to meet 600-line file size limit

- App.tsx (603→594): extract StudioToast component
- useDomEditSession.ts (688→600): extract useGsapSelectionHandlers hook
- Timeline.tsx (614→557): extract useTimelineAssetDrop hook
- PropertyPanel.tsx (647→584): extract TimingSection to propertyPanelTimingSection

* style: fix formatting in TimelineToolbar
This commit is contained in:
Miguel Ángel
2026-06-05 16:17:58 -04:00
committed by GitHub
parent 20894ab9a3
commit 6bd1e764e5
14 changed files with 503 additions and 277 deletions
+2 -11
View File
@@ -44,6 +44,7 @@ import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
import { FileManagerProvider } from "./contexts/FileManagerContext";
import { DomEditProvider } from "./contexts/DomEditContext";
import { StudioSplash } from "./components/StudioSplash";
import { StudioToast } from "./components/StudioToast";
import { useServerConnection } from "./hooks/useServerConnection";
import {
normalizeStudioCompositionPath,
@@ -583,17 +584,7 @@ export function StudioApp() {
)}
{dragOverlay.active && <StudioGlobalDragOverlay />}
{appToast && (
<div
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
appToast.tone === "error"
? "bg-red-900/90 border-red-700/50 text-red-200"
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
}`}
>
{appToast.message}
</div>
)}
{appToast && <StudioToast message={appToast.message} tone={appToast.tone} />}
</div>
</DomEditProvider>
</FileManagerProvider>
@@ -0,0 +1,18 @@
interface StudioToastProps {
message: string;
tone?: "error" | "info";
}
export function StudioToast({ message, tone }: StudioToastProps) {
return (
<div
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
tone === "error"
? "bg-red-900/90 border-red-700/50 text-red-200"
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
}`}
>
{message}
</div>
);
}
@@ -125,8 +125,8 @@ function useKeyframeToggle(session?: DomEditSessionSlice) {
? async () => {
const t = usePlayerStore.getState().currentTime;
if (kfAnim?.keyframes) {
if (kfAnim.hasUnresolvedKeyframes && session.handleGsapMaterializeKeyframes) {
await session.handleGsapMaterializeKeyframes(kfAnim.id);
if (kfAnim.hasUnresolvedKeyframes) {
await session.handleGsapMaterializeKeyframes?.(kfAnim.id);
}
const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0;
const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1;
@@ -1,5 +1,5 @@
import { memo } from "react";
import { Clock, Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
import { type DomEditSelection } from "./domEditing";
import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
import type { ImportedFontAsset } from "./fontAssets";
@@ -13,6 +13,7 @@ import {
import { MetricField, Section } from "./propertyPanelPrimitives";
import { isMediaElement, MediaSection } from "./propertyPanelMediaSection";
import { TextSection, StyleSections } from "./propertyPanelSections";
import { TimingSection } from "./propertyPanelTimingSection";
import { GsapAnimationSection } from "./GsapAnimationSection";
import { KeyframeNavigation } from "./KeyframeNavigation";
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
@@ -84,70 +85,6 @@ interface PropertyPanelProps {
onSeekToTime?: (time: number) => void;
}
/* ------------------------------------------------------------------ */
/* TimingSection */
/* ------------------------------------------------------------------ */
function formatTimingValue(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
return `${seconds.toFixed(2)}s`;
}
function parseTimingValue(input: string): number | null {
const cleaned = input.replace(/s$/i, "").trim();
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
function TimingSection({
element,
onSetAttribute,
}: {
element: DomEditSelection;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
}) {
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
const duration =
Number.parseFloat(
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
) || 0;
const end = start + duration;
const commitStart = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null) return;
void onSetAttribute("start", parsed.toFixed(2));
};
const commitDuration = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= 0) return;
void onSetAttribute("duration", parsed.toFixed(2));
};
const commitEnd = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= start) return;
void onSetAttribute("duration", (parsed - start).toFixed(2));
};
return (
<Section title="Timing" icon={<Clock size={15} />}>
<div className={RESPONSIVE_GRID}>
<MetricField label="Start" value={formatTimingValue(start)} onCommit={commitStart} />
<MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
</div>
<div className="mt-3">
<MetricField
label="Duration"
value={formatTimingValue(duration)}
onCommit={commitDuration}
/>
</div>
</Section>
);
}
/* ------------------------------------------------------------------ */
/* PropertyPanel */
/* ------------------------------------------------------------------ */
@@ -0,0 +1,64 @@
import { Clock } from "../../icons/SystemIcons";
import type { DomEditSelection } from "./domEditing";
import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
function formatTimingValue(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0.00s";
return `${seconds.toFixed(2)}s`;
}
function parseTimingValue(input: string): number | null {
const cleaned = input.replace(/s$/i, "").trim();
const parsed = Number.parseFloat(cleaned);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
export function TimingSection({
element,
onSetAttribute,
}: {
element: DomEditSelection;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
}) {
const start = Number.parseFloat(element.dataAttributes.start ?? "0") || 0;
const duration =
Number.parseFloat(
element.dataAttributes.duration ?? element.dataAttributes["hf-authored-duration"] ?? "0",
) || 0;
const end = start + duration;
const commitStart = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null) return;
void onSetAttribute("start", parsed.toFixed(2));
};
const commitDuration = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= 0) return;
void onSetAttribute("duration", parsed.toFixed(2));
};
const commitEnd = (nextValue: string) => {
const parsed = parseTimingValue(nextValue);
if (parsed == null || parsed <= start) return;
void onSetAttribute("duration", (parsed - start).toFixed(2));
};
return (
<Section title="Timing" icon={<Clock size={15} />}>
<div className={RESPONSIVE_GRID}>
<MetricField label="Start" value={formatTimingValue(start)} onCommit={commitStart} />
<MetricField label="End" value={formatTimingValue(end)} onCommit={commitEnd} />
</div>
<div className="mt-3">
<MetricField
label="Duration"
value={formatTimingValue(duration)}
onCommit={commitDuration}
/>
</div>
</Section>
);
}
+36 -124
View File
@@ -30,6 +30,7 @@ import {
tryGsapRotationIntercept,
} from "./gsapRuntimeBridge";
import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
// ── Types ──
@@ -308,10 +309,7 @@ export function useDomEditSession({
buildDomSelectionFromTarget,
});
// Wrap the CSS-based path offset commit with GSAP-awareness: when the
// selected element has GSAP animations controlling x/y, read the actual
// interpolated position from the iframe runtime and commit via the GSAP
// script mutation path instead of the CSS translate offset.
// GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated.
const handleGsapAwarePathOffsetCommit = useCallback(
async (selection: DomEditSelection, next: { x: number; y: number }) => {
if (gsapCommitMutation) {
@@ -406,126 +404,40 @@ export function useDomEditSession({
],
);
const handleGsapUpdateProperty = useCallback(
(animId: string, prop: string, value: number | string) => {
if (!domEditSelection) return;
updateGsapProperty(domEditSelection, animId, prop, value);
},
[domEditSelection, updateGsapProperty],
);
const handleGsapUpdateMeta = useCallback(
(animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
if (!domEditSelection) return;
updateGsapMeta(domEditSelection, animId, updates);
},
[domEditSelection, updateGsapMeta],
);
const handleGsapDeleteAnimation = useCallback(
(animId: string) => {
if (!domEditSelection) return;
deleteGsapAnimation(domEditSelection, animId);
},
[domEditSelection, deleteGsapAnimation],
);
const handleGsapAddAnimation = useCallback(
(method: "to" | "from" | "set" | "fromTo") => {
if (!domEditSelection) return;
addGsapAnimation(domEditSelection, method, currentTime);
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
handleDomManualEditsReset(domEditSelection);
}
},
[domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset],
);
const handleGsapAddProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
addGsapProperty(domEditSelection, animId, prop);
},
[domEditSelection, addGsapProperty],
);
const handleGsapRemoveProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
removeGsapProperty(domEditSelection, animId, prop);
},
[domEditSelection, removeGsapProperty],
);
const handleGsapUpdateFromProperty = useCallback(
(animId: string, prop: string, value: number | string) => {
if (!domEditSelection) return;
updateGsapFromProperty(domEditSelection, animId, prop, value);
},
[domEditSelection, updateGsapFromProperty],
);
const handleGsapAddFromProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
addGsapFromProperty(domEditSelection, animId, prop);
},
[domEditSelection, addGsapFromProperty],
);
const handleGsapRemoveFromProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
removeGsapFromProperty(domEditSelection, animId, prop);
},
[domEditSelection, removeGsapFromProperty],
);
const handleGsapAddKeyframe = useCallback(
(animId: string, percentage: number, property: string, value: number | string) => {
if (!domEditSelection) return;
addKeyframe(domEditSelection, animId, percentage, property, value);
},
[domEditSelection, addKeyframe],
);
const handleGsapRemoveKeyframe = useCallback(
(animId: string, percentage: number) => {
if (!domEditSelection) return;
removeKeyframe(domEditSelection, animId, percentage);
},
[domEditSelection, removeKeyframe],
);
const handleGsapConvertToKeyframes = useCallback(
(animId: string) => {
if (!domEditSelection) return;
convertToKeyframes(domEditSelection, animId);
},
[domEditSelection, convertToKeyframes],
);
const handleGsapRemoveAllKeyframes = useCallback(
(animId: string) => {
if (!domEditSelection) return;
removeAllKeyframes(domEditSelection, animId);
},
[domEditSelection, removeAllKeyframes],
);
/**
* Reset keyframes for the currently selected element.
* Finds the animation with keyframes from the resolved GSAP animations
* and sends a remove-all-keyframes mutation. Returns true if keyframes
* were found and the mutation was dispatched.
*/
const handleResetSelectedElementKeyframes = useCallback((): boolean => {
if (!domEditSelection) return false;
const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
if (!withKeyframes) return false;
removeAllKeyframes(domEditSelection, withKeyframes.id);
return true;
}, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
const {
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
} = useGsapSelectionHandlers({
domEditSelection,
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
removeKeyframe,
convertToKeyframes,
removeAllKeyframes,
currentTime,
handleDomManualEditsReset,
selectedGsapAnimations,
});
const commitAnimatedProperty = useAnimatedPropertyCommit({
selectedGsapAnimations,
@@ -0,0 +1,202 @@
import { useCallback } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
/**
* Thin useCallback wrappers that guard on `domEditSelection` before
* delegating to the underlying GSAP script-commit functions. Extracted
* from useDomEditSession to keep that file under the 600-line limit.
*/
// fallow-ignore-next-line complexity
export function useGsapSelectionHandlers({
domEditSelection,
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
removeKeyframe,
convertToKeyframes,
removeAllKeyframes,
currentTime,
handleDomManualEditsReset,
selectedGsapAnimations,
}: {
domEditSelection: DomEditSelection | null;
updateGsapProperty: (
sel: DomEditSelection,
animId: string,
prop: string,
value: number | string,
) => void;
updateGsapMeta: (
sel: DomEditSelection,
animId: string,
updates: { duration?: number; ease?: string; position?: number },
) => void;
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => void;
addGsapAnimation: (
sel: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
time: number,
) => void;
addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
updateGsapFromProperty: (
sel: DomEditSelection,
animId: string,
prop: string,
value: number | string,
) => void;
addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => void;
addKeyframe: (
sel: DomEditSelection,
animId: string,
percentage: number,
property: string,
value: number | string,
) => void;
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
convertToKeyframes: (sel: DomEditSelection, animId: string) => void;
removeAllKeyframes: (sel: DomEditSelection, animId: string) => void;
currentTime: number;
handleDomManualEditsReset: (sel: DomEditSelection) => void;
selectedGsapAnimations: { id: string; keyframes?: unknown }[];
}) {
const handleGsapUpdateProperty = useCallback(
(animId: string, prop: string, value: number | string) => {
if (!domEditSelection) return;
updateGsapProperty(domEditSelection, animId, prop, value);
},
[domEditSelection, updateGsapProperty],
);
const handleGsapUpdateMeta = useCallback(
(animId: string, updates: { duration?: number; ease?: string; position?: number }) => {
if (!domEditSelection) return;
updateGsapMeta(domEditSelection, animId, updates);
},
[domEditSelection, updateGsapMeta],
);
const handleGsapDeleteAnimation = useCallback(
(animId: string) => {
if (!domEditSelection) return;
deleteGsapAnimation(domEditSelection, animId);
},
[domEditSelection, deleteGsapAnimation],
);
const handleGsapAddAnimation = useCallback(
(method: "to" | "from" | "set" | "fromTo") => {
if (!domEditSelection) return;
addGsapAnimation(domEditSelection, method, currentTime);
if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) {
handleDomManualEditsReset(domEditSelection);
}
},
[domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset],
);
const handleGsapAddProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
addGsapProperty(domEditSelection, animId, prop);
},
[domEditSelection, addGsapProperty],
);
const handleGsapRemoveProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
removeGsapProperty(domEditSelection, animId, prop);
},
[domEditSelection, removeGsapProperty],
);
const handleGsapUpdateFromProperty = useCallback(
(animId: string, prop: string, value: number | string) => {
if (!domEditSelection) return;
updateGsapFromProperty(domEditSelection, animId, prop, value);
},
[domEditSelection, updateGsapFromProperty],
);
const handleGsapAddFromProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
addGsapFromProperty(domEditSelection, animId, prop);
},
[domEditSelection, addGsapFromProperty],
);
const handleGsapRemoveFromProperty = useCallback(
(animId: string, prop: string) => {
if (!domEditSelection) return;
removeGsapFromProperty(domEditSelection, animId, prop);
},
[domEditSelection, removeGsapFromProperty],
);
const handleGsapAddKeyframe = useCallback(
(animId: string, percentage: number, property: string, value: number | string) => {
if (!domEditSelection) return;
addKeyframe(domEditSelection, animId, percentage, property, value);
},
[domEditSelection, addKeyframe],
);
const handleGsapRemoveKeyframe = useCallback(
(animId: string, percentage: number) => {
if (!domEditSelection) return;
removeKeyframe(domEditSelection, animId, percentage);
},
[domEditSelection, removeKeyframe],
);
const handleGsapConvertToKeyframes = useCallback(
(animId: string) => {
if (!domEditSelection) return;
convertToKeyframes(domEditSelection, animId);
},
[domEditSelection, convertToKeyframes],
);
const handleGsapRemoveAllKeyframes = useCallback(
(animId: string) => {
if (!domEditSelection) return;
removeAllKeyframes(domEditSelection, animId);
},
[domEditSelection, removeAllKeyframes],
);
const handleResetSelectedElementKeyframes = useCallback((): boolean => {
if (!domEditSelection) return false;
const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
if (!withKeyframes) return false;
removeAllKeyframes(domEditSelection, withKeyframes.id);
return true;
}, [domEditSelection, selectedGsapAnimations, removeAllKeyframes]);
return {
handleGsapUpdateProperty,
handleGsapUpdateMeta,
handleGsapDeleteAnimation,
handleGsapAddAnimation,
handleGsapAddProperty,
handleGsapRemoveProperty,
handleGsapUpdateFromProperty,
handleGsapAddFromProperty,
handleGsapRemoveFromProperty,
handleGsapAddKeyframe,
handleGsapRemoveKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleResetSelectedElementKeyframes,
};
}
@@ -12,6 +12,7 @@ import {
shouldHandleTimelineDeleteKey,
shouldAutoScrollTimeline,
} from "./Timeline";
import { RULER_H, TRACK_H } from "./timelineLayout";
import { formatTime } from "../lib/time";
describe("generateTicks", () => {
@@ -230,8 +231,7 @@ describe("getTimelinePlayheadLeft", () => {
describe("getTimelineCanvasHeight", () => {
it("includes bottom scroll buffer below the last track", () => {
// RULER_H (24) + trackCount * TRACK_H (48) + scroll buffer
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(24 + 3 * 48);
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(RULER_H + 3 * TRACK_H);
});
it("still keeps ruler space when there are no tracks", () => {
@@ -8,7 +8,7 @@ import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { type TrackVisualStyle, getTrackStyle } from "./timelineIcons";
import { getTimelinePixelsPerSecond } from "./timelineZoom";
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
import { useTimelineAssetDrop } from "./timelineDragDrop";
import { TimelineEmptyState } from "./TimelineEmptyState";
import { TimelineCanvas } from "./TimelineCanvas";
import {
@@ -19,11 +19,9 @@ import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { ClipContextMenu } from "./ClipContextMenu";
import {
GUTTER,
TRACK_H,
generateTicks,
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
resolveTimelineAssetDrop,
} from "./timelineLayout";
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
@@ -364,71 +362,15 @@ export const Timeline = memo(function Timeline({
[resizingClip],
);
const [isDragOver, setIsDragOver] = useState(false);
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
const hasFiles = e.dataTransfer.files.length > 0;
const types = Array.from(e.dataTransfer.types);
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
if (!hasFiles && !hasAsset && !hasBlock) return;
e.preventDefault();
if (hasAsset || hasBlock) e.dataTransfer.dropEffect = "copy";
setIsDragOver(true);
}, []);
const handleAssetDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const scroll = scrollRef.current;
const rect = scroll?.getBoundingClientRect();
const dropInput = {
rectLeft: rect?.left ?? 0,
rectTop: rect?.top ?? 0,
scrollLeft: scroll?.scrollLeft ?? 0,
scrollTop: scroll?.scrollTop ?? 0,
pixelsPerSecond: ppsRef.current,
duration: durationRef.current,
trackHeight: TRACK_H,
trackOrder: trackOrderRef.current,
};
if (onFileDrop && e.dataTransfer.files.length > 0) {
void onFileDrop(
Array.from(e.dataTransfer.files),
scroll && rect ? resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY) : undefined,
);
return;
}
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
if (assetPayload && onAssetDrop && scroll && rect) {
try {
const parsed = JSON.parse(assetPayload) as { path?: string };
if (parsed.path)
void onAssetDrop(
parsed.path,
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
);
} catch {
/* ignore malformed drag payloads */
}
return;
}
const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
if (blockPayload && onBlockDrop && scroll && rect) {
try {
const parsed = JSON.parse(blockPayload) as { name?: string };
if (parsed.name)
void onBlockDrop(
parsed.name,
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
);
} catch {
/* ignore malformed drag payloads */
}
}
},
[onAssetDrop, onBlockDrop, onFileDrop],
);
const { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop } = useTimelineAssetDrop({
scrollRef,
ppsRef,
durationRef,
trackOrderRef,
onFileDrop,
onAssetDrop,
onBlockDrop,
});
if (!timelineReady || elements.length === 0) {
return (
@@ -0,0 +1,103 @@
// fallow-ignore-file clone-families
import { useCallback, useState, type RefObject } from "react";
import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop";
import { TRACK_H, resolveTimelineAssetDrop } from "./timelineLayout";
interface UseTimelineAssetDropOptions {
scrollRef: RefObject<HTMLDivElement | null>;
ppsRef: RefObject<number>;
durationRef: RefObject<number>;
trackOrderRef: RefObject<number[]>;
onFileDrop?: (
files: File[],
placement?: { start: number; track: number },
) => Promise<void> | void;
onAssetDrop?: (
assetPath: string,
placement: { start: number; track: number },
) => Promise<void> | void;
onBlockDrop?: (
blockName: string,
placement: { start: number; track: number },
) => Promise<void> | void;
}
export function useTimelineAssetDrop({
scrollRef,
ppsRef,
durationRef,
trackOrderRef,
onFileDrop,
onAssetDrop,
onBlockDrop,
}: UseTimelineAssetDropOptions) {
const [isDragOver, setIsDragOver] = useState(false);
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
const hasFiles = e.dataTransfer.files.length > 0;
const types = Array.from(e.dataTransfer.types);
const hasAsset = types.includes(TIMELINE_ASSET_MIME);
const hasBlock = types.includes(TIMELINE_BLOCK_MIME);
if (!hasFiles && !hasAsset && !hasBlock) return;
e.preventDefault();
if (hasAsset || hasBlock) e.dataTransfer.dropEffect = "copy";
setIsDragOver(true);
}, []);
const handleAssetDrop = useCallback(
// fallow-ignore-next-line complexity
(e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const scroll = scrollRef.current;
const rect = scroll?.getBoundingClientRect();
const dropInput = {
rectLeft: rect?.left ?? 0,
rectTop: rect?.top ?? 0,
scrollLeft: scroll?.scrollLeft ?? 0,
scrollTop: scroll?.scrollTop ?? 0,
pixelsPerSecond: ppsRef.current,
duration: durationRef.current,
trackHeight: TRACK_H,
trackOrder: trackOrderRef.current,
};
if (onFileDrop && e.dataTransfer.files.length > 0) {
void onFileDrop(
Array.from(e.dataTransfer.files),
scroll && rect ? resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY) : undefined,
);
return;
}
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
if (assetPayload && onAssetDrop && scroll && rect) {
try {
const parsed = JSON.parse(assetPayload) as { path?: string };
if (parsed.path)
void onAssetDrop(
parsed.path,
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
);
} catch {
/* ignore malformed drag payloads */
}
return;
}
const blockPayload = e.dataTransfer.getData(TIMELINE_BLOCK_MIME);
if (blockPayload && onBlockDrop && scroll && rect) {
try {
const parsed = JSON.parse(blockPayload) as { name?: string };
if (parsed.name)
void onBlockDrop(
parsed.name,
resolveTimelineAssetDrop(dropInput, e.clientX, e.clientY),
);
} catch {
/* ignore malformed drag payloads */
}
}
},
[onAssetDrop, onBlockDrop, onFileDrop, scrollRef, ppsRef, durationRef, trackOrderRef],
);
return { isDragOver, setIsDragOver, handleAssetDragOver, handleAssetDrop };
}