Files
hyperframes/packages/studio/src/player/components/EditModal.tsx
Vance IngallsandClaude Opus 5 a01a5d7b3c fix(studio): player UX — honest waveform, keyframe menu actions, beat-delete gesture (#1967)
Player and timeline fixes from the studio UX review, reconciled against six
weeks of main.

Honest media states:
- AudioWaveform no longer falls back to synthesised sine-wave peaks when a
  decode fails. The failure propagates and the clip renders a dashed flat
  line + "waveform unavailable" instead of a plausible waveform an author
  would trim and beat-align against. Main's thumbnail scheduler already
  caches the failure with a TTL, so this neither refetch-loops nor pins the
  degraded state past a transient error.
- VideoThumbnail renders a static "no preview" placeholder on a failed
  decode rather than resolving to an empty box.

Keyframe context menu, restored:
- "Edit Ease…" (showing the current ease) and "Copy Properties" (async,
  "Copied!"/"Copy failed") were plumbed but never rendered. Edit Ease routes
  to the same focused-ease-segment path a segment click takes, so the menu
  advertises the editor that exists instead of growing a second one; it is
  offered only for a keyframe that names a tween to focus. Copy Properties
  matches the keyframe cache on clip-% with the same tolerance main's
  move-to-playhead uses. Every row is a role="menuitem" with arrow-key
  navigation and focus handling via the new useMenuKeyboardNav helper, and a
  separator now isolates "Delete All Keyframes" from the single delete.

Error prevention:
- Beat dots: hit target 12→24px (WCAG 2.5.8), and delete moves off
  double-click to ⌥-click — a stuttered drag reads as a double-click and
  would destroy the beat. ⌥ starts no drag, so a slipped ⌥-drag abandons
  instead of deleting.
- ShortcutsPanel moves focus into the panel on open and returns it to the
  trigger on close; SpeedMenu's trigger is labelled and reports its popup.

Superseded by main, deliberately dropped: the seek-slider keyboard and
aria-valuenow fixes (the transport no longer owns a seek bar), the Player
load-error inline retry (main's reports the actual message and retries with
a cache-busting src), TimelineClip keyboard selection (main renders a native
button, and this PR's onKeyDown would have preventDefault'ed the synthesized
click), the keyframe-diamond keyboard guard and label (both already on main,
with a richer label), and the waveform's own cache/failure maps (main's
scheduler owns that). TimelineOverlays.tsx is a main-side file edited to
thread the two restored menu actions; BeatStrip.test.tsx tracks the new
gesture and hit target.

Restacked onto main now that PRs 1962-1966 have squash-merged, so this
carries only its own changes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 11:02:47 -07:00

211 lines
7.3 KiB
TypeScript

import { useState, useCallback, useMemo, useRef } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { usePlayerStore } from "../store/playerStore";
import { formatTime } from "../lib/time";
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
import { copyTextToClipboard } from "../../utils/clipboard";
interface EditPopoverProps {
rangeStart: number;
rangeEnd: number;
anchorX: number;
anchorY: number;
onClose: () => void;
}
function draftKey(start: number, end: number): string {
return `hf-edit-draft:${start.toFixed(2)}:${end.toFixed(2)}`;
}
function readDraft(key: string): string {
try {
return sessionStorage.getItem(key) ?? "";
} catch {
return "";
}
}
function writeDraft(key: string, value: string): void {
try {
if (value) sessionStorage.setItem(key, value);
else sessionStorage.removeItem(key);
} catch {
/* storage unavailable — draft persistence degrades gracefully */
}
}
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
const elements = usePlayerStore((s) => s.elements);
const start = Math.min(rangeStart, rangeEnd);
const end = Math.max(rangeStart, rangeEnd);
// Persist the typed prompt per range so Escape/outside-click doesn't destroy it.
const storageKey = draftKey(start, end);
const [prompt, setPromptState] = useState(() => readDraft(storageKey));
const setPrompt = (value: string) => {
setPromptState(value);
writeDraft(storageKey, value);
};
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
const [copyError, setCopyError] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const elementsInRange = useMemo(() => {
return elements.filter((el) => {
const elEnd = el.start + el.duration;
return el.start < end && elEnd > start;
});
}, [elements, start, end]);
useMountEffect(() => {
setTimeout(() => textareaRef.current?.focus(), 50);
});
useMountEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
});
useMountEffect(() => {
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);
});
const buildClipboardText = useCallback(() => {
return buildTimelineAgentPrompt({
rangeStart: start,
rangeEnd: end,
elements: elementsInRange,
prompt,
});
}, [start, end, elementsInRange, prompt]);
const handleCopy = useCallback(async () => {
const copied = await copyTextToClipboard(buildClipboardText());
if (!copied) {
setCopyError(true);
return;
}
setCopyError(false);
writeDraft(storageKey, "");
setCopiedAgentPrompt(true);
setTimeout(() => {
setCopiedAgentPrompt(false);
onClose();
}, 800);
}, [buildClipboardText, onClose, storageKey]);
const handleCopyPrompt = useCallback(async () => {
const promptText = buildPromptCopyText(prompt);
if (!promptText) return;
const copied = await copyTextToClipboard(promptText);
if (!copied) {
setCopyError(true);
return;
}
setCopyError(false);
setCopiedPromptOnly(true);
setTimeout(() => {
setCopiedPromptOnly(false);
}, 800);
}, [prompt]);
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-studio-accent" />
<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-studio-accent/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-studio-accent/40 transition-colors"
/>
</div>
{/* Action */}
{copyError && (
<p className="px-3 pb-2 text-[10px] text-red-400" role="alert">
Copy failed — check clipboard permissions and try again.
</p>
)}
<div className="grid grid-cols-2 gap-2 px-3 pb-3">
<button
onClick={handleCopyPrompt}
disabled={!buildPromptCopyText(prompt)}
className={`py-1.5 text-[11px] font-medium rounded-lg transition-all border ${
copiedPromptOnly
? "bg-green-500/20 text-green-400 border-green-500/30"
: "bg-neutral-800/70 text-neutral-200 border-neutral-700/50 hover:bg-neutral-800"
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{copiedPromptOnly ? "Prompt Copied!" : "Copy Prompt"}
</button>
<button
onClick={handleCopy}
className={`py-1.5 text-[11px] font-medium rounded-lg transition-all ${
copiedAgentPrompt
? "bg-green-500/20 text-green-400 border border-green-500/30"
: "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
}`}
>
{copiedAgentPrompt ? "Copied!" : "Copy to Agent"}
{!copiedAgentPrompt && (
<span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>
)}
</button>
</div>
</div>
</div>
);
}