diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 6693261b8..3f4b71a55 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -5,7 +5,7 @@ import { SourceEditor } from "./components/editor/SourceEditor"; import { LeftSidebar } from "./components/sidebar/LeftSidebar"; import { RenderQueue } from "./components/renders/RenderQueue"; import { useRenderQueue } from "./components/renders/useRenderQueue"; -import { CompositionThumbnail, VideoThumbnail } from "./player"; +import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player"; import { AudioWaveform } from "./player/components/AudioWaveform"; import type { TimelineElement } from "./player"; import { LintModal } from "./components/LintModal"; @@ -18,6 +18,11 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline"; import { useCaptionStore } from "./captions/store"; import { useCaptionSync } from "./captions/hooks/useCaptionSync"; import { parseCaptionComposition } from "./captions/parser"; +import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher"; +import { + buildTrackZIndexMap, + formatTimelineAttributeNumber, +} from "./player/components/timelineEditing"; interface EditingFile { path: string; @@ -186,7 +191,7 @@ export function StudioApp() { }, [captionHasSelection, captionEditMode]); const [globalDragOver, setGlobalDragOver] = useState(false); const [uploadToast, setUploadToast] = useState(null); - const [timelineVisible, setTimelineVisible] = useState(false); + const [timelineVisible, setTimelineVisible] = useState(true); const dragCounterRef = useRef(0); const panelDragRef = useRef<{ side: "left" | "right"; @@ -198,6 +203,19 @@ export function StudioApp() { const activePreviewUrl = activeCompPath ? `/api/projects/${projectId}/preview/comp/${activeCompPath}` : null; + const zoomMode = usePlayerStore((s) => s.zoomMode); + const pixelsPerSecond = usePlayerStore((s) => s.pixelsPerSecond); + const setZoomMode = usePlayerStore((s) => s.setZoomMode); + const setPixelsPerSecond = usePlayerStore((s) => s.setPixelsPerSecond); + const timelineElements = usePlayerStore((s) => s.elements); + const timelineDuration = usePlayerStore((s) => s.duration); + const effectiveTimelineDuration = useMemo(() => { + const maxEnd = + timelineElements.length > 0 + ? Math.max(...timelineElements.map((element) => element.start + element.duration)) + : 0; + return Math.max(timelineDuration, maxEnd); + }, [timelineDuration, timelineElements]); const renderClipContent = useCallback( (el: TimelineElement, style: { clip: string; label: string }): ReactNode => { @@ -222,9 +240,10 @@ export function StudioApp() { previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`} label={el.id || el.tag} labelColor={style.label} + accentColor={style.clip} + selector={el.selector} seekTime={0} duration={el.duration} - selector={el.selector} /> ); } @@ -237,13 +256,20 @@ export function StudioApp() { previewUrl={activePreviewUrl} label={el.id || el.tag} labelColor={style.label} + accentColor={style.clip} + selector={el.selector} seekTime={el.start} duration={el.duration} - selector={el.selector} /> ); } + const htmlPreviewEligible = + el.duration > 0 && + effectiveTimelineDuration > 0 && + el.duration < effectiveTimelineDuration * 0.92 && + !/(backdrop|background|overlay|scrim|mask)/i.test(el.id); + // Audio clips — waveform visualization if (el.tag === "audio") { const audioUrl = el.src @@ -270,24 +296,69 @@ export function StudioApp() { ); } - // HTML scene elements — render from the master preview at the scene's time - if (el.tag === "div" && el.duration > 0) { - const previewUrl = `/api/projects/${pid}/preview`; + if (htmlPreviewEligible) { return ( ); } return null; }, - [compIdToSrc, activePreviewUrl], + [compIdToSrc, activePreviewUrl, effectiveTimelineDuration], + ); + const timelineToolbar = ( +
+
+ Timeline +
+
+ + +
+ {zoomMode === "fit" ? "Auto" : `${Math.round(pixelsPerSecond)} px/s`} +
+ +
+
); const [lintModal, setLintModal] = useState(null); const [consoleErrors, setConsoleErrors] = useState(null); @@ -381,6 +452,195 @@ export function StudioApp() { }, 600); }, []); + const handleTimelineElementMove = useCallback( + async (element: TimelineElement, updates: Pick) => { + const pid = projectIdRef.current; + if (!pid) throw new Error("No active project"); + + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`); + if (!response.ok) { + throw new Error(`Failed to read ${targetPath}`); + } + + const data = (await response.json()) as { content?: string }; + const originalContent = data.content; + if (typeof originalContent !== "string") { + throw new Error(`Missing file contents for ${targetPath}`); + } + + const patchTarget = element.domId + ? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex } + : element.selector + ? { selector: element.selector, selectorIndex: element.selectorIndex } + : null; + if (!patchTarget) { + throw new Error(`Timeline element ${element.id} is missing a patchable target`); + } + + const resolvedTargetPath = targetPath || "index.html"; + const relevantElements = timelineElements + .map((timelineElement) => + (timelineElement.key ?? timelineElement.id) === (element.key ?? element.id) + ? { ...timelineElement, start: updates.start, track: updates.track } + : timelineElement, + ) + .filter( + (timelineElement) => + (timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath, + ); + const trackZIndices = buildTrackZIndexMap( + relevantElements.map((timelineElement) => timelineElement.track), + ); + + let patchedContent = applyPatchByTarget(originalContent, patchTarget, { + type: "attribute", + property: "start", + value: formatTimelineAttributeNumber(updates.start), + }); + patchedContent = applyPatchByTarget(patchedContent, patchTarget, { + type: "attribute", + property: "track-index", + value: String(updates.track), + }); + for (const timelineElement of relevantElements) { + const elementTarget = timelineElement.domId + ? { + id: timelineElement.domId, + selector: timelineElement.selector, + selectorIndex: timelineElement.selectorIndex, + } + : timelineElement.selector + ? { + selector: timelineElement.selector, + selectorIndex: timelineElement.selectorIndex, + } + : null; + if (!elementTarget) continue; + const nextZIndex = trackZIndices.get(timelineElement.track); + if (nextZIndex == null) continue; + patchedContent = applyPatchByTarget(patchedContent, elementTarget, { + type: "inline-style", + property: "z-index", + value: String(nextZIndex), + }); + } + + if (patchedContent === originalContent) { + throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`); + } + + const saveResponse = await fetch( + `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, + { + method: "PUT", + headers: { "Content-Type": "text/plain" }, + body: patchedContent, + }, + ); + if (!saveResponse.ok) { + throw new Error(`Failed to save ${targetPath}`); + } + + if (editingPathRef.current === targetPath) { + setEditingFile({ path: targetPath, content: patchedContent }); + } + + setRefreshKey((k) => k + 1); + }, + [activeCompPath, timelineElements], + ); + + const handleTimelineElementResize = useCallback( + async ( + element: TimelineElement, + updates: Pick, + ) => { + const pid = projectIdRef.current; + if (!pid) throw new Error("No active project"); + + const targetPath = element.sourceFile || activeCompPath || "index.html"; + const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`); + if (!response.ok) { + throw new Error(`Failed to read ${targetPath}`); + } + + const data = (await response.json()) as { content?: string }; + const originalContent = data.content; + if (typeof originalContent !== "string") { + throw new Error(`Missing file contents for ${targetPath}`); + } + + const patchTarget = element.domId + ? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex } + : element.selector + ? { selector: element.selector, selectorIndex: element.selectorIndex } + : null; + if (!patchTarget) { + throw new Error(`Timeline element ${element.id} is missing a patchable target`); + } + + const playbackStartAttrName = + element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start"; + const currentPlaybackStartValue = + readAttributeByTarget(originalContent, patchTarget, "playback-start") ?? + readAttributeByTarget(originalContent, patchTarget, "media-start"); + const currentPlaybackStart = + currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined; + const trimDelta = updates.start - element.start; + const fallbackPlaybackStart = + updates.playbackStart == null && + trimDelta !== 0 && + Number.isFinite(currentPlaybackStart) && + currentPlaybackStart != null + ? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)) + : undefined; + const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart; + + let patchedContent = originalContent; + patchedContent = applyPatchByTarget(patchedContent, patchTarget, { + type: "attribute", + property: "start", + value: formatTimelineAttributeNumber(updates.start), + }); + patchedContent = applyPatchByTarget(patchedContent, patchTarget, { + type: "attribute", + property: "duration", + value: formatTimelineAttributeNumber(updates.duration), + }); + if (nextPlaybackStart != null) { + patchedContent = applyPatchByTarget(patchedContent, patchTarget, { + type: "attribute", + property: playbackStartAttrName, + value: formatTimelineAttributeNumber(nextPlaybackStart), + }); + } + + if (patchedContent === originalContent) { + throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`); + } + + const saveResponse = await fetch( + `/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`, + { + method: "PUT", + headers: { "Content-Type": "text/plain" }, + body: patchedContent, + }, + ); + if (!saveResponse.ok) { + throw new Error(`Failed to save ${targetPath}`); + } + + if (editingPathRef.current === targetPath) { + setEditingFile({ path: targetPath, content: patchedContent }); + } + + setRefreshKey((k) => k + 1); + }, + [activeCompPath], + ); + // ── File Management Handlers ── const refreshFileTree = useCallback(async () => { @@ -783,12 +1043,14 @@ export function StudioApp() { {/* Left resize handle */} {!leftCollapsed && (
handlePanelResizeStart("left", e)} onPointerMove={handlePanelResizeMove} onPointerUp={handlePanelResizeEnd} - /> + > +
+
)} {/* Center: Preview */} @@ -797,7 +1059,10 @@ export function StudioApp() { projectId={projectId} refreshKey={refreshKey} activeCompositionPath={activeCompPath} + timelineToolbar={timelineToolbar} renderClipContent={renderClipContent} + onMoveElement={handleTimelineElementMove} + onResizeElement={handleTimelineElementResize} onCompIdToSrcChange={setCompIdToSrc} onCompositionChange={(compPath) => { // Sync activeCompPath when user drills down via timeline double-click @@ -878,12 +1143,14 @@ export function StudioApp() { {!rightCollapsed && ( <>
handlePanelResizeStart("right", e)} onPointerMove={handlePanelResizeMove} onPointerUp={handlePanelResizeEnd} - /> + > +
+
ReactNode; + /** Persist timeline move actions back into source HTML */ + onMoveElement?: ( + element: TimelineElement, + updates: Pick, + ) => Promise | void; + onResizeElement?: ( + element: TimelineElement, + updates: Pick, + ) => Promise | void; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; /** Whether the timeline panel is visible (default: true) */ @@ -50,6 +59,8 @@ export const NLELayout = memo(function NLELayout({ onIframeRef, onCompositionChange, renderClipContent, + onMoveElement, + onResizeElement, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -379,6 +390,8 @@ export const NLELayout = memo(function NLELayout({ onSeek={seek} onDrillDown={handleDrillDown} renderClipContent={renderClipContent} + onMoveElement={onMoveElement} + onResizeElement={onResizeElement} />
{timelineFooter &&
{timelineFooter}
} diff --git a/packages/studio/src/player/components/EditModal.tsx b/packages/studio/src/player/components/EditModal.tsx index 753e6f7f9..067426c08 100644 --- a/packages/studio/src/player/components/EditModal.tsx +++ b/packages/studio/src/player/components/EditModal.tsx @@ -2,6 +2,7 @@ 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"; interface EditPopoverProps { rangeStart: number; @@ -14,7 +15,8 @@ interface EditPopoverProps { 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 [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false); + const [copiedPromptOnly, setCopiedPromptOnly] = useState(false); const popoverRef = useRef(null); const textareaRef = useRef(null); @@ -51,27 +53,12 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, 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.`; + return buildTimelineAgentPrompt({ + rangeStart: start, + rangeEnd: end, + elements: elementsInRange, + prompt, + }); }, [start, end, elementsInRange, prompt]); const handleCopy = useCallback(async () => { @@ -85,13 +72,32 @@ Preserve all other elements and timing outside this range.`; document.execCommand("copy"); document.body.removeChild(ta); } - setCopied(true); + setCopiedAgentPrompt(true); setTimeout(() => { - setCopied(false); + setCopiedAgentPrompt(false); onClose(); }, 800); }, [buildClipboardText, onClose]); + const handleCopyPrompt = useCallback(async () => { + const promptText = buildPromptCopyText(prompt); + if (!promptText) return; + try { + await navigator.clipboard.writeText(promptText); + } catch { + const ta = document.createElement("textarea"); + ta.value = promptText; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } + setCopiedPromptOnly(true); + setTimeout(() => { + setCopiedPromptOnly(false); + }, 800); + }, [prompt]); + const style: React.CSSProperties = { position: "fixed", left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)), @@ -146,17 +152,30 @@ Preserve all other elements and timing outside this range.`;
{/* Action */} -
+
+
diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 64efd0b6f..124e6a3b5 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,9 +1,21 @@ import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react"; -import { usePlayerStore, liveTime } from "../store/playerStore"; +import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore"; import { useMountEffect } from "../../hooks/useMountEffect"; import { formatTime } from "../lib/time"; import { TimelineClip } from "./TimelineClip"; import { EditPopover } from "./EditModal"; +import { + resolveTimelineAutoScroll, + resolveTimelineMove, + resolveTimelineResize, +} from "./timelineEditing"; +import { + defaultTimelineTheme, + getRenderedTimelineElement, + getTimelineTrackStyle, + type TimelineTrackStyle, + type TimelineTheme, +} from "./timelineTheme"; /* ── Layout ─────────────────────────────────────────────────────── */ const GUTTER = 32; @@ -11,17 +23,7 @@ const TRACK_H = 72; const RULER_H = 24; const CLIP_Y = 3; // vertical inset inside track -/* ── Vibrant Color System (Figma-inspired, dark-mode adapted) ──── */ -interface TrackStyle { - /** Clip solid background */ - clip: string; - /** Dark text color for label on clip */ - label: string; - /** Track row tint (very subtle) */ - row: string; - /** Gutter icon circle background */ - gutter: string; - /** SVG icon paths (viewBox 0 0 24 24) */ +interface TrackVisualStyle extends TimelineTrackStyle { icon: ReactNode; } @@ -46,84 +48,29 @@ const IconText = ; const IconComposition = ; const IconAudio = ; -const STYLES: Record = { - video: { - clip: "#1F6AFF", - label: "#DBEAFE", - row: "rgba(31,106,255,0.04)", - gutter: "#1F6AFF", - icon: IconImage, - }, - audio: { - clip: "#00C4FF", - label: "#013A4B", - row: "rgba(0,196,255,0.04)", - gutter: "#00C4FF", - icon: IconMusic, - }, - img: { - clip: "#8B5CF6", - label: "#EDE9FE", - row: "rgba(139,92,246,0.04)", - gutter: "#8B5CF6", - icon: IconImage, - }, - div: { - clip: "#68B200", - label: "#1A2B03", - row: "rgba(104,178,0,0.04)", - gutter: "#68B200", - icon: IconComposition, - }, - span: { - clip: "#F3A6FF", - label: "#8D00A3", - row: "rgba(243,166,255,0.04)", - gutter: "#F3A6FF", - icon: IconCaptions, - }, - p: { - clip: "#35C838", - label: "#024A03", - row: "rgba(53,200,56,0.04)", - gutter: "#35C838", - icon: IconText, - }, - h1: { - clip: "#35C838", - label: "#024A03", - row: "rgba(53,200,56,0.04)", - gutter: "#35C838", - icon: IconText, - }, - section: { - clip: "#68B200", - label: "#1A2B03", - row: "rgba(104,178,0,0.04)", - gutter: "#68B200", - icon: IconComposition, - }, - sfx: { - clip: "#FF8C42", - label: "#512000", - row: "rgba(255,140,66,0.04)", - gutter: "#FF8C42", - icon: IconAudio, - }, +const ICONS: Record = { + video: IconImage, + audio: IconMusic, + img: IconImage, + div: IconComposition, + span: IconCaptions, + p: IconText, + h1: IconText, + section: IconComposition, + sfx: IconAudio, }; -const DEFAULT: TrackStyle = { - clip: "#6B7280", - label: "#F3F4F6", - row: "rgba(107,114,128,0.03)", - gutter: "#6B7280", - icon: IconComposition, -}; - -function getStyle(tag: string): TrackStyle { - const t = tag.toLowerCase(); - if (t.startsWith("h") && t.length === 2 && "123456".includes(t[1])) return STYLES.h1; - return STYLES[t] ?? DEFAULT; +function getStyle(tag: string): TrackVisualStyle { + const trackStyle = getTimelineTrackStyle(tag); + const normalized = tag.toLowerCase(); + const icon = + normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "") + ? ICONS.h1 + : (ICONS[normalized] ?? IconComposition); + return { + ...trackStyle, + icon, + }; } /* ── Tick Generation ────────────────────────────────────────────── */ @@ -167,6 +114,44 @@ interface TimelineProps { renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode; /** Called when files are dropped onto the empty timeline */ onFileDrop?: (files: File[]) => void; + /** Persist a clip move back into source HTML */ + onMoveElement?: ( + element: import("../store/playerStore").TimelineElement, + updates: Pick, + ) => Promise | void; + onResizeElement?: ( + element: import("../store/playerStore").TimelineElement, + updates: Pick< + import("../store/playerStore").TimelineElement, + "start" | "duration" | "playbackStart" + >, + ) => Promise | void; + theme?: Partial; +} + +interface DraggedClipState { + element: TimelineElement; + originClientX: number; + originClientY: number; + originScrollLeft: number; + originScrollTop: number; + pointerClientX: number; + pointerClientY: number; + pointerOffsetX: number; + pointerOffsetY: number; + previewStart: number; + previewTrack: number; + started: boolean; +} + +interface ResizingClipState { + element: TimelineElement; + edge: "start" | "end"; + originClientX: number; + previewStart: number; + previewDuration: number; + previewPlaybackStart?: number; + started: boolean; } export const Timeline = memo(function Timeline({ @@ -175,12 +160,17 @@ export const Timeline = memo(function Timeline({ renderClipContent, renderClipOverlay, onFileDrop, + onMoveElement, + onResizeElement, + theme: themeOverrides, }: TimelineProps = {}) { + const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); const elements = usePlayerStore((s) => s.elements); const duration = usePlayerStore((s) => s.duration); const timelineReady = usePlayerStore((s) => s.timelineReady); const selectedElementId = usePlayerStore((s) => s.selectedElementId); const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); + const updateElement = usePlayerStore((s) => s.updateElement); const zoomMode = usePlayerStore((s) => s.zoomMode); const manualPps = usePlayerStore((s) => s.pixelsPerSecond); const playheadRef = useRef(null); @@ -211,6 +201,17 @@ export const Timeline = memo(function Timeline({ anchorX: number; anchorY: number; } | null>(null); + const [draggedClip, setDraggedClip] = useState(null); + const draggedClipRef = useRef(null); + draggedClipRef.current = draggedClip; + const [resizingClip, setResizingClip] = useState(null); + const resizingClipRef = useRef(null); + resizingClipRef.current = resizingClip; + const onMoveElementRef = useRef(onMoveElement); + onMoveElementRef.current = onMoveElement; + const onResizeElementRef = useRef(onResizeElement); + onResizeElementRef.current = onResizeElement; + const suppressClickRef = useRef(false); const [showPopover, setShowPopover] = useState(false); const [viewportWidth, setViewportWidth] = useState(0); const roRef = useRef(null); @@ -249,6 +250,38 @@ export const Timeline = memo(function Timeline({ return Number.isFinite(result) ? result : safeDur; }, [elements, duration]); + const tracks = useMemo(() => { + const map = new Map(); + for (const el of elements) { + const list = map.get(el.track) ?? []; + list.push(el); + map.set(el.track, list); + } + return Array.from(map.entries()).sort(([a], [b]) => a - b); + }, [elements]); + + const trackStyles = useMemo(() => { + const map = new Map(); + for (const [trackNum, els] of tracks) { + map.set(trackNum, getStyle(els[0]?.tag ?? "")); + } + return map; + }, [tracks]); + + const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]); + const trackOrderRef = useRef(trackOrder); + trackOrderRef.current = trackOrder; + const displayTrackOrder = useMemo(() => { + if ( + !draggedClip?.started || + trackOrder.length === 0 || + trackOrder.includes(draggedClip.previewTrack) + ) { + return trackOrder; + } + return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b); + }, [draggedClip, trackOrder]); + // Calculate effective pixels per second // In fit mode, use clientWidth (excludes scrollbar) with a small padding const fitPps = @@ -290,6 +323,106 @@ export const Timeline = memo(function Timeline({ }); const dragScrollRaf = useRef(0); + const clipDragScrollRaf = useRef(0); + const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); + + const updateDraggedClipPreview = useCallback( + (drag: DraggedClipState, clientX: number, clientY: number) => { + const scroll = scrollRef.current; + const nextMove = resolveTimelineMove( + { + start: drag.element.start, + track: drag.element.track, + duration: drag.element.duration, + originClientX: drag.originClientX, + originClientY: drag.originClientY, + originScrollLeft: drag.originScrollLeft, + originScrollTop: drag.originScrollTop, + currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft, + currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop, + pixelsPerSecond: ppsRef.current, + trackHeight: TRACK_H, + maxStart: Math.max(0, durationRef.current - drag.element.duration), + trackOrder: trackOrderRef.current, + }, + clientX, + clientY, + ); + + return { + ...drag, + started: true, + pointerClientX: clientX, + pointerClientY: clientY, + previewStart: nextMove.start, + previewTrack: nextMove.track, + }; + }, + [], + ); + + const stopClipDragAutoScroll = useCallback(() => { + clipDragPointerRef.current = null; + if (clipDragScrollRaf.current) { + cancelAnimationFrame(clipDragScrollRaf.current); + clipDragScrollRaf.current = 0; + } + }, []); + + const stepClipDragAutoScroll = useCallback(() => { + clipDragScrollRaf.current = 0; + const drag = draggedClipRef.current; + const pointer = clipDragPointerRef.current; + const scroll = scrollRef.current; + if (!drag || !pointer || !scroll) return; + + const rect = scroll.getBoundingClientRect(); + const delta = resolveTimelineAutoScroll(rect, pointer.clientX, pointer.clientY); + if (delta.x === 0 && delta.y === 0) return; + + const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth); + const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight); + const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x)); + const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y)); + const didScroll = nextScrollLeft !== scroll.scrollLeft || nextScrollTop !== scroll.scrollTop; + + if (!didScroll) return; + + scroll.scrollLeft = nextScrollLeft; + scroll.scrollTop = nextScrollTop; + setDraggedClip((prev) => + prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev, + ); + + clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll); + }, [updateDraggedClipPreview]); + + const syncClipDragAutoScroll = useCallback( + (clientX: number, clientY: number) => { + clipDragPointerRef.current = { clientX, clientY }; + const scroll = scrollRef.current; + if (!scroll) return; + const rect = scroll.getBoundingClientRect(); + const delta = resolveTimelineAutoScroll(rect, clientX, clientY); + if (delta.x === 0 && delta.y === 0) { + if (clipDragScrollRaf.current) { + cancelAnimationFrame(clipDragScrollRaf.current); + clipDragScrollRaf.current = 0; + } + return; + } + if (!clipDragScrollRaf.current) { + clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll); + } + }, + [stepClipDragAutoScroll], + ); + const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview); + updateDraggedClipPreviewRef.current = updateDraggedClipPreview; + const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll); + syncClipDragAutoScrollRef.current = syncClipDragAutoScroll; + const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll); + stopClipDragAutoScrollRef.current = stopClipDragAutoScroll; const seekFromX = useCallback( (clientX: number) => { @@ -336,6 +469,158 @@ export const Timeline = memo(function Timeline({ [seekFromX], ); + useMountEffect(() => { + const clearSuppressedClick = () => { + requestAnimationFrame(() => { + suppressClickRef.current = false; + }); + }; + + const handleWindowPointerMove = (e: PointerEvent) => { + const drag = draggedClipRef.current; + const resize = resizingClipRef.current; + if (resize) { + const distance = Math.abs(e.clientX - resize.originClientX); + if (!resize.started && distance < 2) return; + + setShowPopover(false); + setRangeSelection(null); + + const sourceRemaining = + resize.element.sourceDuration != null + ? Math.max( + 0, + (resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) / + Math.max(resize.element.playbackRate ?? 1, 0.1), + ) + : Number.POSITIVE_INFINITY; + const nextResize = resolveTimelineResize( + { + start: resize.element.start, + duration: resize.element.duration, + originClientX: resize.originClientX, + pixelsPerSecond: ppsRef.current, + minStart: 0, + maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining), + playbackStart: resize.element.playbackStart, + playbackRate: resize.element.playbackRate, + }, + resize.edge, + e.clientX, + ); + + setResizingClip((prev) => + prev + ? { + ...prev, + started: true, + previewStart: nextResize.start, + previewDuration: nextResize.duration, + previewPlaybackStart: nextResize.playbackStart, + } + : prev, + ); + return; + } + if (!drag) return; + + const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY); + if (!drag.started && distance < 4) return; + + setShowPopover(false); + setRangeSelection(null); + + setDraggedClip((prev) => + prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev, + ); + syncClipDragAutoScrollRef.current(e.clientX, e.clientY); + }; + + const handleWindowPointerUp = () => { + stopClipDragAutoScrollRef.current(); + const resize = resizingClipRef.current; + if (resize) { + resizingClipRef.current = null; + setResizingClip(null); + + if (!resize.started) return; + + suppressClickRef.current = true; + clearSuppressedClick(); + + const hasChanged = + resize.previewStart !== resize.element.start || + resize.previewDuration !== resize.element.duration || + resize.previewPlaybackStart !== resize.element.playbackStart; + if (!hasChanged) return; + + updateElement(resize.element.key ?? resize.element.id, { + start: resize.previewStart, + duration: resize.previewDuration, + playbackStart: resize.previewPlaybackStart, + }); + + Promise.resolve( + onResizeElementRef.current?.(resize.element, { + start: resize.previewStart, + duration: resize.previewDuration, + playbackStart: resize.previewPlaybackStart, + }), + ).catch((error) => { + updateElement(resize.element.key ?? resize.element.id, { + start: resize.element.start, + duration: resize.element.duration, + playbackStart: resize.element.playbackStart, + }); + console.error("[Timeline] Failed to persist clip resize", error); + }); + return; + } + + const drag = draggedClipRef.current; + if (!drag) return; + draggedClipRef.current = null; + setDraggedClip(null); + + if (!drag.started) return; + + suppressClickRef.current = true; + clearSuppressedClick(); + + const hasChanged = + drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track; + if (!hasChanged) return; + + updateElement(drag.element.key ?? drag.element.id, { + start: drag.previewStart, + track: drag.previewTrack, + }); + + Promise.resolve( + onMoveElementRef.current?.(drag.element, { + start: drag.previewStart, + track: drag.previewTrack, + }), + ).catch((error) => { + updateElement(drag.element.key ?? drag.element.id, { + start: drag.element.start, + track: drag.element.track, + }); + console.error("[Timeline] Failed to persist clip move", error); + }); + }; + + window.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("pointerup", handleWindowPointerUp); + window.addEventListener("pointercancel", handleWindowPointerUp); + return () => { + stopClipDragAutoScrollRef.current(); + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp); + window.removeEventListener("pointercancel", handleWindowPointerUp); + }; + }); + const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (e.button !== 0) return; @@ -402,26 +687,21 @@ export const Timeline = memo(function Timeline({ cancelAnimationFrame(dragScrollRaf.current); }, []); - const tracks = useMemo(() => { - const map = new Map(); - for (const el of elements) { - const list = map.get(el.track) ?? []; - list.push(el); - map.set(el.track, list); - } - return Array.from(map.entries()).sort(([a], [b]) => a - b); - }, [elements]); - - // Determine dominant style per track (from first element) - const trackStyles = useMemo(() => { - const map = new Map(); - for (const [trackNum, els] of tracks) { - map.set(trackNum, getStyle(els[0]?.tag ?? "")); - } - return map; - }, [tracks]); - const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]); + const getPreviewElement = useCallback( + (element: TimelineElement): TimelineElement => { + if (resizingClip?.element.id === element.id) { + return { + ...element, + start: resizingClip.previewStart, + duration: resizingClip.previewDuration, + playbackStart: resizingClip.previewPlaybackStart, + }; + } + return element; + }, + [resizingClip], + ); const [isDragOver, setIsDragOver] = useState(false); @@ -522,14 +802,92 @@ export const Timeline = memo(function Timeline({ ); } - const totalH = RULER_H + tracks.length * TRACK_H; + const totalH = RULER_H + displayTrackOrder.length * TRACK_H; + const draggedElement = draggedClip?.element ?? null; + const activeDraggedElement = + draggedClip?.started === true && draggedElement + ? getRenderedTimelineElement({ + element: draggedElement, + draggedElementId: draggedElement.id, + previewStart: draggedClip.previewStart, + previewTrack: draggedClip.previewTrack, + }) + : null; + const activeDraggedPosition = + draggedClip?.started === true && activeDraggedElement && scrollRef.current + ? { + left: + draggedClip.pointerClientX - + scrollRef.current.getBoundingClientRect().left + + scrollRef.current.scrollLeft - + draggedClip.pointerOffsetX, + top: + draggedClip.pointerClientY - + scrollRef.current.getBoundingClientRect().top + + scrollRef.current.scrollTop - + draggedClip.pointerOffsetY, + } + : null; + const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => { + return ( + <> + {renderClipOverlay?.(element)} +
+ {renderClipContent?.(element, clipStyle) ?? ( +
+
+ + {element.tag} + +
+ + {element.id || element.tag} + +
+ + {formatTime(element.start)} {"\u2192"}{" "} + {formatTime(element.start + element.duration)} + +
+
+ )} +
+ + ); + }; return (
); @@ -564,20 +922,20 @@ export const Timeline = memo(function Timeline({ {/* Ruler */}
{/* Shift hint */} {shiftHeld && !rangeSelection && (
- + Drag to select range
)} {minor.map((t) => (
-
+
))} {major.map((t) => ( @@ -586,36 +944,49 @@ export const Timeline = memo(function Timeline({ className="absolute bottom-0 flex flex-col items-center" style={{ left: t * pps }} > - + {formatTime(t)} -
+
))}
{/* Tracks */} - {tracks.map(([trackNum, els]) => { - const ts = trackStyles.get(trackNum) ?? DEFAULT; + {displayTrackOrder.map((trackNum) => { + const els = tracks.find(([currentTrack]) => currentTrack === trackNum)?.[1] ?? []; + const ts = trackStyles.get(trackNum) ?? getStyle(""); + const isPendingTrack = + draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; return (
- {/* Gutter: colored icon badge (Figma Motion Cut style) */}
@@ -625,64 +996,98 @@ export const Timeline = memo(function Timeline({ {/* Clips */}
+ {isPendingTrack && ( +
+ New track +
+ )} {els.map((el, i) => { const clipStyle = getStyle(el.tag); - const isSelected = selectedElementId === el.id; + const elementKey = el.key ?? el.id; + const isSelected = selectedElementId === elementKey; const isComposition = !!el.compositionSrc; - const clipKey = `${el.id}-${i}`; + const clipKey = `${elementKey}-${i}`; const isHovered = hoveredClip === clipKey; const hasCustomContent = !!renderClipContent; - const clipWidthPx = Math.max(el.duration * pps, 4); + const isDragging = + draggedClip?.started === true && + (draggedElement?.key ?? draggedElement?.id) === elementKey; + if (isDragging) return null; + const previewElement = getPreviewElement(el); return ( setHoveredClip(clipKey)} onHoverEnd={() => setHoveredClip(null)} + onResizeStart={(edge, e) => { + if (e.button !== 0 || e.shiftKey || !onResizeElement) return; + e.stopPropagation(); + setShowPopover(false); + setRangeSelection(null); + setResizingClip({ + element: el, + edge, + originClientX: e.clientX, + previewStart: el.start, + previewDuration: el.duration, + previewPlaybackStart: el.playbackStart, + started: false, + }); + }} + onPointerDown={(e) => { + if (e.button !== 0 || e.shiftKey || !onMoveElement) return; + setShowPopover(false); + setRangeSelection(null); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + setDraggedClip({ + element: el, + originClientX: e.clientX, + originClientY: e.clientY, + originScrollLeft: scrollRef.current?.scrollLeft ?? 0, + originScrollTop: scrollRef.current?.scrollTop ?? 0, + pointerClientX: e.clientX, + pointerClientY: e.clientY, + pointerOffsetX: e.clientX - rect.left, + pointerOffsetY: e.clientY - rect.top, + previewStart: el.start, + previewTrack: el.track, + started: false, + }); + }} onClick={(e) => { e.stopPropagation(); - setSelectedElementId(isSelected ? null : el.id); + if (suppressClickRef.current) return; + setSelectedElementId(isSelected ? null : elementKey); }} onDoubleClick={(e) => { e.stopPropagation(); + if (suppressClickRef.current) return; if (isComposition && onDrillDown) onDrillDown(el); }} > - {renderClipOverlay?.(el)} -
- {renderClipContent?.(el, clipStyle) ?? ( - <> - - {el.id || el.tag} - - {clipWidthPx > 60 && ( - - {el.duration.toFixed(1)}s - - )} - - )} -
+ {renderClipChildren(previewElement, clipStyle)}
); })} @@ -691,6 +1096,41 @@ export const Timeline = memo(function Timeline({ ); })} + {activeDraggedElement && activeDraggedPosition && ( +
+ {}} + onHoverEnd={() => {}} + onResizeStart={() => {}} + onClick={() => {}} + onDoubleClick={() => {}} + > + {renderClipChildren(activeDraggedElement, getStyle(activeDraggedElement.tag))} + +
+ )} + {/* Range selection highlight */} {rangeSelection && (
-
- +
+ Shift - + drag to edit range + + + drag to edit range +
)} diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index ba8deb741..8d87cef0c 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -1,7 +1,9 @@ +import type { TimelineTrackStyle } from "./timelineTheme"; // TimelineClip — Visual clip component for the NLE timeline. import { memo, type ReactNode } from "react"; import type { TimelineElement } from "../store/playerStore"; +import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme"; interface TimelineClipProps { el: TimelineElement; @@ -9,11 +11,15 @@ interface TimelineClipProps { clipY: number; isSelected: boolean; isHovered: boolean; + isDragging?: boolean; hasCustomContent: boolean; - style: { clip: string; label: string }; + theme?: TimelineTheme; + trackStyle: TimelineTrackStyle; isComposition: boolean; onHoverStart: () => void; onHoverEnd: () => void; + onPointerDown?: (e: React.PointerEvent) => void; + onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void; onClick: (e: React.MouseEvent) => void; onDoubleClick: (e: React.MouseEvent) => void; children?: ReactNode; @@ -25,43 +31,62 @@ export const TimelineClip = memo(function TimelineClip({ clipY, isSelected, isHovered, + isDragging = false, hasCustomContent, - style, + theme = defaultTimelineTheme, + trackStyle, isComposition, onHoverStart, onHoverEnd, + onPointerDown, + onResizeStart, onClick, onDoubleClick, children, }: TimelineClipProps) { const leftPx = el.start * pps; const widthPx = Math.max(el.duration * pps, 4); + const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging }); + const borderColor = isSelected + ? theme.clipBorderActive + : isHovered + ? theme.clipBorderHover + : theme.clipBorder; + const boxShadow = isDragging + ? theme.clipShadowDragging + : isSelected + ? theme.clipShadowActive + : isHovered + ? theme.clipShadowHover + : theme.clipShadow; + const showHandles = handleOpacity > 0.01; return (
+