Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,8 @@ export function StudioApp() {
handleTimelineFileDrop={timelineEditing.handleTimelineFileDrop}
handleTimelineElementMove={timelineEditing.handleTimelineElementMove}
handleTimelineElementResize={timelineEditing.handleTimelineElementResize}
handleTimelineGroupMove={timelineEditing.handleTimelineGroupMove}
handleTimelineGroupResize={timelineEditing.handleTimelineGroupResize}
handleToggleTrackHidden={timelineEditing.handleToggleTrackHidden}
handleToggleElementHidden={timelineEditing.handleToggleElementHidden}
handleBlockedTimelineEdit={timelineEditing.handleBlockedTimelineEdit}
Expand Down
74 changes: 71 additions & 3 deletions packages/studio/src/components/StudioPreviewArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ import { TimelineEditProvider } from "../contexts/TimelineEditContext";
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
import type { GestureRecordingState } from "./editor/GestureRecordControl";
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
import type {
TimelineGroupMoveChange,
TimelineGroupResizeChange,
} from "../hooks/useTimelineGroupEditing";
import {
formatTimelineAttributeNumber,
patchIframeDomTiming,
} from "../hooks/timelineEditingHelpers";

export interface StudioPreviewAreaProps {
timelineToolbar: ReactNode;
Expand Down Expand Up @@ -58,6 +67,8 @@ export interface StudioPreviewAreaProps {
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
handleTimelineGroupMove: (changes: TimelineGroupMoveChange[]) => Promise<void> | void;
handleTimelineGroupResize: (changes: TimelineGroupResizeChange[]) => Promise<void> | void;
handleToggleTrackHidden: (track: number, hidden: boolean) => Promise<void> | void;
handleToggleElementHidden: (elementKey: string, hidden: boolean) => Promise<void> | void;
handleBlockedTimelineEdit: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
Expand Down Expand Up @@ -85,6 +96,8 @@ export function StudioPreviewArea({
handleTimelineFileDrop,
handleTimelineElementMove,
handleTimelineElementResize,
handleTimelineGroupMove,
handleTimelineGroupResize,
handleToggleTrackHidden,
handleToggleElementHidden,
handleBlockedTimelineEdit,
Expand Down Expand Up @@ -148,6 +161,9 @@ export function StudioPreviewArea({
buildDomSelectionForTimelineElement,
applyMarqueeSelection,
} = useDomEditActionsContext();
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const timelineElements = usePlayerStore((s) => s.elements);

// fallow-ignore-next-line complexity
const [snapPrefs, setSnapPrefs] = useState(() => {
Expand All @@ -160,6 +176,18 @@ export function StudioPreviewArea({
};
});

useTimelineSelectionPreviewSync({
selectedElementId,
selectedElementIds,
timelineElements,
domEditSelection,
domEditGroupSelections,
activeCompPath,
buildDomSelectionForTimelineElement,
applyDomSelection,
applyMarqueeSelection,
});

// Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its
// tween-relative percentage (shared by the delete/move keyframe callbacks): the
// diamond reports a clip-% but the script ops key on the tween-%. Prefers the
Expand All @@ -178,11 +206,47 @@ export function StudioPreviewArea({
[domEditSelection?.id, selectedGsapAnimations],
);

const handleTimelineGroupMovePreview = useCallback(
(changes: TimelineGroupMoveChange[]) => {
for (const change of changes) {
patchIframeDomTiming(previewIframeRef.current, change.element, [
["data-start", formatTimelineAttributeNumber(change.start)],
]);
}
},
[previewIframeRef],
);

const handleTimelineGroupResizePreview = useCallback(
(changes: TimelineGroupResizeChange[]) => {
for (const change of changes) {
const attrs: Array<[string, string]> = [
["data-start", formatTimelineAttributeNumber(change.start)],
["data-duration", formatTimelineAttributeNumber(change.duration)],
];
if (change.playbackStart != null) {
attrs.push([
change.element.playbackStartAttr === "playback-start"
? "data-playback-start"
: "data-media-start",
formatTimelineAttributeNumber(change.playbackStart),
]);
}
patchIframeDomTiming(previewIframeRef.current, change.element, attrs);
}
},
[previewIframeRef],
);

// fallow-ignore-next-line complexity
const timelineEditCallbacks = useMemo(
() => ({
onMoveElement: handleTimelineElementMove,
onResizeElement: handleTimelineElementResize,
onMoveElements: handleTimelineGroupMove,
onResizeElements: handleTimelineGroupResize,
onPreviewMoveElements: handleTimelineGroupMovePreview,
onPreviewResizeElements: handleTimelineGroupResizePreview,
onToggleTrackHidden: handleToggleTrackHidden,
onToggleElementHidden: handleToggleElementHidden,
onBlockedEditAttempt: handleBlockedTimelineEdit,
Expand All @@ -191,7 +255,7 @@ export function StudioPreviewArea({
onRazorSplitAll: handleRazorSplitAll,
onDeleteAllKeyframes: () => {
// Hold the element where it is (collapse keyframes to a static set) rather
// than deleting the whole animation deleting strands a stale GSAP base
// than deleting the whole animation, deleting strands a stale GSAP base
// that the next drag adds to, flinging the element off-screen.
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (!anim) return;
Expand All @@ -211,7 +275,7 @@ export function StudioPreviewArea({
// absolute time (via the clip's timing basis) and let resolveKeyframeRetime
// decide: a drop inside the tween window is a plain move (re-key tween-%); a
// drop past the boundary (last keyframe past the end, first before the start)
// resizes the tween position/duration grow so the dragged keyframe lands at
// resizes the tween, position/duration grow so the dragged keyframe lands at
// the drop while every other keyframe keeps its absolute time (value+ease too).
// fallow-ignore-next-line complexity
onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => {
Expand Down Expand Up @@ -284,6 +348,10 @@ export function StudioPreviewArea({
[
handleTimelineElementMove,
handleTimelineElementResize,
handleTimelineGroupMove,
handleTimelineGroupMovePreview,
handleTimelineGroupResize,
handleTimelineGroupResizePreview,
handleToggleTrackHidden,
handleToggleElementHidden,
handleBlockedTimelineEdit,
Expand Down Expand Up @@ -327,7 +395,7 @@ export function StudioPreviewArea({
onCompositionLoadingChange={setCompositionLoading}
onCompositionChange={(compPath) => {
// Sync activeCompPath when user drills down via timeline double-click
// or navigates back via breadcrumb keeps sidebar + thumbnails in sync.
// or navigates back via breadcrumb, keeps sidebar + thumbnails in sync.
// Guard against no-op updates to prevent circular refresh cascades
// between activeCompPath → compositionStack → onCompositionChange.
if (compPath !== activeCompPath) {
Expand Down
8 changes: 6 additions & 2 deletions packages/studio/src/contexts/TimelineEditContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function useTimelineEditContext(): TimelineEditCallbacks {
}

/**
* Optional access returns an empty object when outside a provider.
* Optional access, returns an empty object when outside a provider.
* Useful in components that can render both inside and outside the NLE.
*/
export function useTimelineEditContextOptional(): TimelineEditCallbacks {
Expand All @@ -26,12 +26,16 @@ export function TimelineEditProvider({
}) {
const memoized = useMemo(
() => value,
// Each callback is a stable reference from the parent memoize the bag
// Each callback is a stable reference from the parent, memoize the bag
// so consumers don't re-render when unrelated parent state changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
[
value.onMoveElement,
value.onResizeElement,
value.onMoveElements,
value.onResizeElements,
value.onPreviewMoveElements,
value.onPreviewResizeElements,
value.onToggleTrackHidden,
value.onToggleElementHidden,
value.onBlockedEditAttempt,
Expand Down
162 changes: 162 additions & 0 deletions packages/studio/src/hooks/timelineEditingHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ export interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
/** Per-entry coalesce window override (ms); lets a slow follow-up still merge. */
coalesceMs?: number;
files: Record<string, { before: string; after: string }>;
}

Expand Down Expand Up @@ -312,6 +314,66 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
input.domEditSaveTimestampRef.current = Date.now();
}

export interface PersistTimelineBatchChange {
element: TimelineElement;
buildPatches: (original: string, target: PatchTarget) => string;
}

export interface PersistTimelineBatchEditInput {
projectId: string;
activeCompPath: string | null;
label: string;
changes: PersistTimelineBatchChange[];
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
coalesceKey?: string;
}

export async function persistTimelineBatchEdit(
input: PersistTimelineBatchEditInput,
): Promise<void> {
const originals = new Map<string, string>();
const patchedByPath = new Map<string, string>();

for (const change of input.changes) {
const targetPath = change.element.sourceFile || input.activeCompPath || "index.html";
const original =
originals.get(targetPath) ?? (await readFileContent(input.projectId, targetPath));
originals.set(targetPath, original);

const patchTarget = buildPatchTarget(change.element);
if (!patchTarget) {
throw new Error(`Timeline element ${change.element.id} is missing a patchable target`);
}

const current = patchedByPath.get(targetPath) ?? original;
const patched = change.buildPatches(current, patchTarget);
if (patched === current) {
throw new Error(`Unable to patch timeline element ${change.element.id} in ${targetPath}`);
}
patchedByPath.set(targetPath, patched);
}

const files = Object.fromEntries(patchedByPath);
for (const targetPath of Object.keys(files)) {
input.pendingTimelineEditPathRef.current.add(targetPath);
}
input.domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: input.projectId,
label: input.label,
kind: "timeline",
coalesceKey: input.coalesceKey,
files,
readFile: async (path) => originals.get(path) ?? readFileContent(input.projectId, path),
writeFile: input.writeProjectFile,
recordEdit: input.recordEdit,
});
input.domEditSaveTimestampRef.current = Date.now();
}

export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
if (targetPath.includes("\0") || targetPath.includes("..")) {
throw new Error(`Unsafe path: ${targetPath}`);
Expand Down Expand Up @@ -370,6 +432,53 @@ export async function finishTimelineTimingFallback(input: {
input.reloadPreview();
}

// Coalesce window for folding a GSAP mutation into the preceding timing edit; only has to
// outlast one GSAP server round-trip, never a real second edit.
const GSAP_HISTORY_COALESCE_MS = 10_000;

/**
* A server GSAP rewrite mutates the same file the timing patch just wrote, but AFTER the
* timing edit was recorded, leaving the recorded `after` stale so an undo hits a hash
* conflict. This snapshots every touched file, runs the mutation, then records a follow-up
* edit under the same coalesceKey with a window wide enough to survive the GSAP round-trip,
* folding both writes into one undo step. Returns the mutation status for caller reloads.
*/
export async function foldGsapMutationIntoHistory(input: {
projectId: string;
paths: string[];
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
gsapMutation: () => Promise<GsapMutationStatus>;
}): Promise<GsapMutationStatus> {
const uniquePaths = [...new Set(input.paths)];
const before = new Map<string, string>();
for (const path of uniquePaths) {
before.set(path, await readFileContent(input.projectId, path));
}
const status = await input.gsapMutation();
if (status.mutated) {
const files: Record<string, { before: string; after: string }> = {};
for (const path of uniquePaths) {
const priorContent = before.get(path);
const finalContent = await readFileContent(input.projectId, path);
if (priorContent !== undefined && finalContent !== priorContent) {
files[path] = { before: priorContent, after: finalContent };
}
}
if (Object.keys(files).length > 0) {
await input.recordEdit({
label: input.label,
kind: "timeline",
coalesceKey: input.coalesceKey,
coalesceMs: GSAP_HISTORY_COALESCE_MS,
files,
});
}
}
return status;
}

/**
* Shift all GSAP animation positions targeting a given element by a time delta.
* Calls the server-side GSAP mutation endpoint which uses the AST-based parser.
Expand Down Expand Up @@ -433,5 +542,58 @@ export async function scaleGsapPositions(
return readMutationStatus(await res.json().catch(() => null));
}

/** Single-clip move GSAP shift, folded into the timing edit's history entry (see above). */
export function foldedShiftGsapMutation(input: {
projectId: string;
targetPath: string;
domId: string;
delta: number;
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
}): () => Promise<GsapMutationStatus> {
return () =>
foldGsapMutationIntoHistory({
projectId: input.projectId,
paths: [input.targetPath],
label: input.label,
coalesceKey: input.coalesceKey,
recordEdit: input.recordEdit,
gsapMutation: () =>
shiftGsapPositions(input.projectId, input.targetPath, input.domId, input.delta),
});
}

/** Single-clip resize GSAP scale, folded into the timing edit's history entry (see above). */
export function foldedScaleGsapMutation(input: {
projectId: string;
targetPath: string;
domId: string;
from: { start: number; duration: number };
to: { start: number; duration: number };
label: string;
coalesceKey?: string;
recordEdit: (edit: RecordEditInput) => Promise<void>;
}): () => Promise<GsapMutationStatus> {
return () =>
foldGsapMutationIntoHistory({
projectId: input.projectId,
paths: [input.targetPath],
label: input.label,
coalesceKey: input.coalesceKey,
recordEdit: input.recordEdit,
gsapMutation: () =>
scaleGsapPositions(
input.projectId,
input.targetPath,
input.domId,
input.from.start,
input.from.duration,
input.to.start,
input.to.duration,
),
});
}

// Re-export applyPatchByTarget for use in the hook (avoids double import in callers)
export { applyPatchByTarget, formatTimelineAttributeNumber };
Loading
Loading