diff --git a/packages/studio/src/components/timeline/EditModal.tsx b/packages/studio/src/components/timeline/EditModal.tsx new file mode 100644 index 00000000..b5523a26 --- /dev/null +++ b/packages/studio/src/components/timeline/EditModal.tsx @@ -0,0 +1,164 @@ +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { formatTime } from "../../player/lib/time"; + +interface EditPopoverProps { + rangeStart: number; + rangeEnd: number; + anchorX: number; + anchorY: number; + onClose: () => void; +} + +export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) { + const elements = usePlayerStore((s) => s.elements); + const [prompt, setPrompt] = useState(""); + const [copied, setCopied] = useState(false); + const popoverRef = useRef(null); + const textareaRef = useRef(null); + + const start = Math.min(rangeStart, rangeEnd); + const end = Math.max(rangeStart, rangeEnd); + + const elementsInRange = useMemo(() => { + return elements.filter((el) => { + const elEnd = el.start + el.duration; + return el.start < end && elEnd > start; + }); + }, [elements, start, end]); + + useEffect(() => { + setTimeout(() => textareaRef.current?.focus(), 50); + }, []); + + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [onClose]); + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + }; + setTimeout(() => window.addEventListener("mousedown", handleClick), 100); + return () => window.removeEventListener("mousedown", handleClick); + }, [onClose]); + + const buildClipboardText = useCallback(() => { + const elementLines = elementsInRange + .map( + (el) => + `- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`, + ) + .join("\n"); + + return `Edit the following HyperFrames composition: + +Time range: ${formatTime(start)} — ${formatTime(end)} + +Elements in range: +${elementLines || "(none)"} + +User request: +${prompt.trim() || "(no prompt provided)"} + +Instructions: +Modify only the elements listed above within the specified time range. +The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations. +Preserve all other elements and timing outside this range.`; + }, [start, end, elementsInRange, prompt]); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(buildClipboardText()); + } catch { + const ta = document.createElement("textarea"); + ta.value = buildClipboardText(); + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } + setCopied(true); + setTimeout(() => { + setCopied(false); + onClose(); + }, 800); + }, [buildClipboardText, onClose]); + + const style: React.CSSProperties = { + position: "fixed", + left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)), + top: Math.max(8, anchorY - 280), + zIndex: 200, + }; + + return ( +
+
+ {/* Header */} +
+
+
+ + {formatTime(start)} — {formatTime(end)} + +
+ + {elementsInRange.length} element{elementsInRange.length !== 1 ? "s" : ""} + +
+ + {/* Elements */} + {elementsInRange.length > 0 && ( +
+ {elementsInRange.map((el) => ( +
+ #{el.id} + {el.tag} +
+ ))} +
+ )} + + {/* Prompt */} +
+