import { useEffect, useRef } from "react"; import { Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react"; import { useEnableKeyframes, isPlayheadWithinTween, type EnableKeyframesSession, } from "../hooks/useEnableKeyframes"; import { computeElementPercentage } from "../hooks/gsapShared"; import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard"; import { getNextTimelineZoomPercent, getTimelineZoomPercent, timelineZoomPercentToSlider, timelineSliderToZoomPercent, } from "../player/components/timelineZoom"; import { useTimelineZoom } from "../player/components/useTimelineZoom"; import { usePlayerStore, type TimelineElement } from "../player"; import { STUDIO_KEYFRAMES_ENABLED, STUDIO_RAZOR_TOOL_ENABLED, } from "./editor/manualEditingAvailability"; import { Tooltip } from "./ui"; import { Scissors } from "../icons/SystemIcons"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "./editor/domEditingTypes"; import { canSplitElement } from "../utils/timelineElementSplit"; import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions"; interface DomEditSessionSlice extends EnableKeyframesSession { domEditSelection: DomEditSelection | null; selectedGsapAnimations: GsapAnimation[]; } interface TimelineToolbarProps { domEditSession?: DomEditSessionSlice; onSplitElement?: (element: TimelineElement, splitTime: number) => void; } function useKeyframeToggle(session?: DomEditSessionSlice) { const currentTime = usePlayerStore((s) => s.currentTime); const sessionRef = useRef(session); sessionRef.current = session; const onToggle = useEnableKeyframes( sessionRef as React.RefObject, ); if (!session) return { state: "none" as const, onToggle: undefined }; const sel = session.domEditSelection; const anims = session.selectedGsapAnimations; const kfAnim = anims.find((a) => a.keyframes); let state: "active" | "inactive" | "none" = "none"; // Outside the tween, clicking extends the animation to the playhead rather than // toggling a (clamped) edge keyframe — so the button stays an "add" affordance. let willExtend = false; if (kfAnim?.keyframes && sel) { if (!isPlayheadWithinTween(kfAnim, currentTime)) { state = "inactive"; willExtend = true; } else { // Tween-relative percentage (not the clip range) so the button state matches // where the keyframe would actually land. const pct = computeElementPercentage(currentTime, sel, kfAnim); state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) ? "active" : "inactive"; } } return { state, willExtend, onToggle: sel ? onToggle : undefined }; } // fallow-ignore-next-line complexity export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineToolbarProps) { const activeTool = usePlayerStore((s) => s.activeTool); const setActiveTool = usePlayerStore((s) => s.setActiveTool); const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled); const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled); const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled); const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled); // Subscribe so the add-beat button reacts to playhead movement and analysis load. const currentTime = usePlayerStore((s) => s.currentTime); const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null); // Subscribe (not getState) so the split button enables/disables the moment // the selection changes, not only on the next playhead tick. const selectedElementId = usePlayerStore((s) => s.selectedElementId); const elements = usePlayerStore((s) => s.elements); const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent); const { state: keyframeState, willExtend: keyframeWillExtend, onToggle: onToggleKeyframe, } = useKeyframeToggle(domEditSession); // Wire the "Add keyframe (K)" shortcut the toolbar advertises. Active only when // there's a keyframeable selection; otherwise K stays JKL-pause in playback. useKeyframeKeyboard({ enabled: STUDIO_KEYFRAMES_ENABLED && Boolean(onToggleKeyframe), onAddKeyframe: onToggleKeyframe, }); // "N" toggles timeline snapping (industry convention: Resolve/FCP). // Skip when typing in an input/contenteditable. useEffect(() => { // fallow-ignore-next-line complexity const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "n" && e.key !== "N") return; if (e.metaKey || e.ctrlKey || e.altKey) return; const target = e.target instanceof HTMLElement ? e.target : null; if (target?.isContentEditable) return; const tag = target?.tagName?.toLowerCase() ?? ""; if (tag === "input" || tag === "textarea" || tag === "select") return; const store = usePlayerStore.getState(); store.setTimelineSnapEnabled(!store.timelineSnapEnabled); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, []); // CapCut-flat icon buttons: no per-button border/box chrome — a transparent // 28px hit area with a subtle rounded hover wash, consistent 16px glyphs. const flatBtn = "flex h-7 w-7 items-center justify-center rounded-md transition-colors"; const flatIdle = `${flatBtn} text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200 active:scale-[0.98]`; const flatActive = `${flatBtn} bg-white/[0.08] text-neutral-100 active:scale-[0.98]`; const flatDisabled = `${flatBtn} text-neutral-700 cursor-not-allowed`; return ( // The "TIMELINE" label is dropped for CapCut-like density — the pane's // position (tracks right below) makes it self-evident.
{STUDIO_RAZOR_TOOL_ENABLED && ( <> {/* Divider: tool-mode | editing-actions */}
{ setZoomMode("manual"); setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value))); }} className="mx-1 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing" /> {/* Numeric zoom readout (main-parity): "Fit" in fit mode, N% in manual. */} {zoomMode === "fit" ? "Fit" : `${displayedTimelineZoomPercent}%`}
); }