mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): add Edit Range toolbar with Copy to Agent (#65)
## Summary - Replace Split/Delete buttons with Edit Range toolbar - Edit button opens a time-range selection on the timeline - "Copy to Agent" exports the selected range as a prompt-ready description - Range selection shows start/end times with drag handles 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
@@ -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
|
||||
@@ -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" />
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user