Skip to content
3 changes: 3 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export function StudioApp() {
const appHotkeys = useAppHotkeys({
toggleTimelineVisibility,
handleTimelineElementDelete: timelineEditing.handleTimelineElementDelete,
handleTimelineElementSplit: timelineEditing.handleTimelineElementSplit,
handleDomEditElementDelete: domEditDeleteBridge,
domEditSelectionRef: domEditSelectionBridgeRef,
clearDomSelectionRef,
Expand Down Expand Up @@ -489,6 +490,7 @@ export function StudioApp() {
<TimelineToolbar
toggleTimelineVisibility={toggleTimelineVisibility}
domEditSession={domEditSession}
onSplitElement={timelineEditing.handleTimelineElementSplit}
/>
);
return (
Expand Down Expand Up @@ -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}
Expand Down
10 changes: 7 additions & 3 deletions packages/studio/src/components/StudioPreviewArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface StudioPreviewAreaProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
setCompIdToSrc: (map: Map<string, string>) => void;
setCompositionLoading: (loading: boolean) => void;
shouldShowSelectedDomBounds: boolean;
Expand All @@ -67,6 +68,7 @@ export function StudioPreviewArea({
handleTimelineElementMove,
handleTimelineElementResize,
handleBlockedTimelineEdit,
handleTimelineElementSplit,
setCompIdToSrc,
setCompositionLoading,
shouldShowSelectedDomBounds,
Expand Down Expand Up @@ -107,7 +109,7 @@ export function StudioPreviewArea({
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
handleGsapDeleteAnimation,
} = useDomEditContext();

return (
Expand All @@ -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);
Expand Down
73 changes: 41 additions & 32 deletions packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,38 +218,47 @@ export function TimelineToolbar({
</button>
</Tooltip>
)}
{onSplitElement && (
<Tooltip label="Split clip at playhead (S)">
<button
type="button"
onClick={() => {
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
if (!selectedElementId) return;
const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
if (el && currentTime > el.start && currentTime < el.start + el.duration) {
onSplitElement(el, currentTime);
}
}}
className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-neutral-200"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
>
<line x1="8" y1="2" x2="8" y2="14" />
<polyline points="5,5 3,2" />
<polyline points="11,5 13,2" />
<polyline points="5,11 3,14" />
<polyline points="11,11 13,14" />
</svg>
</button>
</Tooltip>
)}
{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 (
<Tooltip label="Split clip at playhead (S)">
<button
type="button"
disabled={!canSplit}
onClick={() => {
if (canSplit) onSplitElement(el, currentTime);
}}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
canSplit
? "text-neutral-500 hover:text-neutral-200"
: "text-neutral-700 cursor-not-allowed"
}`}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="18" r="3" />
<circle cx="16" cy="18" r="3" />
<line x1="12" y1="2" x2="8" y2="15" />
<line x1="12" y1="2" x2="16" y2="15" />
</svg>
</button>
</Tooltip>
);
})()}
</div>
<div className="flex items-center gap-1">
<Tooltip label="Fit timeline to width">
Expand Down
50 changes: 50 additions & 0 deletions packages/studio/src/components/editor/manualEditsDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }>;
}
>;
})
| 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<string, unknown>)) {
if (kfVal && typeof kfVal === "object") {
for (const p of propSet) {
if (p in (kfVal as Record<string, unknown>)) 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) {
Expand Down
3 changes: 3 additions & 0 deletions packages/studio/src/components/nle/NLELayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ interface NLELayoutProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onSelectTimelineElement?: (element: TimelineElement | null) => void;
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
Expand Down Expand Up @@ -122,6 +123,7 @@ export const NLELayout = memo(function NLELayout({
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
onSplitElement,
onSelectTimelineElement,
onDeleteKeyframe,
onDeleteAllKeyframes,
Expand Down Expand Up @@ -457,6 +459,7 @@ export const NLELayout = memo(function NLELayout({
onMoveElement={onMoveElement}
onResizeElement={onResizeElement}
onBlockedEditAttempt={onBlockedEditAttempt}
onSplitElement={onSplitElement}
onSelectElement={onSelectTimelineElement}
onDeleteKeyframe={onDeleteKeyframe}
onDeleteAllKeyframes={onDeleteAllKeyframes}
Expand Down
27 changes: 27 additions & 0 deletions packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ interface EditHistoryHandle {
interface UseAppHotkeysParams {
toggleTimelineVisibility: () => void;
handleTimelineElementDelete: (element: TimelineElement) => Promise<void>;
handleTimelineElementSplit: (element: TimelineElement, splitTime: number) => Promise<void>;
handleDomEditElementDelete: (selection: DomEditSelection) => Promise<void>;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
clearDomSelectionRef: React.MutableRefObject<() => void>;
Expand All @@ -87,6 +88,7 @@ interface UseAppHotkeysParams {
export function useAppHotkeys({
toggleTimelineVisibility,
handleTimelineElementDelete,
handleTimelineElementSplit,
handleDomEditElementDelete,
domEditSelectionRef,
editHistory,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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") &&
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
12 changes: 8 additions & 4 deletions packages/studio/src/player/components/ClipContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
Expand Down
26 changes: 26 additions & 0 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type KeyframeDiamondContextMenuState,
} from "./KeyframeDiamondContextMenu";
import { useTimelineClipDrag } from "./useTimelineClipDrag";
import { ClipContextMenu } from "./ClipContextMenu";
import {
GUTTER,
TRACK_H,
Expand Down Expand Up @@ -70,6 +71,7 @@ interface TimelineProps {
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
onSelectElement?: (element: TimelineElement | null) => void;
onDeleteKeyframe?: (elementId: string, percentage: number) => void;
onDeleteAllKeyframes?: (elementId: string) => void;
Expand All @@ -91,6 +93,7 @@ export const Timeline = memo(function Timeline({
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
onSplitElement,
onSelectElement,
onDeleteKeyframe,
onDeleteAllKeyframes,
Expand Down Expand Up @@ -135,6 +138,11 @@ export const Timeline = memo(function Timeline({
const [showPopover, setShowPopover] = useState(false);
const [showShortcutHint, setShowShortcutHint] = useState(true);
const [kfContextMenu, setKfContextMenu] = useState<KeyframeDiamondContextMenuState | null>(null);
const [clipContextMenu, setClipContextMenu] = useState<{
x: number;
y: number;
element: TimelineElement;
} | null>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const shortcutHintRafRef = useRef(0);
Expand Down Expand Up @@ -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 });
}}
/>
</div>

Expand Down Expand Up @@ -583,6 +597,18 @@ export const Timeline = memo(function Timeline({
}}
/>
)}

{clipContextMenu && (
<ClipContextMenu
x={clipContextMenu.x}
y={clipContextMenu.y}
element={clipContextMenu.element}
currentTime={currentTime}
onClose={() => setClipContextMenu(null)}
onSplit={(el, time) => onSplitElement?.(el, time)}
onDelete={(el) => _onDeleteElement?.(el)}
/>
)}
</div>
);
});
Loading
Loading