Files
hyperframes/packages/studio/src/player/components/EditModal.tsx
T
Miguel Ángel dac304ed9f refactor(studio): code quality — 22 findings, dead code removal, App.tsx split (#144)
## Summary

Full code quality review of the studio package, fixing 22 of 25 findings. Removes dead code, extracts modules from App.tsx, fixes accessibility and performance issues.

## Critical fixes (3)

- **`aria-valuenow`** on seek bar now updates imperatively via `liveTime.subscribe` — screen readers previously always reported position 0
- **Speed menu** closes on outside click (was permanently stuck open)
- **RenderQueue auto-scroll** moved from render phase to `useEffect` (was violating React render purity via `queueMicrotask` during render)

## Dead code removed (-331 lines)

| File | Lines | Why dead |
|---|---|---|
| `PreviewPanel.tsx` | 180 | Replaced by NLELayout + NLEPreview |
| `useCodeEditor.ts` | 80 | Exported but never imported |
| `formatTick` alias | 2 | Deprecated, unused |
| `onClipChange` prop | 5 | Declared, never used |
| `trackH` prop | 5 | Declared, never used |
| `editRange*` + updaters in store | 60 | Never read or written |

## App.tsx extraction

| Extracted to | Lines | What |
|---|---|---|
| `components/LintModal.tsx` | 130 | Lint results modal + LintFinding type |
| `components/MediaPreview.tsx` | 75 | Image/video/audio/font file previewer |
| `utils/mediaTypes.ts` | 15 | Shared regex constants (App.tsx and AssetsTab.tsx had diverged copies) |

## Performance fixes

- `useMemo` for `compositions`/`assets` derivation from `fileTree`
- `useMemo` for `buildTree(files)` in FileTree
- Debounced `handleContentChange` PUT (600ms — was firing on every keystroke)
- CompositionsTab iframe hover debounced (300ms — was mounting immediately)
- `VideoFrameThumbnail` re-extracts frame when `src` prop changes

## Not addressed (3 — low priority)

- #6: SystemIcons consolidation (large refactor across many files)
- #16-17: Overlay dismiss pattern standardization
- #18: Inline SVG → Phosphor replacement (gradual, per-PR)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-03-31 04:21:12 +02:00

166 lines
5.7 KiB
TypeScript

import { useState, useCallback, useMemo, useRef } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { usePlayerStore } from "../store/playerStore";
import { formatTime } from "../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]);
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(() => {
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-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 */}
<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-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
}`}
>
{copied ? "Copied!" : "Copy to Agent"}
{!copied && <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>}
</button>
</div>
</div>
</div>
);
}