mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-05 05:37:09 +00:00
Merge pull request #687 from heygen-com/feat/studio-shader-loading-state
feat: show composition loading in Studio
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldDisableTimelineWhileCompositionLoading } from "./NLELayout";
|
||||
|
||||
describe("timeline loading disable state", () => {
|
||||
it("disables the timeline while the composition loading overlay is visible", () => {
|
||||
expect(shouldDisableTimelineWhileCompositionLoading(true)).toBe(true);
|
||||
});
|
||||
|
||||
it("reenables the timeline after composition loading finishes", () => {
|
||||
expect(shouldDisableTimelineWhileCompositionLoading(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,10 @@ const MIN_TIMELINE_H = 100;
|
||||
const DEFAULT_TIMELINE_H = 220;
|
||||
const MIN_PREVIEW_H = 120;
|
||||
|
||||
export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
|
||||
return compositionLoading;
|
||||
}
|
||||
|
||||
export const NLELayout = memo(function NLELayout({
|
||||
projectId,
|
||||
portrait,
|
||||
@@ -214,6 +218,8 @@ export const NLELayout = memo(function NLELayout({
|
||||
|
||||
// Resizable timeline height
|
||||
const [timelineH, setTimelineH] = useState(DEFAULT_TIMELINE_H);
|
||||
const [compositionLoading, setCompositionLoading] = useState(true);
|
||||
const timelineDisabled = shouldDisableTimelineWhileCompositionLoading(compositionLoading);
|
||||
const isTimelineVisible = timelineVisible ?? true;
|
||||
const isDragging = useRef(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -327,23 +333,31 @@ export const NLELayout = memo(function NLELayout({
|
||||
}, [activeCompositionPath, projectId, updateCompositionStack]);
|
||||
|
||||
// Resize divider handlers
|
||||
const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
const handleDividerPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (timelineDisabled) return;
|
||||
e.preventDefault();
|
||||
isDragging.current = true;
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
},
|
||||
[timelineDisabled],
|
||||
);
|
||||
|
||||
const handleDividerPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!isDragging.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const containerH = rect.height;
|
||||
const newTimelineH = Math.max(
|
||||
MIN_TIMELINE_H,
|
||||
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
||||
);
|
||||
setTimelineH(newTimelineH);
|
||||
}, []);
|
||||
const handleDividerPointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (timelineDisabled) return;
|
||||
if (!isDragging.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const mouseY = e.clientY - rect.top;
|
||||
const containerH = rect.height;
|
||||
const newTimelineH = Math.max(
|
||||
MIN_TIMELINE_H,
|
||||
Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
|
||||
);
|
||||
setTimelineH(newTimelineH);
|
||||
},
|
||||
[timelineDisabled],
|
||||
);
|
||||
|
||||
const handleDividerPointerUp = useCallback(() => {
|
||||
isDragging.current = false;
|
||||
@@ -374,6 +388,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
projectId={projectId}
|
||||
iframeRef={iframeRef}
|
||||
onIframeLoad={onIframeLoad}
|
||||
onCompositionLoadingChange={setCompositionLoading}
|
||||
portrait={portrait}
|
||||
directUrl={directUrl}
|
||||
refreshKey={refreshKey}
|
||||
@@ -388,7 +403,7 @@ export const NLELayout = memo(function NLELayout({
|
||||
onNavigate={handleNavigateComposition}
|
||||
/>
|
||||
)}
|
||||
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
|
||||
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} disabled={timelineDisabled} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -406,13 +421,18 @@ export const NLELayout = memo(function NLELayout({
|
||||
</div>
|
||||
|
||||
{/* Timeline section — fixed height, resizable */}
|
||||
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
|
||||
<div
|
||||
className="relative flex flex-col flex-shrink-0"
|
||||
style={{ height: timelineH }}
|
||||
aria-disabled={timelineDisabled || undefined}
|
||||
>
|
||||
{/* Timeline tracks */}
|
||||
<div
|
||||
// flex-col: toolbar takes natural height, Timeline fills remainder.
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden bg-neutral-950"
|
||||
onDoubleClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
if (timelineDisabled) return;
|
||||
if (compositionStack.length > 1) {
|
||||
updateCompositionStack((prev) => prev.slice(0, -1));
|
||||
}
|
||||
@@ -435,9 +455,20 @@ export const NLELayout = memo(function NLELayout({
|
||||
layerChildCounts={timelineLayerChildCounts}
|
||||
thumbnailedElementIds={thumbnailedTimelineElementIds}
|
||||
onToggleElementThumbnail={onToggleTimelineElementThumbnail}
|
||||
disabled={timelineDisabled}
|
||||
/>
|
||||
</div>
|
||||
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
||||
{timelineDisabled && (
|
||||
<div
|
||||
className="absolute inset-0 z-30 cursor-not-allowed bg-black/18"
|
||||
data-testid="timeline-loading-disabled-overlay"
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : onToggleTimeline ? (
|
||||
|
||||
@@ -5,6 +5,7 @@ interface NLEPreviewProps {
|
||||
projectId: string;
|
||||
iframeRef: Ref<HTMLIFrameElement>;
|
||||
onIframeLoad: () => void;
|
||||
onCompositionLoadingChange?: (loading: boolean) => void;
|
||||
portrait?: boolean;
|
||||
directUrl?: string;
|
||||
refreshKey?: number;
|
||||
@@ -36,6 +37,7 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
projectId,
|
||||
iframeRef,
|
||||
onIframeLoad,
|
||||
onCompositionLoadingChange,
|
||||
portrait,
|
||||
directUrl,
|
||||
refreshKey,
|
||||
@@ -88,6 +90,7 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
projectId={directUrl ? undefined : projectId}
|
||||
directUrl={directUrl}
|
||||
onLoad={retiringKey ? handleNewPlayerLoad : onIframeLoad}
|
||||
onCompositionLoadingChange={onCompositionLoadingChange}
|
||||
portrait={portrait}
|
||||
style={retiringKey ? { position: "absolute", inset: 0, zIndex: 1 } : undefined}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shouldShowCompositionLoadingOverlay } from "./Player";
|
||||
|
||||
describe("composition loading overlay", () => {
|
||||
it("shows while the composition is loading", () => {
|
||||
expect(shouldShowCompositionLoadingOverlay(true)).toBe(true);
|
||||
});
|
||||
|
||||
it("hides after the composition is ready", () => {
|
||||
expect(shouldShowCompositionLoadingOverlay(false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ interface PlayerProps {
|
||||
projectId?: string;
|
||||
directUrl?: string;
|
||||
onLoad: () => void;
|
||||
onCompositionLoadingChange?: (loading: boolean) => void;
|
||||
portrait?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
@@ -31,6 +32,10 @@ function getShaderTransitionLoading(event: Event): boolean | null {
|
||||
return state.loading === true && state.ready !== true;
|
||||
}
|
||||
|
||||
export function shouldShowCompositionLoadingOverlay(compositionLoading: boolean): boolean {
|
||||
return compositionLoading;
|
||||
}
|
||||
|
||||
function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
||||
const root = player.shadowRoot;
|
||||
if (!root) return;
|
||||
@@ -84,7 +89,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
|
||||
* timeline probing, and DOM inspection.
|
||||
*/
|
||||
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
({ projectId, directUrl, onLoad, portrait, style }, ref) => {
|
||||
({ projectId, directUrl, onLoad, onCompositionLoadingChange, portrait, style }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const loadCountRef = useRef(0);
|
||||
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -93,6 +98,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
|
||||
const [assetOverlayFading, setAssetOverlayFading] = useState(false);
|
||||
const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
|
||||
const [compositionLoading, setCompositionLoading] = useState(true);
|
||||
|
||||
useMountEffect(() => {
|
||||
const container = containerRef.current;
|
||||
@@ -138,10 +144,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
};
|
||||
player.addEventListener("shadertransitionstate", handleShaderTransitionState);
|
||||
|
||||
const handleReady = () => {
|
||||
setCompositionLoading(false);
|
||||
};
|
||||
const handleError = () => {
|
||||
setCompositionLoading(false);
|
||||
};
|
||||
player.addEventListener("ready", handleReady);
|
||||
player.addEventListener("error", handleError);
|
||||
|
||||
// Forward the iframe's native load event to the studio's onIframeLoad.
|
||||
const handleLoad = () => {
|
||||
loadCountRef.current++;
|
||||
setShaderTransitionLoading(false);
|
||||
setCompositionLoading(true);
|
||||
// Reveal animation on reload (hot-reload, composition switch)
|
||||
if (loadCountRef.current > 1) {
|
||||
container.classList.remove("preview-revealing");
|
||||
@@ -192,6 +208,8 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
iframe.removeEventListener("load", handleLoad);
|
||||
player.removeEventListener("click", preventToggle, { capture: true });
|
||||
player.removeEventListener("shadertransitionstate", handleShaderTransitionState);
|
||||
player.removeEventListener("ready", handleReady);
|
||||
player.removeEventListener("error", handleError);
|
||||
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
||||
assetPollRef.current = null;
|
||||
container.removeChild(player);
|
||||
@@ -237,7 +255,13 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
};
|
||||
}, [assetsLoading]);
|
||||
|
||||
const showAssetOverlay = assetOverlayVisible && !shaderTransitionLoading;
|
||||
const showCompositionOverlay = shouldShowCompositionLoadingOverlay(compositionLoading);
|
||||
const showAssetOverlay =
|
||||
assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
|
||||
|
||||
useEffect(() => {
|
||||
onCompositionLoadingChange?.(showCompositionOverlay);
|
||||
}, [onCompositionLoadingChange, showCompositionOverlay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -245,6 +269,23 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
||||
style={style}
|
||||
>
|
||||
<div ref={containerRef} className="w-full h-full" />
|
||||
{showCompositionOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black flex items-center justify-center z-30 select-none"
|
||||
data-hyperframes-ignore=""
|
||||
data-testid="composition-loading-overlay"
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<HyperframesLoader
|
||||
title="Loading composition"
|
||||
detail="Preparing the Studio preview."
|
||||
size={56}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showAssetOverlay && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black flex items-center justify-center z-20 select-none"
|
||||
|
||||
@@ -26,11 +26,13 @@ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth:
|
||||
interface PlayerControlsProps {
|
||||
onTogglePlay: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const PlayerControls = memo(function PlayerControls({
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
disabled = false,
|
||||
}: PlayerControlsProps) {
|
||||
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
||||
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
||||
@@ -57,6 +59,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
const durationRef = useRef(duration);
|
||||
durationRef.current = duration;
|
||||
const controlsDisabled = disabled || !timelineReady;
|
||||
useMountEffect(() => {
|
||||
const updateProgress = (t: number) => {
|
||||
currentTimeRef.current = t;
|
||||
@@ -115,6 +118,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
if (disabled) return;
|
||||
const bar = seekBarRef.current;
|
||||
if (!bar || duration <= 0) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
@@ -125,7 +129,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
||||
onSeek(percent * duration);
|
||||
},
|
||||
[duration, onSeek],
|
||||
[disabled, duration, onSeek],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
@@ -204,7 +208,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!timelineReady || duration <= 0) return;
|
||||
if (disabled || !timelineReady || duration <= 0) return;
|
||||
const step = e.shiftKey ? 10 : 1;
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
@@ -214,14 +218,15 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
|
||||
}
|
||||
},
|
||||
[timelineReady, duration, onSeek],
|
||||
[disabled, timelineReady, duration, onSeek],
|
||||
);
|
||||
|
||||
const commitJumpFrame = useCallback(() => {
|
||||
if (disabled) return;
|
||||
const frame = Number.parseInt(jumpFrame, 10);
|
||||
if (!Number.isFinite(frame) || duration <= 0) return;
|
||||
onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
|
||||
}, [duration, jumpFrame, onSeek]);
|
||||
}, [disabled, duration, jumpFrame, onSeek]);
|
||||
|
||||
const handleJumpSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
@@ -243,6 +248,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
return (
|
||||
<div
|
||||
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
|
||||
aria-disabled={disabled || undefined}
|
||||
style={{
|
||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
// Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
|
||||
@@ -256,7 +262,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
type="button"
|
||||
aria-label={isPlaying ? "Pause" : "Play"}
|
||||
onClick={onTogglePlay}
|
||||
disabled={!timelineReady}
|
||||
disabled={controlsDisabled}
|
||||
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
||||
style={{ background: "rgba(255,255,255,0.06)" }}
|
||||
>
|
||||
@@ -293,12 +299,15 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
(sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
}}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-label="Seek"
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.round(duration)}
|
||||
aria-valuenow={0}
|
||||
className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
|
||||
className={`min-w-[96px] flex-1 h-6 flex items-center group ${
|
||||
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||
}`}
|
||||
// `touch-action: none` tells the browser we're handling every
|
||||
// pointer gesture on this element ourselves. Without it, iOS
|
||||
// Safari consumes horizontal swipes for its own swipe-back-to-
|
||||
@@ -334,6 +343,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSpeedMenu((v) => !v)}
|
||||
disabled={disabled}
|
||||
className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
||||
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
||||
>
|
||||
@@ -374,6 +384,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoopEnabled(!loopEnabled)}
|
||||
disabled={disabled}
|
||||
className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
|
||||
loopEnabled
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
@@ -389,6 +400,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
|
||||
disabled={disabled}
|
||||
className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
|
||||
title="Toggle time/frame display"
|
||||
aria-label="Toggle time and frame display"
|
||||
@@ -403,6 +415,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
<input
|
||||
value={jumpFrame}
|
||||
onChange={(e) => setJumpFrame(e.target.value)}
|
||||
disabled={disabled}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
aria-label="Jump to frame"
|
||||
|
||||
@@ -340,6 +340,7 @@ interface TimelineProps {
|
||||
layerChildCounts?: ReadonlyMap<string, number>;
|
||||
thumbnailedElementIds?: ReadonlySet<string>;
|
||||
onToggleElementThumbnail?: (element: import("../store/playerStore").TimelineElement) => void;
|
||||
disabled?: boolean;
|
||||
theme?: Partial<TimelineTheme>;
|
||||
}
|
||||
|
||||
@@ -393,6 +394,7 @@ export const Timeline = memo(function Timeline({
|
||||
layerChildCounts,
|
||||
thumbnailedElementIds,
|
||||
onToggleElementThumbnail,
|
||||
disabled = false,
|
||||
theme: themeOverrides,
|
||||
}: TimelineProps = {}) {
|
||||
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
||||
@@ -412,6 +414,8 @@ export const Timeline = memo(function Timeline({
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
|
||||
const isDragging = useRef(false);
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
const shiftClickClipRef = useRef<{
|
||||
element: TimelineElement;
|
||||
anchorX: number;
|
||||
@@ -498,6 +502,19 @@ export const Timeline = memo(function Timeline({
|
||||
if (shortcutHintRafRef.current) cancelAnimationFrame(shortcutHintRafRef.current);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!disabled) return;
|
||||
stopClipDragAutoScrollRef.current();
|
||||
isDragging.current = false;
|
||||
isRangeSelecting.current = false;
|
||||
blockedClipRef.current = null;
|
||||
setDraggedClip(null);
|
||||
setResizingClip(null);
|
||||
setRangeSelection(null);
|
||||
setShowPopover(false);
|
||||
setIsDragOver(false);
|
||||
}, [disabled]);
|
||||
|
||||
// Effective duration: max of store duration and the furthest element end.
|
||||
// processTimelineMessage updates elements but not duration, so elements can
|
||||
// extend beyond the store's duration — this ensures fit mode shows everything.
|
||||
@@ -724,6 +741,7 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
const seekFromX = useCallback(
|
||||
(clientX: number) => {
|
||||
if (disabledRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el || effectiveDuration <= 0) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
@@ -781,6 +799,7 @@ export const Timeline = memo(function Timeline({
|
||||
};
|
||||
|
||||
const handleWindowPointerMove = (e: PointerEvent) => {
|
||||
if (disabledRef.current) return;
|
||||
const drag = draggedClipRef.current;
|
||||
const resize = resizingClipRef.current;
|
||||
const blocked = blockedClipRef.current;
|
||||
@@ -865,6 +884,7 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
const handleWindowPointerUp = () => {
|
||||
stopClipDragAutoScrollRef.current();
|
||||
if (disabledRef.current) return;
|
||||
const resize = resizingClipRef.current;
|
||||
if (resize) {
|
||||
resizingClipRef.current = null;
|
||||
@@ -958,6 +978,7 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
useMountEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (disabledRef.current) return;
|
||||
if (!shouldHandleTimelineDeleteKey(event)) return;
|
||||
const selected = selectedElementRef.current;
|
||||
const onDelete = onDeleteElementRef.current;
|
||||
@@ -980,6 +1001,10 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (disabledRef.current) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
|
||||
// Shift+click starts range selection — even on clips
|
||||
@@ -1011,6 +1036,7 @@ export const Timeline = memo(function Timeline({
|
||||
);
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (disabledRef.current) return;
|
||||
if (isRangeSelecting.current) {
|
||||
const rect = scrollRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
@@ -1081,6 +1107,7 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (disabledRef.current) return;
|
||||
const hasFiles = e.dataTransfer.files.length > 0;
|
||||
const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME);
|
||||
if (!hasFiles && !hasAsset) return;
|
||||
@@ -1095,6 +1122,7 @@ export const Timeline = memo(function Timeline({
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (disabledRef.current) return;
|
||||
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
||||
const scroll = scrollRef.current;
|
||||
const rect = scroll?.getBoundingClientRect();
|
||||
@@ -1151,6 +1179,7 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
const handlePinchWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
if (disabledRef.current) return;
|
||||
if (!e.ctrlKey) return;
|
||||
const scroll = scrollRef.current;
|
||||
if (!scroll || durationRef.current <= 0 || fitPpsRef.current <= 0 || ppsRef.current <= 0) {
|
||||
@@ -1206,6 +1235,7 @@ export const Timeline = memo(function Timeline({
|
||||
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
||||
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
|
||||
}`}
|
||||
aria-disabled={disabled || undefined}
|
||||
onDragOver={handleAssetDragOver}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleAssetDrop}
|
||||
@@ -1361,7 +1391,14 @@ export const Timeline = memo(function Timeline({
|
||||
<div
|
||||
ref={setContainerRef}
|
||||
aria-label="Timeline"
|
||||
className={`relative border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={`relative border-t select-none h-full overflow-hidden transition-opacity ${
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-45"
|
||||
: shiftHeld
|
||||
? "cursor-crosshair"
|
||||
: "cursor-default"
|
||||
}`}
|
||||
style={{
|
||||
touchAction: "pan-x pan-y",
|
||||
background: theme.shellBackground,
|
||||
|
||||
Reference in New Issue
Block a user