diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 8171302ab..52f10be23 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -286,6 +286,7 @@ export function StudioApp() { const appHotkeys = useAppHotkeys({ toggleTimelineVisibility, handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete, + handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit, handleDomEditElementDelete: domEditDeleteBridge, domEditSelectionRef: domEditSelectionBridgeRef, clearDomSelectionRef, @@ -489,6 +490,7 @@ export function StudioApp() { ); return ( @@ -532,6 +534,7 @@ export function StudioApp() { handleTimelineElementMove={timelineEditing.handleTimelineElementMove} handleTimelineElementResize={timelineEditing.handleTimelineElementResize} handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit} + handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit} setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index fd92d36ff..0dfbf0392 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -49,6 +49,7 @@ export interface StudioPreviewAreaProps { updates: Pick, ) => Promise | void; handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise | void; setCompIdToSrc: (map: Map) => void; setCompositionLoading: (loading: boolean) => void; shouldShowSelectedDomBounds: boolean; @@ -67,6 +68,7 @@ export function StudioPreviewArea({ handleTimelineElementMove, handleTimelineElementResize, handleBlockedTimelineEdit, + handleTimelineElementSplit, setCompIdToSrc, setCompositionLoading, shouldShowSelectedDomBounds, @@ -107,7 +109,7 @@ export function StudioPreviewArea({ handleGsapUpdateMeta, handleGsapAddKeyframe, handleGsapConvertToKeyframes, - handleGsapRemoveAllKeyframes, + handleGsapDeleteAnimation, } = useDomEditContext(); return ( @@ -127,10 +129,12 @@ export function StudioPreviewArea({ onMoveElement={handleTimelineElementMove} onResizeElement={handleTimelineElementResize} onBlockedEditAttempt={handleBlockedTimelineEdit} + onSplitElement={handleTimelineElementSplit} onSelectTimelineElement={handleTimelineElementSelect} onDeleteAllKeyframes={(_elId) => { - const anim = selectedGsapAnimations.find((a) => a.keyframes); - if (anim) handleGsapRemoveAllKeyframes(anim.id); + const anim = + selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0]; + if (anim) handleGsapDeleteAnimation(anim.id); }} onDeleteKeyframe={(_elId, pct) => { const anim = selectedGsapAnimations.find((a) => a.keyframes); diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 1535eb80a..539df6b6e 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -218,38 +218,47 @@ export function TimelineToolbar({ )} - {onSplitElement && ( - - - - )} + {onSplitElement && + (() => { + const { selectedElementId, elements, currentTime } = usePlayerStore.getState(); + const el = selectedElementId + ? elements.find((e) => (e.key ?? e.id) === selectedElementId) + : null; + if (!el || el.compositionSrc) return null; + const canSplit = currentTime > el.start && currentTime < el.start + el.duration; + return ( + + + + ); + })()}
diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index c4e50a0d1..27c38e480 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -516,8 +516,58 @@ function reapplyPathOffsets(doc: Document): void { } } +function gsapAnimatesProperty(el: HTMLElement, ...props: string[]): boolean { + const win = el.ownerDocument.defaultView as + | (Window & { + __timelines?: Record< + string, + { + getChildren?: ( + deep: boolean, + ) => Array<{ targets?: () => Element[]; vars?: Record }>; + } + >; + }) + | null; + if (!win?.__timelines) return false; + const propSet = new Set(props); + for (const tl of Object.values(win.__timelines)) { + if (!tl?.getChildren) continue; + try { + for (const child of tl.getChildren(true)) { + if (!child.targets || !child.vars) continue; + let targetsEl = false; + for (const t of child.targets()) { + if (t === el || (el.id && t.id === el.id)) { + targetsEl = true; + break; + } + } + if (!targetsEl) continue; + const vars = child.vars; + for (const p of propSet) { + if (p in vars) return true; + } + if (vars.keyframes && typeof vars.keyframes === "object") { + for (const kfVal of Object.values(vars.keyframes as Record)) { + if (kfVal && typeof kfVal === "object") { + for (const p of propSet) { + if (p in (kfVal as Record)) return true; + } + } + } + } + } + } catch { + /* */ + } + } + return false; +} + function reapplyBoxSizes(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_BOX_SIZE_ATTR)) { + if (gsapAnimatesProperty(el, "width", "height")) continue; const w = Number.parseFloat(el.style.getPropertyValue(STUDIO_WIDTH_PROP)); const h = Number.parseFloat(el.style.getPropertyValue(STUDIO_HEIGHT_PROP)); if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) { diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index cad19d33f..e49fb730e 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -69,6 +69,7 @@ interface NLELayoutProps { updates: Pick, ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void; onSelectTimelineElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; onDeleteAllKeyframes?: (elementId: string) => void; @@ -122,6 +123,7 @@ export const NLELayout = memo(function NLELayout({ onMoveElement, onResizeElement, onBlockedEditAttempt, + onSplitElement, onSelectTimelineElement, onDeleteKeyframe, onDeleteAllKeyframes, @@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({ onMoveElement={onMoveElement} onResizeElement={onResizeElement} onBlockedEditAttempt={onBlockedEditAttempt} + onSplitElement={onSplitElement} onSelectElement={onSelectTimelineElement} onDeleteKeyframe={onDeleteKeyframe} onDeleteAllKeyframes={onDeleteAllKeyframes} diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 61d65af9f..60315fb7d 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -62,6 +62,7 @@ interface EditHistoryHandle { interface UseAppHotkeysParams { toggleTimelineVisibility: () => void; handleTimelineElementDelete: (element: TimelineElement) => Promise; + handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise; handleDomEditElementDelete: (selection: DomEditSelection) => Promise; domEditSelectionRef: React.MutableRefObject; clearDomSelectionRef: React.MutableRefObject<() => void>; @@ -87,6 +88,7 @@ interface UseAppHotkeysParams { export function useAppHotkeys({ toggleTimelineVisibility, handleTimelineElementDelete, + handleTimelineElementSplit, handleDomEditElementDelete, domEditSelectionRef, editHistory, @@ -195,6 +197,8 @@ export function useAppHotkeys({ handleToggleRef.current = handleTimelineToggleHotkey; const handleDeleteRef = useRef(handleTimelineElementDelete); handleDeleteRef.current = handleTimelineElementDelete; + const handleSplitRef = useRef(handleTimelineElementSplit); + handleSplitRef.current = handleTimelineElementSplit; const handleDomEditDeleteRef = useRef(handleDomEditElementDelete); handleDomEditDeleteRef.current = handleDomEditElementDelete; const handleUndoRef = useRef(handleUndo); @@ -306,6 +310,29 @@ export function useAppHotkeys({ return; } + // S — split selected clip at playhead + if ( + event.key === "s" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !isEditableTarget(event.target) + ) { + const { selectedElementId, elements, currentTime } = usePlayerStore.getState(); + if (selectedElementId) { + const element = elements.find((el) => (el.key ?? el.id) === selectedElementId); + if ( + element && + currentTime > element.start && + currentTime < element.start + element.duration + ) { + event.preventDefault(); + void handleSplitRef.current(element, currentTime); + return; + } + } + } + // Delete / Backspace — remove selected keyframes > reset keyframes > remove element if ( (event.key === "Delete" || event.key === "Backspace") && diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index e9045b960..024777dc2 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -474,10 +474,10 @@ export function useTimelineEditing({ if ( element.timelineLocked || element.timingSource === "implicit" || + element.compositionSrc || !element.duration || !Number.isFinite(element.duration) ) { - showToast("This clip cannot be split.", "error"); return; } diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx index 4caf1a065..4199d1a27 100644 --- a/packages/studio/src/player/components/ClipContextMenu.tsx +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -43,11 +43,15 @@ export const ClipContextMenu = memo(function ClipContextMenu({ const adjustedX = Math.min(x, window.innerWidth - 200); const adjustedY = Math.min(y, window.innerHeight - 200); - const canSplit = currentTime > element.start && currentTime < element.start + element.duration; + const isComposition = !!element.compositionSrc; + const canSplit = + !isComposition && currentTime > element.start && currentTime < element.start + element.duration; - const splitLabel = canSplit - ? `Split at ${currentTime.toFixed(2)}s` - : "Split (move playhead inside clip)"; + const splitLabel = isComposition + ? "Split (not available for compositions)" + : canSplit + ? `Split at ${currentTime.toFixed(2)}s` + : "Split (move playhead inside clip)"; return (
, ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + onSplitElement?: (element: TimelineElement, splitTime: number) => Promise | void; onSelectElement?: (element: TimelineElement | null) => void; onDeleteKeyframe?: (elementId: string, percentage: number) => void; onDeleteAllKeyframes?: (elementId: string) => void; @@ -91,6 +93,7 @@ export const Timeline = memo(function Timeline({ onMoveElement, onResizeElement, onBlockedEditAttempt, + onSplitElement, onSelectElement, onDeleteKeyframe, onDeleteAllKeyframes, @@ -135,6 +138,11 @@ export const Timeline = memo(function Timeline({ const [showPopover, setShowPopover] = useState(false); const [showShortcutHint, setShowShortcutHint] = useState(true); const [kfContextMenu, setKfContextMenu] = useState(null); + const [clipContextMenu, setClipContextMenu] = useState<{ + x: number; + y: number; + element: TimelineElement; + } | null>(null); const [viewportWidth, setViewportWidth] = useState(0); const roRef = useRef(null); const shortcutHintRafRef = useRef(0); @@ -532,6 +540,12 @@ export const Timeline = memo(function Timeline({ currentEase: kf?.ease ?? kfData?.ease, }); }} + onContextMenuClip={(e, el) => { + e.preventDefault(); + setSelectedElementId(el.key ?? el.id); + onSelectElement?.(el); + setClipContextMenu({ x: e.clientX, y: e.clientY, element: el }); + }} />
@@ -583,6 +597,18 @@ export const Timeline = memo(function Timeline({ }} /> )} + + {clipContextMenu && ( + setClipContextMenu(null)} + onSplit={(el, time) => onSplitElement?.(el, time)} + onDelete={(el) => _onDeleteElement?.(el)} + /> + )}
); }); diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 1d9233930..215c55b97 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -67,6 +67,7 @@ interface TimelineCanvasProps { onShiftClickKeyframe?: (elementId: string, percentage: number) => void; onDragKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void; onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void; } @@ -116,6 +117,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({ onShiftClickKeyframe, onDragKeyframe, onContextMenuKeyframe, + onContextMenuClip, onToggleKeyframeAtPlayhead: _onToggleKeyframeAtPlayhead, }: TimelineCanvasProps) { const draggedElement = draggedClip?.element ?? null; @@ -249,6 +251,10 @@ export const TimelineCanvas = memo(function TimelineCanvas({ return ( { + e.preventDefault(); + onContextMenuClip?.(e, el); + }} el={previewElement} pps={pps} clipY={CLIP_Y} diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index c964545a8..f26701de9 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -23,6 +23,7 @@ interface TimelineClipProps { onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void; onClick: (e: React.MouseEvent) => void; onDoubleClick: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; children?: ReactNode; } @@ -44,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({ onResizeStart, onClick, onDoubleClick, + onContextMenu, children, }: TimelineClipProps) { const leftPx = el.start * pps; @@ -98,6 +100,7 @@ export const TimelineClip = memo(function TimelineClip({ onPointerDown={onPointerDown} onClick={onClick} onDoubleClick={onDoubleClick} + onContextMenu={onContextMenu} > {/* Left accent stripe */}