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
8 changes: 8 additions & 0 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSid
import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore } from "./player";
import { LintModal } from "./components/LintModal";
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { useCaptionStore } from "./captions/store";
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
Expand Down Expand Up @@ -481,6 +482,13 @@ export function StudioApp() {
onExport={() => void renderQueue.startRender()}
/>

{previewPersistence.domEditSaveQueuePaused && (
<SaveQueuePausedBanner
message={previewPersistence.domEditSaveQueuePaused}
onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
/>
)}

<div className="flex flex-1 min-h-0">
<StudioLeftSidebar
leftSidebarRef={leftSidebarRef}
Expand Down
23 changes: 23 additions & 0 deletions packages/studio/src/components/SaveQueuePausedBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
interface SaveQueuePausedBannerProps {
message: string;
onDismiss: () => void;
}

/** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
return (
<div
className="absolute left-1/2 top-14 z-[92] flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-red-500/30 bg-red-950/85 px-4 py-2 text-[12px] font-medium text-red-100 shadow-lg shadow-black/30"
role="alert"
>
<span>{message}</span>
<button
type="button"
onClick={onDismiss}
className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10"
>
Dismiss
</button>
</div>
);
}
45 changes: 26 additions & 19 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { memo, useEffect, useRef, useState } from "react";
import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
import { useStudioContext } from "../../contexts/StudioContext";
Expand All @@ -20,6 +20,7 @@
import { usePlayerStore, liveTime } from "../../player";
import { TimingSection } from "./propertyPanelTimingSection";
import { type PropertyPanelProps } from "./propertyPanelHelpers";
import { useAnimatedPropertyCommitTelemetry } from "../../hooks/useAnimatedPropertyCommitTelemetry";

// Re-export helpers that external consumers import from this module
export {
Expand Down Expand Up @@ -111,6 +112,8 @@
const currentTime = isPlaying ? liveTimeRef.current : storeTime;
const cacheElementKey = element?.id ?? element?.selector ?? "";
const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
const { commitAnimatedPropertySafely, commitAnimatedPropertyWithTelemetry } =
useAnimatedPropertyCommitTelemetry(onCommitAnimatedProperty);

if (!element) {
return (
Expand Down Expand Up @@ -163,7 +166,7 @@
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, axis, parsed);
commitAnimatedPropertySafely(element, axis, parsed);
return;
}
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
Expand All @@ -176,18 +179,20 @@
return;
}
const current = readStudioPathOffset(element.element);
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
});
void Promise.resolve(
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
}),
).catch(() => {});
};

// fallow-ignore-next-line complexity
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return;
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, axis, parsed);
commitAnimatedPropertySafely(element, axis, parsed);
return;
}
if (hasGsapAnimation) {
Expand All @@ -203,17 +208,19 @@
current.height > 0
? current.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
onSetManualSize(element, {
width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height,
});
void Promise.resolve(
onSetManualSize(element, {
width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height,
}),
).catch(() => {});
};

const manualRotation = readStudioRotation(element.element);
const commitManualRotation = (nextValue: string) => {
const parsed = Number.parseFloat(nextValue);
if (!Number.isFinite(parsed)) return;
onSetManualRotation(element, { angle: parsed });
void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => {});
};

const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
Expand Down Expand Up @@ -392,8 +399,7 @@
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "x", displayX)
onCommitAnimatedProperty && commitAnimatedPropertySafely(element, "x", displayX)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
Expand All @@ -417,8 +423,7 @@
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "y", displayY)
onCommitAnimatedProperty && commitAnimatedPropertySafely(element, "y", displayY)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
Expand All @@ -443,7 +448,7 @@
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "width", displayW)
commitAnimatedPropertySafely(element, "width", displayW)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
Expand All @@ -468,7 +473,7 @@
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "height", displayH)
commitAnimatedPropertySafely(element, "height", displayH)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
Expand All @@ -491,7 +496,7 @@
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty &&
void onCommitAnimatedProperty(element, "rotation", displayR)
commitAnimatedPropertySafely(element, "rotation", displayR)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(gsapAnimId, pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(gsapAnimId)}
Expand All @@ -508,7 +513,9 @@
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperty={
onCommitAnimatedProperty ? commitAnimatedPropertyWithTelemetry : undefined
}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
Expand Down
12 changes: 9 additions & 3 deletions packages/studio/src/components/editor/propertyPanelHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@ export interface PropertyPanelProps {
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onSetAttribute: (attr: string, value: string) => void | Promise<void>;
onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
onSetManualOffset: (
element: DomEditSelection,
next: { x: number; y: number },
) => void | Promise<void>;
onSetManualSize: (
element: DomEditSelection,
next: { width: number; height: number },
) => void | Promise<void>;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise<void>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
Expand Down
4 changes: 2 additions & 2 deletions packages/studio/src/hooks/useAnimatedPropertyCommit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ interface CommitAnimatedPropertyDeps {
selection: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
currentTime?: number,
) => void;
) => Promise<void>;
convertToKeyframes: (selection: DomEditSelection, animId: string) => void;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
bumpGsapCache: () => void;
Expand Down Expand Up @@ -106,7 +106,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {

// Case 3: No animation — create one first
if (!anim) {
addGsapAnimation(selection, "to");
await addGsapAnimation(selection, "to");
// The addGsapAnimation triggers a reload. We need to wait for the cache
// to update. Use a small delay then bump cache to re-fetch.
await new Promise((r) => setTimeout(r, 500));
Expand Down
47 changes: 47 additions & 0 deletions packages/studio/src/hooks/useAnimatedPropertyCommitTelemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useCallback } from "react";
import { useStudioContext } from "../contexts/StudioContext";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";

type CommitAnimatedProperty = (
selection: DomEditSelection,
property: string,
value: number,
) => Promise<void>;

export function useAnimatedPropertyCommitTelemetry(
onCommitAnimatedProperty: CommitAnimatedProperty | undefined,
) {
const { showToast } = useStudioContext();

const commitAnimatedPropertyWithTelemetry = useCallback(
async (selection: DomEditSelection, property: string, value: number) => {
if (!onCommitAnimatedProperty) return;
try {
await onCommitAnimatedProperty(selection, property, value);
} catch (error) {
trackStudioSaveFailure({
source: "animated_property",
error,
filePath: selection.sourceFile ?? undefined,
mutationType: property,
label: `Edit ${property}`,
targetId: selection.id,
targetSelector: selection.selector,
targetSourceFile: selection.sourceFile,
});
showToast?.("Failed to save animated property.", "error");
}
},
[onCommitAnimatedProperty, showToast],
);

const commitAnimatedPropertySafely = useCallback(
(selection: DomEditSelection, property: string, value: number) => {
void commitAnimatedPropertyWithTelemetry(selection, property, value);
},
[commitAnimatedPropertyWithTelemetry],
);

return { commitAnimatedPropertySafely, commitAnimatedPropertyWithTelemetry };
}
63 changes: 50 additions & 13 deletions packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { shouldHandleTimelineToggleHotkey, isEditableTarget } from "../utils/tim
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
import { canSplitElement } from "../utils/timelineElementSplit";
import { STUDIO_RAZOR_TOOL_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";

/** Safely resolves contentWindow for a potentially cross-origin iframe. */
function iframeContentWindow(iframe: HTMLIFrameElement | null): Window | null {
Expand Down Expand Up @@ -222,6 +223,39 @@ export function useAppHotkeys({
const onToggleRecordingRef = useRef(onToggleRecording);
onToggleRecordingRef.current = onToggleRecording;

const runUndoRedoWithTelemetry = useCallback(
async (action: "undo" | "redo", run: () => Promise<void>) => {
try {
await run();
} catch (error) {
trackStudioSaveFailure({
source: "undo_redo",
error,
mutationType: action,
label: action === "undo" ? "Undo" : "Redo",
});
showToast(`Failed to ${action}.`, "error");
}
},
[showToast],
);

const runUndoRedoHotkey = useCallback(
(action: "undo" | "redo", run: () => Promise<void>) => {
void runUndoRedoWithTelemetry(action, run);
},
[runUndoRedoWithTelemetry],
);

const handleUndoWithTelemetry = useCallback(
() => runUndoRedoWithTelemetry("undo", handleUndo),
[handleUndo, runUndoRedoWithTelemetry],
);
const handleRedoWithTelemetry = useCallback(
() => runUndoRedoWithTelemetry("redo", handleRedo),
[handleRedo, runUndoRedoWithTelemetry],
);

// ── Consolidated keydown handler ──

handleAppKeyDownRef.current = (event: KeyboardEvent) => {
Expand All @@ -234,8 +268,8 @@ export function useAppHotkeys({
!shouldIgnoreHistoryShortcut(event.target) &&
handleUndoRedoKey(
event,
() => void handleUndoRef.current(),
() => void handleRedoRef.current(),
() => runUndoRedoHotkey("undo", handleUndoRef.current),
() => runUndoRedoHotkey("redo", handleRedoRef.current),
)
) {
return;
Expand Down Expand Up @@ -498,15 +532,18 @@ export function useAppHotkeys({

// ── History hotkey for iframe forwarding ──

const handleHistoryHotkey = useCallback((event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey)) return;
if (shouldIgnoreHistoryShortcut(event.target)) return;
handleUndoRedoKey(
event,
() => void handleUndoRef.current(),
() => void handleRedoRef.current(),
);
}, []);
const handleHistoryHotkey = useCallback(
(event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey)) return;
if (shouldIgnoreHistoryShortcut(event.target)) return;
handleUndoRedoKey(
event,
() => runUndoRedoHotkey("undo", handleUndoRef.current),
() => runUndoRedoHotkey("redo", handleRedoRef.current),
);
},
[runUndoRedoHotkey],
);

const syncPreviewHistoryHotkey = useCallback(
(iframe: HTMLIFrameElement | null) => {
Expand Down Expand Up @@ -549,8 +586,8 @@ export function useAppHotkeys({
);

return {
handleUndo,
handleRedo,
handleUndo: handleUndoWithTelemetry,
handleRedo: handleRedoWithTelemetry,
syncPreviewTimelineHotkey,
syncPreviewHistoryHotkey,
handleTimelineToggleHotkey,
Expand Down
Loading
Loading