Skip to content
Closed
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
26 changes: 10 additions & 16 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions packages/core/src/parsers/gsapWriter.acorn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,14 @@ describe("T6c — keyframe write ops", () => {
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
});

it("addKeyframeToScript merges a new property into an existing keyframe, preserving siblings", () => {
// 50% already holds { opacity: 0.7 }; adding x must NOT drop opacity.
const result = addKeyframeToScript(SCRIPT_D, "#box-to-200-visual", 50, { x: 100 });
expect(result).toContain("opacity: 0.7");
expect(result).toContain("x: 100");
expect((result.match(/"50%"/g) ?? []).length).toBe(1);
});

it("removeKeyframeFromScript removes the target percentage", () => {
// Remove 50% from 0%/50%/100% → leaves 0%/100% (no collapse in T6c)
const result = removeKeyframeFromScript(SCRIPT_D, "#box-to-200-visual", 50);
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/parsers/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,17 @@ export function addKeyframeToScript(

const existing = findKfPropByPct(kfNode, percentage);
if (existing) {
ms.overwrite(existing.prop.value.start, existing.prop.value.end, valueCode);
// Merge into the existing keyframe at this percentage, preserving sibling
// properties — overwrite only the given keys. (A whole-value overwrite here
// would silently drop other properties already keyframed at this percent.)
if (existing.prop.value?.type === "ObjectExpression") {
for (const [k, v] of Object.entries(properties)) {
upsertProp(ms, existing.prop.value, k, v);
}
if (ease !== undefined) upsertProp(ms, existing.prop.value, "ease", ease);
} else {
ms.overwrite(existing.prop.value.start, existing.prop.value.end, valueCode);
}
} else {
const allProps = (kfNode.properties ?? []).filter((p: any) => isObjectProperty(p));
let insertBeforeProp: any = null;
Expand Down
19 changes: 8 additions & 11 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export function StudioApp() {
uploadProjectFiles: fileManager.uploadProjectFiles,
isRecordingRef: isGestureRecordingRef,
sdkSession,
forceReloadSdkSession,
});
const {
activeBlockParams,
Expand Down Expand Up @@ -261,14 +262,10 @@ export function StudioApp() {
? () => handleToggleRecordingRef.current()
: undefined,
});
const selectSidebarTabStable = useCallback(
(tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab),
[],
);
const getSidebarTabStable = useCallback(
() => leftSidebarRef.current?.getTab() ?? "compositions",
[],
);
const sidebarTabRef = useRef({
select: (t: SidebarTab) => leftSidebarRef.current?.selectTab(t),
get: () => leftSidebarRef.current?.getTab() ?? "compositions",
});
const domEditSession = useDomEditSession({
projectId,
activeCompPath,
Expand Down Expand Up @@ -301,9 +298,10 @@ export function StudioApp() {
reloadPreview,
setRefreshKey,
openSourceForSelection: fileManager.openSourceForSelection,
selectSidebarTab: selectSidebarTabStable,
getSidebarTab: getSidebarTabStable,
selectSidebarTab: sidebarTabRef.current.select,
getSidebarTab: sidebarTabRef.current.get,
sdkSession,
forceReloadSdkSession,
});
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
clearDomSelectionRef.current = domEditSession.clearDomSelection;
Expand Down Expand Up @@ -358,7 +356,6 @@ export function StudioApp() {
resetErrors: resetConsoleErrors,
} = useConsoleErrorCapture(previewIframe);
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);

// Gesture recording
const handleToggleRecordingRef = useRef<() => void>(() => {});
const domEditSessionRef = useRef(domEditSession);
Expand Down
11 changes: 0 additions & 11 deletions packages/studio/src/components/editor/manualEditingAvailability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,15 +88,4 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(

export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;

// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK
// session alongside the server patch path and logs mismatches via telemetry.
// Default on: server stays authoritative (no user-visible change), so we want
// the sdk_shadow_dispatch parity signal from all traffic. Disable via
// VITE_STUDIO_SDK_SHADOW_ENABLED=false.
export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
env,
["VITE_STUDIO_SDK_SHADOW_ENABLED"],
true,
);

export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
5 changes: 4 additions & 1 deletion packages/studio/src/hooks/gsapScriptCommitTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export interface GsapScriptCommitsParams {
onCacheInvalidate: () => void;
onFileContentChanged?: (path: string, content: string) => void;
showToast: (message: string, tone?: "error" | "info") => void;
/** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
/** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */
sdkSession?: Composition | null;
writeProjectFile?: (path: string, content: string) => Promise<void>;
/** Resync the in-memory SDK session after a server-authoritative write. */
forceReloadSdkSession?: () => void;
}
63 changes: 48 additions & 15 deletions packages/studio/src/hooks/useDomEditCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
import { useDomEditTextCommits } from "./useDomEditTextCommits";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
import { useElementLifecycleOps } from "./useElementLifecycleOps";
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";

// Re-export so existing consumers keep their import path
export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";

// ── Helpers ──

Expand All @@ -32,7 +34,11 @@ async function readErrorResponseBody(

function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
if (!body?.error) return "Couldn't save edit";
return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
const fields = Array.isArray(body.fields)
? body.fields.filter((field): field is string => typeof field === "string")
: [];
const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
return `Couldn't save edit: ${body.error}${suffix}`;
}

interface RecordEditInput {
Expand All @@ -42,6 +48,8 @@ interface RecordEditInput {
files: Record<string, { before: string; after: string }>;
}

export type { PersistDomEditOperations } from "./domEditCommitTypes";

export interface UseDomEditCommitsParams {
activeCompPath: string | null;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
Expand All @@ -68,10 +76,20 @@ export interface UseDomEditCommitsParams {
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>;
/** Stage 7 Step 3b: called after a successful server-side element patch. */
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
/** Stage 7 Step 3b: called after a successful server-side element delete. */
onElementDeleted?: (selection: DomEditSelection) => void;
/** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK
* path, whose session is already current) so a later SDK edit doesn't
* serialize the pre-write doc and revert the server's change. */
forceReloadSdkSession?: () => void;
/** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
onTrySdkPersist?: (
selection: DomEditSelection,
operations: PatchOperation[],
originalContent: string,
targetPath: string,
options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
) => Promise<boolean>;
/** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
}

export function useDomEditCommits({
Expand All @@ -92,8 +110,9 @@ export function useDomEditCommits({
clearDomSelection,
refreshDomEditSelectionFromPreview,
buildDomSelectionFromTarget,
onDomEditPersisted,
onElementDeleted,
forceReloadSdkSession,
onTrySdkPersist,
onTrySdkDelete,
}: UseDomEditCommitsParams) {
const resolveImportedFontAsset = useCallback(
(fontFamilyValue: string): ImportedFontAsset | null => {
Expand Down Expand Up @@ -129,7 +148,6 @@ export function useDomEditCommits({
if (options?.shouldSave && !options.shouldSave()) return;

const targetPath = selection.sourceFile || activeCompPath || "index.html";

const readResponse = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
);
Expand All @@ -141,9 +159,23 @@ export function useDomEditCommits({
if (typeof originalContent !== "string") {
throw new Error(`Missing file contents for ${targetPath}`);
}

if (options?.shouldSave && !options.shouldSave()) return;

// Skip the SDK path when prepareContent is set (e.g. @font-face injection
// for a custom font): sdkCutoverPersist serializes only the patched DOM
// and would drop the injected content. Let the server path run prepareContent.
if (
onTrySdkPersist &&
!options?.prepareContent &&
(await onTrySdkPersist(selection, operations, originalContent, targetPath, {
label: options?.label,
coalesceKey: options?.coalesceKey,
skipRefresh: options?.skipRefresh,
}))
) {
// SDK handled it — its in-memory doc is already current, so do NOT
// forceReload (that would echo-reload the session we just wrote).
return;
}
const patchTarget = buildDomEditPatchTarget(selection);
const patchBody = { target: patchTarget, operations };
const unsafeFields = findUnsafeDomPatchValues(patchBody);
Expand All @@ -157,7 +189,6 @@ export function useDomEditCommits({
// handler suppresses the reload even if the event arrives before the
// response (the server writes the file and emits SSE during the fetch).
domEditSaveTimestampRef.current = Date.now();

const patchResponse = await fetch(
`/api/projects/${pid}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
{
Expand Down Expand Up @@ -215,7 +246,7 @@ export function useDomEditCommits({
coalesceKey: options?.coalesceKey,
files: { [targetPath]: { before: originalContent, after: finalContent } },
});
onDomEditPersisted?.(selection, operations);
forceReloadSdkSession?.();

if (!options?.skipRefresh) {
reloadPreview();
Expand All @@ -229,7 +260,8 @@ export function useDomEditCommits({
domEditSaveTimestampRef,
reloadPreview,
showToast,
onDomEditPersisted,
forceReloadSdkSession,
onTrySdkPersist,
],
);

Expand Down Expand Up @@ -289,8 +321,9 @@ export function useDomEditCommits({
projectIdRef,
reloadPreview,
clearDomSelection,
onTrySdkDelete,
forceReloadSdkSession,
commitPositionPatchToHtml,
onElementDeleted,
});

return {
Expand Down
Loading