Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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: 7 additions & 1 deletion packages/core/src/studio-api/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,6 +713,12 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {

// Re-parse the mutated script so the UI gets fresh state
const freshParsed = parseGsapScript(newScript);
return c.json({ ok: true, parsed: freshParsed, before: html, after: newHtml });
return c.json({
ok: true,
parsed: freshParsed,
before: html,
after: newHtml,
scriptText: newScript,
});
});
}
20 changes: 20 additions & 0 deletions packages/shader-transitions/src/hyper-shader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,13 +245,29 @@ function getDocumentStyleSignature(doc: Document): string {
return stableHash(`${styleText}\n${linkedStyles}`);
}

// fallow-ignore-next-line complexity
function isGsapAnimationOnlyScript(text: string): boolean {
return (
(text.includes("gsap.timeline") || text.includes("__timelines")) &&
!text.includes("HyperShader") &&
!text.includes("hyper-shader") &&
!text.includes("hyperShader")
);
}

function getDocumentScriptSignature(doc: Document): string {
const projectSignature = Array.from(
doc.querySelectorAll<HTMLMetaElement>('meta[name="hyperframes-project-signature"]'),
)
.map((meta) => meta.getAttribute("content") || "")
.join("\n");
const scriptText = Array.from(doc.querySelectorAll<HTMLScriptElement>("script"))
.filter((script) => {
if (script.src) return true;
const text = script.textContent || "";
if (!text.trim()) return false;
return !isGsapAnimationOnlyScript(text);
})
.map((script) => {
const attrs = [
script.type,
Expand Down Expand Up @@ -2192,6 +2208,10 @@ export function init(config: HyperShaderConfig): GsapTimeline {
hfWin.__hf = hfWin.__hf || {};
hfWin.__hf.shaderTransitionsReady = prewarmPromise;

(
window as Window & { __hfSuppressSceneMutations?: <T>(fn: () => T) => T }
).__hfSuppressSceneMutations = <T>(fn: () => T): T => suppressSceneMutationTracking(fn);

registerTimeline(compId, tl, config.timeline);
return tl;
}
Expand Down
1 change: 1 addition & 0 deletions packages/studio/src/hooks/useDomEditSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ export function useDomEditSession({
} = useGsapScriptCommits({
projectIdRef,
activeCompPath,
previewIframeRef,
editHistory,
domEditSaveTimestampRef,
reloadPreview,
Expand Down
12 changes: 11 additions & 1 deletion packages/studio/src/hooks/useGsapScriptCommits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from "react";
import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { EditHistoryKind } from "../utils/editHistory";
import { applySoftReload } from "../utils/gsapSoftReload";

const PROPERTY_DEFAULTS: Record<string, number> = {
opacity: 1,
Expand Down Expand Up @@ -45,6 +46,7 @@ interface MutationResult {
parsed?: ParsedGsap;
before?: string;
after?: string;
scriptText?: string;
}

async function mutateGsapScript(
Expand All @@ -71,6 +73,7 @@ async function mutateGsapScript(
interface GsapScriptCommitsParams {
projectIdRef: React.MutableRefObject<string | null>;
activeCompPath: string | null;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
editHistory: {
recordEdit: (entry: {
label: string;
Expand All @@ -90,6 +93,7 @@ const DEBOUNCE_MS = 150;
export function useGsapScriptCommits({
projectIdRef,
activeCompPath,
previewIframeRef,
editHistory,
domEditSaveTimestampRef,
reloadPreview,
Expand Down Expand Up @@ -131,13 +135,18 @@ export function useGsapScriptCommits({

onCacheInvalidate();

if (!options.softReload) {
if (options.softReload && result.scriptText) {
if (!applySoftReload(previewIframeRef.current, result.scriptText)) {
reloadPreview();
}
} else {
reloadPreview();
}
},
[
projectIdRef,
activeCompPath,
previewIframeRef,
editHistory,
domEditSaveTimestampRef,
reloadPreview,
Expand All @@ -156,6 +165,7 @@ export function useGsapScriptCommits({
{
label: `Edit GSAP ${property}`,
coalesceKey: `gsap:${animationId}:${property}`,
softReload: true,
},
);
}, [commitMutation]);
Expand Down
86 changes: 86 additions & 0 deletions packages/studio/src/utils/gsapSoftReload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// @vitest-environment happy-dom

import { describe, it, expect, vi } from "vitest";
import { applySoftReload } from "./gsapSoftReload";

const SCRIPT_TEXT = `
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#box", { opacity: 0.8 });
window.__timelines["root"] = tl;
`;

function buildMockIframe(overrides: Record<string, unknown> = {}) {
const scriptEl = document.createElement("script");
scriptEl.textContent =
'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
const container = document.createElement("div");
container.appendChild(scriptEl);

const mockTimeline = { kill: vi.fn(), pause: vi.fn() };
const contentWindow = {
gsap: { timeline: vi.fn() },
__hfForceTimelineRebind: vi.fn(),
__timelines: { root: mockTimeline } as Record<string, typeof mockTimeline>,
__player: { getTime: () => 2.0, seek: vi.fn() },
__hfStudioManualEditsApply: vi.fn(),
__hfSuppressSceneMutations: undefined as undefined | (<T>(fn: () => T) => T),
...overrides,
};

const contentDocument = {
querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
createElement: (tag: string) => document.createElement(tag),
body: container,
};

return {
iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
contentWindow,
mockTimeline,
};
}

describe("applySoftReload", () => {
it("returns false when iframe is null", () => {
expect(applySoftReload(null, SCRIPT_TEXT)).toBe(false);
});

it("returns false when scriptText is empty", () => {
const { iframe } = buildMockIframe();
expect(applySoftReload(iframe, "")).toBe(false);
});

it("returns false when gsap is not on iframe window", () => {
const { iframe } = buildMockIframe({ gsap: undefined });
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
});

it("returns false when __hfForceTimelineRebind is missing", () => {
const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
expect(applySoftReload(iframe, SCRIPT_TEXT)).toBe(false);
});

it("kills existing timelines, rebinds, and re-seeks on success", () => {
const { iframe, contentWindow, mockTimeline } = buildMockIframe();
const result = applySoftReload(iframe, SCRIPT_TEXT);
expect(result).toBe(true);
expect(mockTimeline.kill).toHaveBeenCalled();
expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalled();
expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
});

it("wraps execution in __hfSuppressSceneMutations when available", () => {
let suppressionCalled = false;
const { iframe } = buildMockIframe({
__hfSuppressSceneMutations: <T>(fn: () => T): T => {
suppressionCalled = true;
return fn();
},
});
const result = applySoftReload(iframe, SCRIPT_TEXT);
expect(result).toBe(true);
expect(suppressionCalled).toBe(true);
});
});
69 changes: 69 additions & 0 deletions packages/studio/src/utils/gsapSoftReload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
type IframeWindow = Window & {
__timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
__player?: { getTime?: () => number; seek?: (t: number) => void };
__hfForceTimelineRebind?: () => void;
__hfSuppressSceneMutations?: <T>(fn: () => T) => T;
__hfStudioManualEditsApply?: () => void;
gsap?: { timeline?: (...args: unknown[]) => unknown };
};

function findGsapScriptElement(doc: Document): HTMLScriptElement | null {
const scripts = doc.querySelectorAll<HTMLScriptElement>("script:not([src])");
for (const script of scripts) {
const text = script.textContent || "";
if (
text.includes("gsap.timeline") ||
text.includes("__timelines") ||
text.includes(".to(") ||
text.includes(".set(")
)
return script;
}
return null;
}

export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: string): boolean {
if (!iframe || !scriptText) return false;

const win = iframe.contentWindow as IframeWindow | null;
const doc = iframe.contentDocument;
if (!win || !doc) return false;
if (!win.gsap || !win.__hfForceTimelineRebind) return false;

const oldScriptEl = findGsapScriptElement(doc);
if (!oldScriptEl) return false;

const currentTime = win.__player?.getTime?.() ?? 0;

const doReload = () => {
const timelines = win.__timelines;
if (timelines) {
for (const key of Object.keys(timelines)) {
try {
timelines[key]?.kill?.();
} catch {}
delete timelines[key];
}
}

oldScriptEl.remove();
const newScript = doc.createElement("script");
newScript.textContent = `(function(){${scriptText}\n})();`;
doc.body.appendChild(newScript);

win.__hfForceTimelineRebind?.();
win.__player?.seek?.(currentTime);
win.__hfStudioManualEditsApply?.();
};

try {
if (win.__hfSuppressSceneMutations) {
win.__hfSuppressSceneMutations(doReload);
} else {
doReload();
}
return true;
} catch {
return false;
}
}
Loading