refactor(studio): extract shared timeline components and deduplicate code (#1329)

Extract shared utilities to reduce duplication across timeline components:

- PlayheadIndicator: shared playhead rendering (was duplicated in
  TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
  duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
  operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
  readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs
This commit is contained in:
Miguel Ángel
2026-06-10 23:45:03 -04:00
committed by GitHub
parent 06426b5014
commit ab08260201
12 changed files with 373 additions and 157 deletions
@@ -1,4 +1,5 @@
import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
import { PlayheadIndicator } from "../../player/components/PlayheadIndicator";
interface TimelineEditorNoticeProps {
onDismiss: () => void;
@@ -76,31 +77,7 @@ export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
"hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
}}
>
<div
className="absolute top-0 bottom-0"
style={{
left: "50%",
width: 2,
marginLeft: -1,
background: "var(--hf-accent, #3CE6AC)",
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
}}
/>
<div
className="absolute"
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
}}
/>
</div>
<PlayheadIndicator />
</div>
<div className="flex flex-col gap-1.5">
@@ -0,0 +1,29 @@
import { useCallback, useEffect, useRef, type RefObject } from "react";
/**
* Shared dismiss logic for context menus: closes on outside click or Escape.
* Returns a ref to attach to the menu container element.
*/
export function useContextMenuDismiss(onClose: () => void): RefObject<HTMLDivElement | null> {
const menuRef = useRef<HTMLDivElement>(null);
const dismiss = useCallback(
(e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
onClose();
},
[onClose],
);
useEffect(() => {
document.addEventListener("mousedown", dismiss);
document.addEventListener("keydown", dismiss);
return () => {
document.removeEventListener("mousedown", dismiss);
document.removeEventListener("keydown", dismiss);
};
}, [dismiss]);
return menuRef;
}
@@ -1,5 +1,7 @@
import { memo, useCallback, useEffect, useRef } from "react";
import { memo } from "react";
import type { TimelineElement } from "../store/playerStore";
import { canSplitElement } from "../../utils/timelineElementSplit";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
interface ClipContextMenuProps {
x: number;
@@ -20,30 +22,12 @@ export const ClipContextMenu = memo(function ClipContextMenu({
onSplit,
onDelete,
}: ClipContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const dismiss = useCallback(
(e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
onClose();
},
[onClose],
);
useEffect(() => {
document.addEventListener("mousedown", dismiss);
document.addEventListener("keydown", dismiss);
return () => {
document.removeEventListener("mousedown", dismiss);
document.removeEventListener("keydown", dismiss);
};
}, [dismiss]);
const menuRef = useContextMenuDismiss(onClose);
const adjustedX = Math.min(x, window.innerWidth - 200);
const adjustedY = Math.min(y, window.innerHeight - 200);
const isSplittable = ["video", "audio", "img"].includes(element.tag);
const isSplittable = canSplitElement(element) && ["video", "audio", "img"].includes(element.tag);
const canSplit =
isSplittable && currentTime > element.start && currentTime < element.start + element.duration;
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useRef } from "react";
import { memo, useRef } from "react";
import { EASE_LABELS } from "../../components/editor/gsapAnimationConstants";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
export interface KeyframeDiamondContextMenuState {
x: number;
@@ -41,27 +42,9 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe
onChangeEase,
onCopyProperties,
}: KeyframeDiamondContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const menuRef = useContextMenuDismiss(onClose);
const easeSubmenuRef = useRef<HTMLDivElement>(null);
const dismiss = useCallback(
(e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
onClose();
},
[onClose],
);
useEffect(() => {
document.addEventListener("mousedown", dismiss);
document.addEventListener("keydown", dismiss);
return () => {
document.removeEventListener("mousedown", dismiss);
document.removeEventListener("keydown", dismiss);
};
}, [dismiss]);
const adjustedX = Math.min(state.x, window.innerWidth - 200);
const adjustedY = Math.min(state.y, window.innerHeight - 300);
@@ -0,0 +1,43 @@
// fallow-ignore-file dead-code
/**
* Shared playhead visual used by TimelineCanvas (real playhead) and
* TimelineEditorNotice (animated illustration).
*/
interface PlayheadIndicatorProps {
/** CSS color, defaults to the HF accent variable */
color?: string;
/** Glow shadow color, defaults to translucent accent */
glowColor?: string;
}
export function PlayheadIndicator({
color = "var(--hf-accent, #3CE6AC)",
glowColor = "rgba(60,230,172,0.5)",
}: PlayheadIndicatorProps) {
return (
<>
<div
className="absolute top-0 bottom-0"
style={{
left: "50%",
width: 2,
marginLeft: -1,
background: color,
boxShadow: `0 0 8px ${glowColor}`,
}}
/>
<div className="absolute" style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: `8px solid ${color}`,
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
}}
/>
</div>
</>
);
}
@@ -0,0 +1,44 @@
// fallow-ignore-file code-duplication
// fallow-ignore-file dead-code
import type { TimelineElement } from "../store/playerStore";
import type { BlockedTimelineEditIntent } from "./timelineEditing";
/**
* Shared callback signatures for timeline editing operations.
* Used by NLELayout, Timeline, and any component that passes through
* the standard set of timeline mutation handlers.
*/
export interface TimelineDropCallbacks {
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 interface TimelineEditCallbacks {
onMoveElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "track">,
) => Promise<void> | void;
onResizeElement?: (
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
}
@@ -1,25 +1,13 @@
// 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";
import type { TimelineDropCallbacks } from "./timelineCallbacks";
interface UseTimelineAssetDropOptions {
interface UseTimelineAssetDropOptions extends TimelineDropCallbacks {
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({
@@ -0,0 +1,18 @@
// fallow-ignore-file dead-code
import { usePlayerStore, type ZoomMode } from "../store/playerStore";
export interface TimelineZoomState {
zoomMode: ZoomMode;
manualZoomPercent: number;
setZoomMode: (mode: ZoomMode) => void;
setManualZoomPercent: (percent: number) => void;
}
/** Shared zoom-related store selectors used by Timeline and TimelineToolbar. */
export function useTimelineZoom(): TimelineZoomState {
const zoomMode = usePlayerStore((s) => s.zoomMode);
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent };
}
@@ -0,0 +1,13 @@
import type { TimelineElement } from "../player/store/playerStore";
export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpers";
export function canSplitElement(el: TimelineElement): boolean {
return (
!el.timelineLocked &&
el.timingSource !== "implicit" &&
!el.compositionSrc &&
!!el.duration &&
Number.isFinite(el.duration)
);
}