Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions packages/studio/src/components/timeline/EditModal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div ref={popoverRef} style={style}>
<div className="w-80 bg-neutral-900 border border-neutral-700/60 rounded-xl shadow-2xl shadow-black/40 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800/60">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
<span className="text-[11px] font-medium text-neutral-300">
{formatTime(start)} — {formatTime(end)}
</span>
</div>
<span className="text-[10px] text-neutral-600">
{elementsInRange.length} element{elementsInRange.length !== 1 ? "s" : ""}
</span>
</div>

{/* Elements */}
{elementsInRange.length > 0 && (
<div className="px-4 py-2 border-b border-neutral-800/40 max-h-24 overflow-y-auto">
{elementsInRange.map((el) => (
<div key={el.id} className="flex items-center justify-between py-0.5">
<span className="text-[10px] font-mono text-blue-400/80">#{el.id}</span>
<span className="text-[10px] text-neutral-600">{el.tag}</span>
</div>
))}
</div>
)}

{/* Prompt */}
<div className="p-3">
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleCopy();
}
}}
placeholder="What should change?"
rows={2}
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-blue-500/40 transition-colors"
/>
</div>

{/* Action */}
<div className="px-3 pb-3">
<button
onClick={handleCopy}
className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
copied
? "bg-green-500/20 text-green-400 border border-green-500/30"
: "bg-blue-500/15 text-blue-400 border border-blue-500/25 hover:bg-blue-500/25"
}`}
>
{copied ? "Copied!" : "Copy to Agent"}
{!copied && <span className="text-[9px] text-blue-400/50 ml-1.5">Cmd+Enter</span>}
</button>
</div>
</div>
</div>
);
}
122 changes: 118 additions & 4 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useRef, useMemo, useCallback, useState, memo, type ReactNode, useEffect
import { usePlayerStore, liveTime } from "../store/playerStore";
import { useMountEffect } from "../lib/useMountEffect";
import { TimelineClip } from "./TimelineClip";
import { EditPopover } from "../../components/timeline/EditModal";

/* ── Layout ─────────────────────────────────────────────────────── */
const GUTTER = 32;
Expand Down Expand Up @@ -197,6 +198,28 @@ export const Timeline = memo(function Timeline({
const scrollRef = useRef<HTMLDivElement>(null);
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
const isDragging = useRef(false);
// Range selection (Shift+drag)
const [shiftHeld, setShiftHeld] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(true);
const up = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(false);
window.addEventListener("keydown", down);
window.addEventListener("keyup", up);
window.addEventListener("blur", () => setShiftHeld(false));
return () => {
window.removeEventListener("keydown", down);
window.removeEventListener("keyup", up);
};
}, []);
const isRangeSelecting = useRef(false);
const rangeAnchorTime = useRef(0);
const [rangeSelection, setRangeSelection] = useState<{
start: number;
end: number;
anchorX: number;
anchorY: number;
} | null>(null);
const [showPopover, setShowPopover] = useState(false);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);

Expand Down Expand Up @@ -328,21 +351,61 @@ export const Timeline = memo(function Timeline({
(e: React.PointerEvent) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (e.button !== 0) return;
isDragging.current = true;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);

// Shift+click starts range selection
if (e.shiftKey) {
isRangeSelecting.current = true;
setShowPopover(false);
const rect = scrollRef.current?.getBoundingClientRect();
if (rect) {
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
const time = Math.max(0, x / pps);
rangeAnchorTime.current = time;
setRangeSelection({ start: time, end: time, anchorX: e.clientX, anchorY: e.clientY });
}
return;
}

isDragging.current = true;
setRangeSelection(null);
setShowPopover(false);
seekFromX(e.clientX);
},
[seekFromX],
[seekFromX, pps],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (isRangeSelecting.current) {
const rect = scrollRef.current?.getBoundingClientRect();
if (rect) {
const x = e.clientX - rect.left + (scrollRef.current?.scrollLeft ?? 0) - GUTTER;
const time = Math.max(0, x / pps);
setRangeSelection((prev) =>
prev ? { ...prev, end: time, anchorX: e.clientX, anchorY: e.clientY } : null,
);
}
return;
}
if (!isDragging.current) return;
seekFromX(e.clientX);
autoScrollDuringDrag(e.clientX);
},
[seekFromX, autoScrollDuringDrag],
[seekFromX, autoScrollDuringDrag, pps],
);
const handlePointerUp = useCallback(() => {
if (isRangeSelecting.current) {
isRangeSelecting.current = false;
// Show popover if range is meaningful (> 0.2s)
setRangeSelection((prev) => {
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
setShowPopover(true);
return prev;
}
return null;
});
return;
}
isDragging.current = false;
cancelAnimationFrame(dragScrollRaf.current);
}, []);
Expand Down Expand Up @@ -471,7 +534,7 @@ export const Timeline = memo(function Timeline({
<div
ref={setContainerRef}
aria-label="Timeline"
className="border-t border-neutral-800/50 bg-[#0a0a0b] select-none cursor-crosshair h-full overflow-hidden"
className={`border-t border-neutral-800/50 bg-[#0a0a0b] select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
style={{ touchAction: "pan-x pan-y" }}
>
<div
Expand Down Expand Up @@ -510,6 +573,14 @@ export const Timeline = memo(function Timeline({
className="relative border-b border-neutral-800/40 overflow-hidden"
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
>
{/* Shift hint */}
{shiftHeld && !rangeSelection && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<span className="text-[9px] text-blue-400/60 font-medium">
Drag to select range
</span>
</div>
)}
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
<div className="w-px h-[3px] bg-neutral-700/40" />
Expand Down Expand Up @@ -627,6 +698,23 @@ export const Timeline = memo(function Timeline({
);
})}

{/* Range selection highlight */}
{rangeSelection && (
<div
className="absolute pointer-events-none"
style={{
left: GUTTER + Math.min(rangeSelection.start, rangeSelection.end) * pps,
width: Math.abs(rangeSelection.end - rangeSelection.start) * pps,
top: RULER_H,
bottom: 0,
backgroundColor: "rgba(59, 130, 246, 0.12)",
borderLeft: "1px solid rgba(59, 130, 246, 0.4)",
borderRight: "1px solid rgba(59, 130, 246, 0.4)",
zIndex: 50,
}}
/>
)}

{/* Playhead — z-[100] to stay above all clips (which use z-1 to z-10) */}
<div
ref={playheadRef}
Expand Down Expand Up @@ -661,6 +749,32 @@ export const Timeline = memo(function Timeline({
</div>
</div>
</div>

{/* Keyboard shortcut hint — always visible */}
{!showPopover && !rangeSelection && (
<div className="absolute bottom-2 right-3 pointer-events-none">
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-neutral-800/50 border border-neutral-700/20">
<kbd className="text-[9px] font-mono text-neutral-500 bg-neutral-700/40 px-1 py-0.5 rounded">
Shift
</kbd>
<span className="text-[9px] text-neutral-600">+ drag to edit range</span>
</div>
</div>
)}

{/* Edit range popover */}
{showPopover && rangeSelection && (
<EditPopover
rangeStart={rangeSelection.start}
rangeEnd={rangeSelection.end}
anchorX={rangeSelection.anchorX}
anchorY={rangeSelection.anchorY}
onClose={() => {
setShowPopover(false);
setRangeSelection(null);
}}
/>
)}
</div>
);
});
Loading