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
38 changes: 25 additions & 13 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,10 @@ export function StudioApp() {
const handleDomEditElementDeleteRef = useRef<(s: DomEditSelection) => Promise<void>>(
async () => {},
);
const domEditDeleteBridge = async (s: DomEditSelection) =>
handleDomEditElementDeleteRef.current(s);
const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s);
const resetKeyframesRef = useRef<() => boolean>(() => false);
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
const invalidateGsapCacheRef = useRef<() => void>(() => {});
const { handleCopy, handlePaste, handleCut } = useClipboard({
projectId,
activeCompPath,
Expand Down Expand Up @@ -299,8 +301,10 @@ export function StudioApp() {
handleCopy,
handlePaste,
handleCut,
onResetKeyframes: () => resetKeyframesRef.current(),
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
});

const selectSidebarTabStable = useCallback(
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
[],
Expand Down Expand Up @@ -345,11 +349,20 @@ export function StudioApp() {
selectSidebarTab: selectSidebarTabStable,
getSidebarTab: getSidebarTabStable,
});

domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
clearDomSelectionRef.current = domEditSession.clearDomSelection;
handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete;

resetKeyframesRef.current = domEditSession.handleResetSelectedElementKeyframes;
invalidateGsapCacheRef.current = domEditSession.invalidateGsapCache;
deleteSelectedKeyframesRef.current = () => {
const sk = usePlayerStore.getState().selectedKeyframes;
const a = domEditSession.selectedGsapAnimations.find((x) => x.keyframes);
if (!a || sk.size === 0) return;
sk.forEach((k) => {
const p = Number(k.split(":")[1]);
if (Number.isFinite(p)) domEditSession.handleGsapRemoveKeyframe(a.id, p);
});
};
useCaptionDetection({
projectId,
activeCompPath,
Expand Down Expand Up @@ -470,12 +483,14 @@ export function StudioApp() {
timelineVisible,
toggleTimelineVisibility,
});

if (resolving || waitingForServer || !projectId) {
if (resolving || waitingForServer || !projectId)
return <StudioSplash waiting={waitingForServer} />;
}

const timelineToolbar = <TimelineToolbar toggleTimelineVisibility={toggleTimelineVisibility} />;
const timelineToolbar = (
<TimelineToolbar
toggleTimelineVisibility={toggleTimelineVisibility}
domEditSession={domEditSession}
/>
);
return (
<StudioProvider value={studioCtxValue}>
<PanelLayoutProvider value={panelLayout}>
Expand Down Expand Up @@ -540,15 +555,13 @@ export function StudioApp() {
{lintModal !== null && (
<LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
)}

{consoleErrors !== null && consoleErrors.length > 0 && (
<LintModal
findings={consoleErrors}
projectId={projectId}
onClose={() => setConsoleErrors(null)}
/>
)}

{domEditSession.agentModalOpen && domEditSession.domEditSelection && (
<AskAgentModal
selectionLabel={domEditSession.domEditSelection.label}
Expand All @@ -567,7 +580,6 @@ export function StudioApp() {
)}

{dragOverlay.active && <StudioGlobalDragOverlay />}

{appToast && (
<div
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
Expand Down
54 changes: 54 additions & 0 deletions packages/studio/src/components/StudioPreviewArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CaptionTimeline } from "../captions/components/CaptionTimeline";
import { DomEditOverlay } from "./editor/DomEditOverlay";
import { StudioFeedbackBar } from "./StudioFeedbackBar";
import type { TimelineElement } from "../player";
import { usePlayerStore } from "../player/store/playerStore";
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
Expand Down Expand Up @@ -101,6 +102,12 @@ export function StudioPreviewArea({
handleDomGroupPathOffsetCommit,
handleDomBoxSizeCommit,
handleDomRotationCommit,
selectedGsapAnimations,
handleGsapRemoveKeyframe,
handleGsapUpdateMeta,
handleGsapAddKeyframe,
handleGsapConvertToKeyframes,
handleGsapRemoveAllKeyframes,
} = useDomEditContext();

return (
Expand All @@ -121,6 +128,53 @@ export function StudioPreviewArea({
onResizeElement={handleTimelineElementResize}
onBlockedEditAttempt={handleBlockedTimelineEdit}
onSelectTimelineElement={handleTimelineElementSelect}
onDeleteAllKeyframes={(_elId) => {
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (anim) handleGsapRemoveAllKeyframes(anim.id);
}}
onDeleteKeyframe={(_elId, pct) => {
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (anim) handleGsapRemoveKeyframe(anim.id, pct);
}}
onChangeKeyframeEase={(_elId, _pct, ease) => {
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (anim) handleGsapUpdateMeta(anim.id, { ease });
}}
// fallow-ignore-next-line complexity
onMoveKeyframe={(_el, oldPct, newPct) => {
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (!anim?.keyframes) return;
const kf = anim.keyframes.keyframes.find((k) => k.percentage === oldPct);
if (!kf) return;
handleGsapRemoveKeyframe(anim.id, oldPct);
for (const [prop, val] of Object.entries(kf.properties)) {
handleGsapAddKeyframe(anim.id, newPct, prop, val);
}
}}
onToggleKeyframeAtPlayhead={(el) => {
const currentTime = usePlayerStore.getState().currentTime;
const pct =
el.duration > 0
? Math.max(
0,
Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100)),
)
: 0;
const anim = selectedGsapAnimations.find((a) => a.keyframes);
if (anim?.keyframes) {
const existing = anim.keyframes.keyframes.find(
(k) => Math.abs(k.percentage - pct) <= 1,
);
if (existing) {
handleGsapRemoveKeyframe(anim.id, existing.percentage);
} else {
handleGsapAddKeyframe(anim.id, pct, "x", 0);
}
} else {
const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes);
if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id);
}
}}
onCompIdToSrcChange={setCompIdToSrc}
onCompositionLoadingChange={setCompositionLoading}
onCompositionChange={(compPath) => {
Expand Down
72 changes: 68 additions & 4 deletions packages/studio/src/components/editor/AnimationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,29 @@ import {
METHOD_LABELS,
METHOD_TOOLTIPS,
PERCENT_PROPS,
PROP_CONSTRAINTS,
PROP_LABELS,
PROP_TOOLTIPS,
PROP_UNITS,
clampPropertyValue,
} from "./gsapAnimationConstants";
import { buildTweenSummary } from "./gsapAnimationHelpers";
import { EaseCurveSection } from "./EaseCurveSection";
const BOOLEAN_PROPS = new Set(["visibility"]);
const STRING_PROPS = new Set(["filter", "clipPath"]);

const FILTER_PRESETS = [
{ label: "Blur", value: "blur(4px)" },
{ label: "Bright", value: "brightness(1.5)" },
{ label: "Gray", value: "grayscale(1)" },
{ label: "None", value: "none" },
];

const CLIP_PATH_PRESETS = [
{ label: "Circle", value: "circle(50% at 50% 50%)" },
{ label: "Inset", value: "inset(10%)" },
{ label: "None", value: "none" },
];

function isPercentProp(prop: string): boolean {
return PERCENT_PROPS.has(prop);
Expand All @@ -27,7 +43,11 @@ function displayValue(prop: string, val: number | string): string {
}

function adjustedValue(prop: string, raw: string): string {
if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100)));
if (isPercentProp(prop)) return String(clampPropertyValue(prop, Number(raw) / 100));
const num = Number(raw);
if (!Number.isNaN(num) && PROP_CONSTRAINTS[prop]) {
return String(clampPropertyValue(prop, num));
}
return raw;
}

Expand Down Expand Up @@ -90,6 +110,48 @@ function PropertyRow({
);
}

if (STRING_PROPS.has(prop)) {
const presets =
prop === "filter" ? FILTER_PRESETS : prop === "clipPath" ? CLIP_PATH_PRESETS : [];
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
<span className="flex-shrink-0 text-[11px] font-medium text-neutral-500">
{PROP_LABELS[prop] ?? prop}
</span>
<input
type="text"
defaultValue={String(val)}
className="flex-1 bg-transparent text-[11px] text-neutral-200 outline-none"
onBlur={(e) => onCommit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
/>
</div>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
{presets.length > 0 && (
<div className="flex gap-1 pl-1">
{presets.map((p) => (
<button
key={p.value}
type="button"
onClick={() => onCommit(p.value)}
className="px-1.5 py-0.5 rounded text-[9px] font-medium text-neutral-500 bg-neutral-800/50 hover:bg-neutral-800 hover:text-neutral-300 transition-colors"
>
{p.label}
</button>
))}
</div>
)}
</div>
);
}

return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
Expand Down Expand Up @@ -292,8 +354,10 @@ export const AnimationCard = memo(function AnimationCard({
{methodLabel}
</span>
<span className="text-[11px] font-medium text-neutral-400" title="When this effect plays">
{typeof animation.position === "number" ? `${animation.position}s` : animation.position} –{" "}
{typeof endTime === "number" ? `${endTime.toFixed(1)}s` : endTime}
{typeof animation.position === "number"
? `${parseFloat(animation.position.toFixed(3))}s`
: animation.position}{" "}
– {typeof endTime === "number" ? `${parseFloat(endTime.toFixed(3))}s` : endTime}
</span>
<span className="ml-auto text-[10px] text-neutral-500" title={easeName}>
{easeLabel}
Expand Down Expand Up @@ -344,7 +408,7 @@ export const AnimationCard = memo(function AnimationCard({
value={
typeof animation.position === "string"
? animation.position
: String(Math.max(0, animation.position))
: String(parseFloat(Math.max(0, animation.position).toFixed(3)))
}
suffix={typeof animation.position === "number" ? "s" : undefined}
tooltip="When this effect begins on the timeline"
Expand Down
Loading
Loading