Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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