mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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)
This commit is contained in:
@@ -1,18 +1,12 @@
|
||||
/**
|
||||
* CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails.
|
||||
* CompositionThumbnail — Single server-rendered JPEG stretched across the clip.
|
||||
*
|
||||
* Requests multiple thumbnails at different timestamps across the clip duration
|
||||
* and tiles them horizontally — like VideoThumbnail does for video clips.
|
||||
* Each frame is a separate <img> from /api/projects/:id/thumbnail/:path?t=X.
|
||||
*
|
||||
* Uses ResizeObserver to adapt frame count when the clip width changes (zoom).
|
||||
* Takes one screenshot at the midpoint of the clip and covers the full width —
|
||||
* same approach as After Effects for precomps. This avoids the 1-2s per-frame
|
||||
* Puppeteer cost of rendering multiple filmstrip frames.
|
||||
*/
|
||||
|
||||
import { memo, useRef, useState, useCallback } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
|
||||
const CLIP_HEIGHT = 66;
|
||||
const MAX_UNIQUE_FRAMES = 6;
|
||||
import { memo } from "react";
|
||||
|
||||
interface CompositionThumbnailProps {
|
||||
previewUrl: string;
|
||||
@@ -30,95 +24,27 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
||||
labelColor,
|
||||
seekTime = 2,
|
||||
duration = 5,
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
}: CompositionThumbnailProps) {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const setRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect();
|
||||
if (!el) return;
|
||||
|
||||
// Walk up to data-clip parent for accurate width
|
||||
let target: HTMLElement = el;
|
||||
let parent = el.parentElement;
|
||||
let depth = 0;
|
||||
while (parent && !parent.hasAttribute("data-clip") && depth < 5) {
|
||||
parent = parent.parentElement;
|
||||
depth++;
|
||||
}
|
||||
if (parent?.hasAttribute("data-clip")) target = parent;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const w = target.clientWidth || target.getBoundingClientRect().width;
|
||||
if (w > 0) setContainerWidth(w);
|
||||
});
|
||||
|
||||
roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width));
|
||||
roRef.current.observe(target);
|
||||
}, []);
|
||||
|
||||
useMountEffect(() => () => {
|
||||
roRef.current?.disconnect();
|
||||
});
|
||||
|
||||
// Convert preview URL to thumbnail base URL
|
||||
// Single screenshot at the midpoint of the clip
|
||||
const thumbnailBase = previewUrl
|
||||
.replace("/preview/comp/", "/thumbnail/")
|
||||
.replace(/\/preview$/, "/thumbnail/index.html");
|
||||
|
||||
// Calculate frame layout
|
||||
const aspect = width / height;
|
||||
const frameW = Math.round(CLIP_HEIGHT * aspect);
|
||||
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
||||
const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES);
|
||||
|
||||
// Each frame tile represents a real position in the clip.
|
||||
// Offset slightly (0.5s) into each segment to avoid landing on transition
|
||||
// points where content is invisible due to fade-in/fade-out animations.
|
||||
const timestamps: number[] = [];
|
||||
const pad = Math.min(0.5, duration * 0.05);
|
||||
for (let i = 0; i < uniqueFrames; i++) {
|
||||
const frac = uniqueFrames === 1 ? 0.5 : i / (uniqueFrames - 1);
|
||||
const raw = seekTime + frac * duration;
|
||||
// Clamp to [pad, duration - pad] to stay inside visible content
|
||||
timestamps.push(seekTime + Math.max(pad, Math.min(duration - pad, raw - seekTime)));
|
||||
}
|
||||
const midTime = seekTime + duration / 2;
|
||||
const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<div ref={setRef} className="absolute inset-0 overflow-hidden bg-neutral-950">
|
||||
{/* Film strip — each tile maps to its real timeline position */}
|
||||
<div className="absolute inset-0 flex">
|
||||
{Array.from({ length: frameCount }).map((_, i) => {
|
||||
// Map this tile's visual position to a timestamp
|
||||
const tileFrac = frameCount === 1 ? 0.5 : i / (frameCount - 1);
|
||||
const t = seekTime + tileFrac * duration;
|
||||
// Use the nearest cached unique frame
|
||||
const uniqueIdx = Math.min(Math.round(tileFrac * (uniqueFrames - 1)), uniqueFrames - 1);
|
||||
const cachedT = timestamps[uniqueIdx];
|
||||
const url = `${thumbnailBase}?t=${(cachedT ?? t).toFixed(2)}`;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
|
||||
style={{ width: frameW }}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "1";
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="absolute inset-0 overflow-hidden bg-neutral-950">
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
onLoad={(e) => {
|
||||
(e.target as HTMLImageElement).style.opacity = "1";
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{ opacity: 0, transition: "opacity 200ms ease-out" }}
|
||||
/>
|
||||
|
||||
{/* Label */}
|
||||
<div
|
||||
|
||||
@@ -105,7 +105,7 @@ Preserve all other elements and timing outside this range.`;
|
||||
{/* 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" />
|
||||
<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>
|
||||
@@ -120,7 +120,7 @@ Preserve all other elements and timing outside this range.`;
|
||||
<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] font-mono text-studio-accent/80">#{el.id}</span>
|
||||
<span className="text-[10px] text-neutral-600">{el.tag}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -141,7 +141,7 @@ Preserve all other elements and timing outside this range.`;
|
||||
}}
|
||||
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"
|
||||
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>
|
||||
|
||||
@@ -152,11 +152,11 @@ Preserve all other elements and timing outside this range.`;
|
||||
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"
|
||||
: "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-blue-400/50 ml-1.5">Cmd+Enter</span>}
|
||||
{!copied && <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState, useCallback, memo } from "react";
|
||||
import { useRef, useState, useCallback, useEffect, memo } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
||||
@@ -30,6 +30,8 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
const progressThumbRef = useRef<HTMLDivElement>(null);
|
||||
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
||||
const seekBarRef = useRef<HTMLDivElement>(null);
|
||||
const sliderRef = useRef<HTMLDivElement>(null);
|
||||
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const currentTimeRef = useRef(0);
|
||||
|
||||
@@ -43,6 +45,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
||||
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
||||
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
|
||||
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
|
||||
};
|
||||
const unsub = liveTime.subscribe(updateProgress);
|
||||
updateProgress(usePlayerStore.getState().currentTime);
|
||||
@@ -64,6 +67,22 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSpeedMenu) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
speedMenuContainerRef.current &&
|
||||
!speedMenuContainerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowSpeedMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
};
|
||||
}, [showSpeedMenu]);
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const bar = seekBarRef.current;
|
||||
@@ -153,7 +172,10 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
|
||||
{/* Seek bar — teal progress fill */}
|
||||
<div
|
||||
ref={seekBarRef}
|
||||
ref={(el) => {
|
||||
(seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
(sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
}}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Seek"
|
||||
@@ -188,7 +210,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
</div>
|
||||
|
||||
{/* Speed control */}
|
||||
<div className="relative flex-shrink-0">
|
||||
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSpeedMenu((v) => !v)}
|
||||
@@ -235,7 +257,7 @@ export const PlayerControls = memo(function PlayerControls({
|
||||
onClick={onToggleTimeline}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
|
||||
timelineVisible
|
||||
? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30"
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
|
||||
}`}
|
||||
title={timelineVisible ? "Hide timeline" : "Show timeline"}
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import { Player } from "./Player";
|
||||
import { PlayerControls } from "./PlayerControls";
|
||||
import { Timeline } from "./Timeline";
|
||||
|
||||
interface RenderStatus {
|
||||
state: "idle" | "rendering" | "complete" | "error";
|
||||
stage?: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
onRender?: () => void;
|
||||
}
|
||||
|
||||
interface PreviewPanelProps {
|
||||
projectId: string | null;
|
||||
hasProject: boolean;
|
||||
portrait: boolean;
|
||||
iframeRef: Ref<HTMLIFrameElement>;
|
||||
onIframeLoad: () => void;
|
||||
onTogglePlay: () => void;
|
||||
onSeek: (t: number) => void;
|
||||
/** Optional render status — pass to show rendering progress/state */
|
||||
renderStatus?: RenderStatus;
|
||||
/** Optional slot for custom content below the timeline */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
projectId,
|
||||
hasProject,
|
||||
portrait,
|
||||
iframeRef,
|
||||
onIframeLoad,
|
||||
onTogglePlay,
|
||||
onSeek,
|
||||
renderStatus,
|
||||
children,
|
||||
}: PreviewPanelProps) {
|
||||
const renderState = renderStatus?.state ?? "idle";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-0 overflow-hidden"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateRows: hasProject && projectId ? "1fr auto auto auto" : "1fr",
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{hasProject && projectId ? (
|
||||
<>
|
||||
{/* Player — takes all remaining space, constrained for portrait */}
|
||||
<div
|
||||
className="flex items-center justify-center p-2 overflow-hidden"
|
||||
style={{ minHeight: 0, minWidth: 0 }}
|
||||
>
|
||||
<Player
|
||||
ref={iframeRef}
|
||||
projectId={projectId}
|
||||
onLoad={onIframeLoad}
|
||||
portrait={portrait}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls — fixed height */}
|
||||
<div className="bg-neutral-950 border-t border-neutral-800 flex-shrink-0">
|
||||
<PlayerControls onTogglePlay={onTogglePlay} onSeek={onSeek} />
|
||||
</div>
|
||||
|
||||
{/* Timeline — capped height, internal scroll */}
|
||||
<div
|
||||
className="bg-neutral-950 flex-shrink-0 overflow-y-auto"
|
||||
style={{ maxHeight: "100px" }}
|
||||
>
|
||||
<Timeline onSeek={onSeek} />
|
||||
</div>
|
||||
|
||||
{/* Render status — only shown when actively rendering, complete, or error */}
|
||||
{renderStatus &&
|
||||
(renderState === "rendering" ||
|
||||
renderState === "complete" ||
|
||||
renderState === "error") && (
|
||||
<div className="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex items-center justify-end gap-2 flex-shrink-0">
|
||||
{renderState === "rendering" && (
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-[width] duration-200"
|
||||
style={{ width: `${renderStatus.progress ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-400 flex-shrink-0">
|
||||
{renderStatus.stage || "Rendering..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{renderState === "complete" && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<span>Complete</span>
|
||||
</div>
|
||||
)}
|
||||
{renderState === "error" && (
|
||||
<div className="flex items-center gap-2 text-xs text-red-400">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span className="truncate">{renderStatus.error}</span>
|
||||
{renderStatus.onRender && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={renderStatus.onRender}
|
||||
className="flex-shrink-0 px-2 py-0.5 text-xs text-neutral-300 hover:text-white hover:bg-neutral-800 rounded transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Optional custom slot */}
|
||||
{children}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full min-w-0">
|
||||
<div className="text-center w-full">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-card bg-neutral-900 flex items-center justify-center">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-neutral-600"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600">Preview will appear here</p>
|
||||
<p className="text-xs text-neutral-700 mt-1">
|
||||
Send a message to generate a video composition
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -152,9 +152,6 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
|
||||
return { major, minor };
|
||||
}
|
||||
|
||||
/** @deprecated Use formatTime from '../lib/time' instead */
|
||||
export const formatTick = formatTime;
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────── */
|
||||
interface TimelineProps {
|
||||
/** Called when user seeks via ruler/track click or playhead drag */
|
||||
@@ -170,11 +167,6 @@ interface TimelineProps {
|
||||
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
||||
/** Called when files are dropped onto the empty timeline */
|
||||
onFileDrop?: (files: File[]) => void;
|
||||
/** Called when a clip is moved, resized, or changes track via drag */
|
||||
onClipChange?: (
|
||||
elementId: string,
|
||||
updates: { start?: number; duration?: number; track?: number },
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const Timeline = memo(function Timeline({
|
||||
@@ -346,12 +338,11 @@ export const Timeline = memo(function Timeline({
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
if (e.button !== 0) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
|
||||
// Shift+click starts range selection
|
||||
// Shift+click starts range selection — even on clips
|
||||
if (e.shiftKey) {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
isRangeSelecting.current = true;
|
||||
setShowPopover(false);
|
||||
const rect = scrollRef.current?.getBoundingClientRect();
|
||||
@@ -364,6 +355,10 @@ export const Timeline = memo(function Timeline({
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal click on a clip — let the clip handle it
|
||||
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
|
||||
isDragging.current = true;
|
||||
setRangeSelection(null);
|
||||
setShowPopover(false);
|
||||
@@ -434,7 +429,7 @@ export const Timeline = memo(function Timeline({
|
||||
return (
|
||||
<div
|
||||
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
||||
isDragOver ? "border-blue-500/50 bg-blue-500/[0.03]" : "border-neutral-800/50"
|
||||
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -471,7 +466,9 @@ export const Timeline = memo(function Timeline({
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div
|
||||
className={`flex items-center gap-3 px-6 py-3 border border-dashed rounded-lg transition-colors duration-150 ${
|
||||
isDragOver ? "border-blue-400/60 bg-blue-500/[0.06]" : "border-neutral-700/50"
|
||||
isDragOver
|
||||
? "border-studio-accent/60 bg-studio-accent/[0.06]"
|
||||
: "border-neutral-700/50"
|
||||
}`}
|
||||
>
|
||||
{isDragOver ? (
|
||||
@@ -485,13 +482,13 @@ export const Timeline = memo(function Timeline({
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-400 flex-shrink-0"
|
||||
className="text-studio-accent flex-shrink-0"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
<span className="text-[13px] text-blue-400">Drop media files to import</span>
|
||||
<span className="text-[13px] text-studio-accent">Drop media files to import</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -573,7 +570,7 @@ export const Timeline = memo(function Timeline({
|
||||
{/* 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">
|
||||
<span className="text-[9px] text-studio-accent/60 font-medium">
|
||||
Drag to select range
|
||||
</span>
|
||||
</div>
|
||||
@@ -642,7 +639,6 @@ export const Timeline = memo(function Timeline({
|
||||
key={clipKey}
|
||||
el={el}
|
||||
pps={pps}
|
||||
trackH={TRACK_H}
|
||||
clipY={CLIP_Y}
|
||||
isSelected={isSelected}
|
||||
isHovered={isHovered}
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { TimelineElement } from "../store/playerStore";
|
||||
interface TimelineClipProps {
|
||||
el: TimelineElement;
|
||||
pps: number;
|
||||
trackH: number;
|
||||
clipY: number;
|
||||
isSelected: boolean;
|
||||
isHovered: boolean;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
export { Player } from "./components/Player";
|
||||
export { PlayerControls } from "./components/PlayerControls";
|
||||
export { Timeline } from "./components/Timeline";
|
||||
export { PreviewPanel } from "./components/PreviewPanel";
|
||||
export { VideoThumbnail } from "./components/VideoThumbnail";
|
||||
export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
||||
|
||||
|
||||
@@ -27,10 +27,6 @@ interface PlayerState {
|
||||
zoomMode: ZoomMode;
|
||||
/** Pixels per second when in manual zoom mode */
|
||||
pixelsPerSecond: number;
|
||||
/** Edit range selection */
|
||||
editRangeStart: number | null;
|
||||
editRangeEnd: number | null;
|
||||
editMode: boolean;
|
||||
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
@@ -39,11 +35,6 @@ interface PlayerState {
|
||||
setTimelineReady: (ready: boolean) => void;
|
||||
setElements: (elements: TimelineElement[]) => void;
|
||||
setSelectedElementId: (id: string | null) => void;
|
||||
setEditRange: (start: number | null, end: number | null) => void;
|
||||
setEditMode: (active: boolean) => void;
|
||||
updateElementStart: (elementId: string, newStart: number) => void;
|
||||
updateElementDuration: (elementId: string, newDuration: number) => void;
|
||||
updateElementTrack: (elementId: string, newTrack: number) => void;
|
||||
updateElement: (
|
||||
elementId: string,
|
||||
updates: Partial<Pick<TimelineElement, "start" | "duration" | "track">>,
|
||||
@@ -76,9 +67,6 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
playbackRate: 1,
|
||||
zoomMode: "fit",
|
||||
pixelsPerSecond: 100,
|
||||
editRangeStart: null,
|
||||
editRangeEnd: null,
|
||||
editMode: false,
|
||||
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setPlaybackRate: (rate) => set({ playbackRate: rate }),
|
||||
@@ -89,26 +77,13 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
||||
setTimelineReady: (ready) => set({ timelineReady: ready }),
|
||||
setElements: (elements) => set({ elements }),
|
||||
setSelectedElementId: (id) => set({ selectedElementId: id }),
|
||||
setEditRange: (start, end) => set({ editRangeStart: start, editRangeEnd: end }),
|
||||
setEditMode: (active) => set({ editMode: active, editRangeStart: null, editRangeEnd: null }),
|
||||
updateElementStart: (elementId, newStart) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) => (el.id === elementId ? { ...el, start: newStart } : el)),
|
||||
})),
|
||||
updateElementDuration: (elementId, newDuration) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) =>
|
||||
el.id === elementId ? { ...el, duration: newDuration } : el,
|
||||
),
|
||||
})),
|
||||
updateElementTrack: (elementId, newTrack) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) => (el.id === elementId ? { ...el, track: newTrack } : el)),
|
||||
})),
|
||||
updateElement: (elementId, updates) =>
|
||||
set((state) => ({
|
||||
elements: state.elements.map((el) => (el.id === elementId ? { ...el, ...updates } : el)),
|
||||
})),
|
||||
// Resets project-specific state when switching compositions.
|
||||
// playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved
|
||||
// because they are user preferences that should survive project switches.
|
||||
reset: () =>
|
||||
set({
|
||||
isPlaying: false,
|
||||
|
||||
Reference in New Issue
Block a user