@hyperframes/studio 0.6.110 → 0.6.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/App.tsx +133 -141
- package/src/components/StudioHeader.tsx +44 -0
- package/src/components/StudioOverlays.tsx +70 -0
- package/src/components/editor/SourceEditor.tsx +19 -16
- package/src/components/editor/manualEditingAvailability.ts +25 -7
- package/src/components/storyboard/FramePoster.tsx +56 -0
- package/src/components/storyboard/StoryboardDirection.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
- package/src/components/storyboard/StoryboardGrid.tsx +33 -0
- package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
- package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
- package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
- package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
- package/src/components/storyboard/StoryboardView.tsx +78 -0
- package/src/components/storyboard/frameStatus.ts +36 -0
- package/src/contexts/ViewModeContext.tsx +98 -0
- package/src/hooks/gsapScriptCommitTypes.ts +4 -7
- package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
- package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +20 -0
- package/src/hooks/useDomEditCommits.ts +43 -14
- package/src/hooks/useDomEditSession.test.ts +41 -0
- package/src/hooks/useDomEditSession.ts +38 -6
- package/src/hooks/useDomGeometryCommits.ts +5 -9
- package/src/hooks/useElementLifecycleOps.ts +30 -0
- package/src/hooks/useGsapAnimationOps.ts +78 -51
- package/src/hooks/useGsapKeyframeOps.ts +127 -43
- package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
- package/src/hooks/useGsapPropertyDebounce.ts +201 -23
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
- package/src/hooks/useGsapScriptCommits.ts +95 -40
- package/src/hooks/useSdkSession.test.ts +12 -0
- package/src/hooks/useSdkSession.ts +91 -25
- package/src/hooks/useStoryboard.ts +80 -0
- package/src/hooks/useTimelineEditing.ts +163 -68
- package/src/utils/gsapSoftReload.ts +14 -0
- package/src/utils/sdkCutover.gate.test.ts +61 -0
- package/src/utils/sdkCutover.test.ts +839 -0
- package/src/utils/sdkCutover.ts +465 -0
- package/src/utils/sdkOpMapping.ts +43 -0
- package/src/utils/sdkResolverShadow.test.ts +366 -0
- package/src/utils/sdkResolverShadow.ts +313 -0
- package/dist/assets/index-1C8oFiPi.css +0 -1
- package/dist/assets/index-D-3sGz65.js +0 -296
- package/src/utils/sdkShadow.test.ts +0 -606
- package/src/utils/sdkShadow.ts +0 -517
- package/src/utils/sdkShadowGsapFidelity.ts +0 -296
- package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
- package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
- package/src/utils/sdkShadowNumeric.ts +0 -11
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Top-level Studio view mode.
|
|
13
|
+
*
|
|
14
|
+
* `timeline` is the existing NLE/preview stage. `storyboard` replaces that stage
|
|
15
|
+
* with the storyboard contact sheet. The mode is mirrored to the `?view=` query
|
|
16
|
+
* param so it survives reloads and — importantly — so an agent can deep-link the
|
|
17
|
+
* user straight into the storyboard by navigating the tab to `?view=storyboard`.
|
|
18
|
+
*/
|
|
19
|
+
export type StudioViewMode = "timeline" | "storyboard";
|
|
20
|
+
|
|
21
|
+
const VIEW_QUERY_PARAM = "view";
|
|
22
|
+
|
|
23
|
+
function readViewModeFromUrl(): StudioViewMode {
|
|
24
|
+
if (typeof window === "undefined") return "timeline";
|
|
25
|
+
return new URLSearchParams(window.location.search).get(VIEW_QUERY_PARAM) === "storyboard"
|
|
26
|
+
? "storyboard"
|
|
27
|
+
: "timeline";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeViewModeToUrl(mode: StudioViewMode): void {
|
|
31
|
+
if (typeof window === "undefined") return;
|
|
32
|
+
const url = new URL(window.location.href);
|
|
33
|
+
if (mode === "storyboard") {
|
|
34
|
+
url.searchParams.set(VIEW_QUERY_PARAM, "storyboard");
|
|
35
|
+
} else {
|
|
36
|
+
url.searchParams.delete(VIEW_QUERY_PARAM);
|
|
37
|
+
}
|
|
38
|
+
window.history.replaceState(window.history.state, "", url);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ViewModeValue {
|
|
42
|
+
viewMode: StudioViewMode;
|
|
43
|
+
setViewMode: (mode: StudioViewMode) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Owns the view-mode state. When `enabled` is false (storyboard flag off) the
|
|
48
|
+
* mode is pinned to `timeline` and the URL is left untouched, so the feature is
|
|
49
|
+
* fully inert until the flag is on.
|
|
50
|
+
*/
|
|
51
|
+
export function useViewModeState(enabled: boolean): ViewModeValue {
|
|
52
|
+
const [viewMode, setMode] = useState<StudioViewMode>(() =>
|
|
53
|
+
enabled ? readViewModeFromUrl() : "timeline",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// Reflect genuine browser back/forward between history entries with a different
|
|
57
|
+
// `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
|
|
58
|
+
// `popstate`, so this listener never sees them — `setViewMode` updates state directly.
|
|
59
|
+
// An agent deep-links by doing a full navigation to `?view=storyboard` (picked up by
|
|
60
|
+
// the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
|
|
61
|
+
// reflected here, by design.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!enabled) return;
|
|
64
|
+
const onPopState = () => setMode(readViewModeFromUrl());
|
|
65
|
+
window.addEventListener("popstate", onPopState);
|
|
66
|
+
return () => window.removeEventListener("popstate", onPopState);
|
|
67
|
+
}, [enabled]);
|
|
68
|
+
|
|
69
|
+
const setViewMode = useCallback(
|
|
70
|
+
(mode: StudioViewMode) => {
|
|
71
|
+
if (!enabled) return;
|
|
72
|
+
setMode(mode);
|
|
73
|
+
writeViewModeToUrl(mode);
|
|
74
|
+
},
|
|
75
|
+
[enabled],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const effectiveMode = enabled ? viewMode : "timeline";
|
|
79
|
+
return useMemo(() => ({ viewMode: effectiveMode, setViewMode }), [effectiveMode, setViewMode]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ViewModeContext = createContext<ViewModeValue | null>(null);
|
|
83
|
+
|
|
84
|
+
export function useViewMode(): ViewModeValue {
|
|
85
|
+
const ctx = useContext(ViewModeContext);
|
|
86
|
+
if (!ctx) throw new Error("useViewMode must be used within ViewModeProvider");
|
|
87
|
+
return ctx;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function ViewModeProvider({
|
|
91
|
+
value,
|
|
92
|
+
children,
|
|
93
|
+
}: {
|
|
94
|
+
value: ViewModeValue;
|
|
95
|
+
children: ReactNode;
|
|
96
|
+
}) {
|
|
97
|
+
return <ViewModeContext value={value}>{children}</ViewModeContext>;
|
|
98
|
+
}
|
|
@@ -2,8 +2,6 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
|
2
2
|
import type { Composition } from "@hyperframes/sdk";
|
|
3
3
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
|
-
import type { ShadowGsapOp } from "../utils/sdkShadow";
|
|
6
|
-
import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
|
|
7
5
|
|
|
8
6
|
export interface MutationResult {
|
|
9
7
|
ok: boolean;
|
|
@@ -28,10 +26,6 @@ export interface CommitMutationOptions {
|
|
|
28
26
|
* (and under distinct keys) run concurrently as before.
|
|
29
27
|
*/
|
|
30
28
|
serializeKey?: string;
|
|
31
|
-
/** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
|
|
32
|
-
shadowGsapOp?: ShadowGsapOp;
|
|
33
|
-
/** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
|
|
34
|
-
shadowKeyframeOp?: ShadowKeyframeOp;
|
|
35
29
|
}
|
|
36
30
|
|
|
37
31
|
export type CommitMutation = (
|
|
@@ -70,6 +64,9 @@ export interface GsapScriptCommitsParams {
|
|
|
70
64
|
onCacheInvalidate: () => void;
|
|
71
65
|
onFileContentChanged?: (path: string, content: string) => void;
|
|
72
66
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
73
|
-
/** Stage 7
|
|
67
|
+
/** Stage 7 §3.5: SDK session for routing GSAP tween ops through addGsapTween/setGsapTween/removeGsapTween. */
|
|
74
68
|
sdkSession?: Composition | null;
|
|
69
|
+
writeProjectFile?: (path: string, content: string) => Promise<void>;
|
|
70
|
+
/** Resync the in-memory SDK session after a server-authoritative write. */
|
|
71
|
+
forceReloadSdkSession?: () => void;
|
|
75
72
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
hashContent,
|
|
4
|
+
markSelfWrite,
|
|
5
|
+
isSelfWriteEcho,
|
|
6
|
+
resetSelfWriteRegistry,
|
|
7
|
+
} from "./sdkSelfWriteRegistry";
|
|
8
|
+
import { shouldReloadOnFileChange } from "./useSdkSession";
|
|
9
|
+
|
|
10
|
+
describe("sdkSelfWriteRegistry (finding #14)", () => {
|
|
11
|
+
beforeEach(() => resetSelfWriteRegistry());
|
|
12
|
+
|
|
13
|
+
it("recognizes the echo of bytes we just wrote", () => {
|
|
14
|
+
markSelfWrite("/comp.html", "<html>A</html>");
|
|
15
|
+
expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("does NOT match different content on the same path (an undo's reverted bytes)", () => {
|
|
19
|
+
markSelfWrite("/comp.html", "<html>A</html>");
|
|
20
|
+
expect(isSelfWriteEcho("/comp.html", "<html>REVERTED</html>")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("is keyed per file — a self-write to one file can't mask a change to another", () => {
|
|
24
|
+
markSelfWrite("/a.html", "<html>A</html>");
|
|
25
|
+
expect(isSelfWriteEcho("/b.html", "<html>A</html>")).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("consumes a matched entry so a later genuine external write isn't suppressed", () => {
|
|
29
|
+
markSelfWrite("/comp.html", "<html>A</html>");
|
|
30
|
+
expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(true);
|
|
31
|
+
// A second arrival of identical bytes is NOT our echo — must reload.
|
|
32
|
+
expect(isSelfWriteEcho("/comp.html", "<html>A</html>")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("expires entries past the TTL so a stale self-write can't suppress forever", () => {
|
|
36
|
+
const t0 = 1_000_000;
|
|
37
|
+
markSelfWrite("/comp.html", "<html>A</html>", t0);
|
|
38
|
+
// 3 s later (> 2 s TTL) the entry is gone.
|
|
39
|
+
expect(isSelfWriteEcho("/comp.html", "<html>A</html>", t0 + 3000)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("hashes are stable and distinguish different content", () => {
|
|
43
|
+
expect(hashContent("x")).toBe(hashContent("x"));
|
|
44
|
+
expect(hashContent("x")).not.toBe(hashContent("y"));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("shouldReloadOnFileChange (finding #14)", () => {
|
|
49
|
+
beforeEach(() => resetSelfWriteRegistry());
|
|
50
|
+
|
|
51
|
+
it("suppresses the reload when content matches a registered self-write (cutover echo)", () => {
|
|
52
|
+
markSelfWrite("/comp.html", "<html>SELF</html>");
|
|
53
|
+
expect(shouldReloadOnFileChange("/comp.html", "<html>SELF</html>", true)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("reloads on an undo write even inside the suppress window (content differs)", () => {
|
|
57
|
+
// The cutover registered SELF; the undo writes REVERTED bytes within the
|
|
58
|
+
// same 2 s window. Time-only suppression dropped this; identity reloads it.
|
|
59
|
+
markSelfWrite("/comp.html", "<html>SELF</html>");
|
|
60
|
+
expect(shouldReloadOnFileChange("/comp.html", "<html>REVERTED</html>", true)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("falls back to the time window only when content is unavailable", () => {
|
|
64
|
+
expect(shouldReloadOnFileChange("/comp.html", null, true)).toBe(false);
|
|
65
|
+
expect(shouldReloadOnFileChange("/comp.html", null, false)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-write identity registry — discriminates an SDK cutover ECHO from a genuine
|
|
3
|
+
* external write (notably undo/redo) in the file-change reload-suppression path.
|
|
4
|
+
*
|
|
5
|
+
* The old suppression was purely time-based: any file-change within 2 s of the
|
|
6
|
+
* shared `domEditSaveTimestampRef` was swallowed. But BOTH an SDK cutover
|
|
7
|
+
* self-write AND an undo write set that same timestamp, so the window could not
|
|
8
|
+
* tell "the echo of the bytes I just wrote" (suppress) from "the reverted bytes
|
|
9
|
+
* an undo just wrote" (must reload). An undo that landed inside the window was
|
|
10
|
+
* silently dropped, leaving the in-memory SDK doc on stale pre-undo content.
|
|
11
|
+
*
|
|
12
|
+
* Fix: tag each cutover self-write with the CONTENT it wrote (by hash). A
|
|
13
|
+
* file-change reload is suppressed only when the new on-disk content matches a
|
|
14
|
+
* recently-registered self-write hash — i.e. it is provably our own echo. Undo
|
|
15
|
+
* writes are never registered (they don't flow through persistSdkSerialize), so
|
|
16
|
+
* their content won't match and the reload always fires. Identity, not a clock.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SELF_WRITE_TTL_MS = 2000;
|
|
20
|
+
|
|
21
|
+
interface SelfWriteEntry {
|
|
22
|
+
hash: string;
|
|
23
|
+
at: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Module-scoped: the studio process has a single SDK session lifecycle at a time
|
|
27
|
+
// and persists are funnelled through one persistSdkSerialize. Keyed by file path
|
|
28
|
+
// so a self-write to one file can't mask a real external change to another.
|
|
29
|
+
const registry = new Map<string, SelfWriteEntry[]>();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Stable 32-bit FNV-1a hash of content. Collisions only risk SUPPRESSING a real
|
|
33
|
+
* reload, and only within the 2 s TTL for the exact same file — negligible, and
|
|
34
|
+
* strictly safer than the prior time-only window it replaces.
|
|
35
|
+
*/
|
|
36
|
+
export function hashContent(content: string): string {
|
|
37
|
+
let h = 0x811c9dc5;
|
|
38
|
+
for (let i = 0; i < content.length; i++) {
|
|
39
|
+
h ^= content.charCodeAt(i);
|
|
40
|
+
h = Math.imul(h, 0x01000193);
|
|
41
|
+
}
|
|
42
|
+
return (h >>> 0).toString(16);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function prune(entries: SelfWriteEntry[], now: number): SelfWriteEntry[] {
|
|
46
|
+
return entries.filter((e) => now - e.at < SELF_WRITE_TTL_MS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Record that WE wrote `content` to `path` (an SDK cutover self-write). */
|
|
50
|
+
export function markSelfWrite(path: string, content: string, now: number = Date.now()): void {
|
|
51
|
+
const next = prune(registry.get(path) ?? [], now);
|
|
52
|
+
next.push({ hash: hashContent(content), at: now });
|
|
53
|
+
registry.set(path, next);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* True when `content` matches a self-write registered for `path` within the TTL.
|
|
58
|
+
* Consumes the matched entry so a later genuinely-external write of identical
|
|
59
|
+
* bytes isn't suppressed forever.
|
|
60
|
+
*/
|
|
61
|
+
export function isSelfWriteEcho(path: string, content: string, now: number = Date.now()): boolean {
|
|
62
|
+
const entries = prune(registry.get(path) ?? [], now);
|
|
63
|
+
const hash = hashContent(content);
|
|
64
|
+
const idx = entries.findIndex((e) => e.hash === hash);
|
|
65
|
+
if (idx === -1) {
|
|
66
|
+
registry.set(path, entries);
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
entries.splice(idx, 1);
|
|
70
|
+
registry.set(path, entries);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Test-only: drop all registered self-writes. */
|
|
75
|
+
export function resetSelfWriteRegistry(): void {
|
|
76
|
+
registry.clear();
|
|
77
|
+
}
|
|
@@ -97,6 +97,7 @@ export interface PersistTimelineEditInput {
|
|
|
97
97
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
98
98
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
99
99
|
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
100
|
+
coalesceKey?: string;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
|
|
@@ -119,6 +120,7 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
|
|
|
119
120
|
projectId: input.projectId,
|
|
120
121
|
label: input.label,
|
|
121
122
|
kind: "timeline",
|
|
123
|
+
coalesceKey: input.coalesceKey,
|
|
122
124
|
files: { [targetPath]: patchedContent },
|
|
123
125
|
readFile: async () => originalContent,
|
|
124
126
|
writeFile: input.writeProjectFile,
|
|
@@ -117,6 +117,14 @@ interface UseAppHotkeysParams {
|
|
|
117
117
|
onDeleteSelectedKeyframes: () => void;
|
|
118
118
|
onAfterUndoRedo?: () => void;
|
|
119
119
|
onToggleRecording?: () => void;
|
|
120
|
+
/** Active composition path — used to decide whether undo/redo must resync the SDK session. */
|
|
121
|
+
activeCompPath?: string | null;
|
|
122
|
+
/**
|
|
123
|
+
* Force-reload the SDK session after undo/redo reverts the active comp file,
|
|
124
|
+
* bypassing the self-write suppress window. Without this, the suppress window
|
|
125
|
+
* blocks the file-change reload and the SDK session stays on pre-undo content.
|
|
126
|
+
*/
|
|
127
|
+
forceReloadSdkSession?: () => void;
|
|
120
128
|
}
|
|
121
129
|
|
|
122
130
|
// ── Extracted keydown dispatch (pure function, no hooks) ──
|
|
@@ -302,6 +310,8 @@ export function useAppHotkeys({
|
|
|
302
310
|
onDeleteSelectedKeyframes,
|
|
303
311
|
onAfterUndoRedo,
|
|
304
312
|
onToggleRecording,
|
|
313
|
+
activeCompPath,
|
|
314
|
+
forceReloadSdkSession,
|
|
305
315
|
}: UseAppHotkeysParams) {
|
|
306
316
|
const previewHotkeyWindowRef = useRef<Window | null>(null);
|
|
307
317
|
const previewHistoryCleanupRef = useRef<(() => void) | null>(null);
|
|
@@ -349,6 +359,14 @@ export function useAppHotkeys({
|
|
|
349
359
|
}
|
|
350
360
|
if (result.ok && result.label) {
|
|
351
361
|
onAfterUndoRedo?.();
|
|
362
|
+
// If the active composition was among the written files, force-reload
|
|
363
|
+
// the SDK session so its in-memory doc matches the reverted content.
|
|
364
|
+
// writeHistoryFile sets domEditSaveTimestampRef which activates the
|
|
365
|
+
// 2 s suppress window — without this call the file-change event would
|
|
366
|
+
// be swallowed and the SDK session would stay on stale pre-undo content.
|
|
367
|
+
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
368
|
+
forceReloadSdkSession?.();
|
|
369
|
+
}
|
|
352
370
|
await syncHistoryPreviewAfterApply(result.paths);
|
|
353
371
|
showToast(`${direction === "undo" ? "Undid" : "Redid"} ${result.label}`, "info");
|
|
354
372
|
}
|
|
@@ -361,6 +379,8 @@ export function useAppHotkeys({
|
|
|
361
379
|
waitForPendingDomEditSaves,
|
|
362
380
|
writeHistoryFile,
|
|
363
381
|
onAfterUndoRedo,
|
|
382
|
+
activeCompPath,
|
|
383
|
+
forceReloadSdkSession,
|
|
364
384
|
],
|
|
365
385
|
);
|
|
366
386
|
|
|
@@ -16,9 +16,6 @@ import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
|
16
16
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
17
17
|
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
|
18
18
|
|
|
19
|
-
// Re-export so existing consumers keep their import path
|
|
20
|
-
export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
|
|
21
|
-
|
|
22
19
|
// ── Helpers ──
|
|
23
20
|
|
|
24
21
|
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
|
@@ -45,8 +42,6 @@ interface RecordEditInput {
|
|
|
45
42
|
files: Record<string, { before: string; after: string }>;
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
export type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
49
|
-
|
|
50
45
|
export interface UseDomEditCommitsParams {
|
|
51
46
|
activeCompPath: string | null;
|
|
52
47
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
@@ -73,10 +68,20 @@ export interface UseDomEditCommitsParams {
|
|
|
73
68
|
target: HTMLElement,
|
|
74
69
|
options?: { preferClipAncestor?: boolean },
|
|
75
70
|
) => Promise<DomEditSelection | null>;
|
|
76
|
-
/**
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
71
|
+
/** Resync the in-memory SDK session after a SERVER-side write (NOT the SDK
|
|
72
|
+
* path, whose session is already current) so a later SDK edit doesn't
|
|
73
|
+
* serialize the pre-write doc and revert the server's change. */
|
|
74
|
+
forceReloadSdkSession?: () => void;
|
|
75
|
+
/** Stage 7 Step 3c: called before the server-side patch path; returns true if SDK handled it. */
|
|
76
|
+
onTrySdkPersist?: (
|
|
77
|
+
selection: DomEditSelection,
|
|
78
|
+
operations: PatchOperation[],
|
|
79
|
+
originalContent: string,
|
|
80
|
+
targetPath: string,
|
|
81
|
+
options?: { label?: string; coalesceKey?: string; skipRefresh?: boolean },
|
|
82
|
+
) => Promise<boolean>;
|
|
83
|
+
/** Stage 7 §3.1: called before the server-side delete path; returns true if SDK handled it. */
|
|
84
|
+
onTrySdkDelete?: (hfId: string, originalContent: string, targetPath: string) => Promise<boolean>;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
export function useDomEditCommits({
|
|
@@ -97,8 +102,9 @@ export function useDomEditCommits({
|
|
|
97
102
|
clearDomSelection,
|
|
98
103
|
refreshDomEditSelectionFromPreview,
|
|
99
104
|
buildDomSelectionFromTarget,
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
forceReloadSdkSession,
|
|
106
|
+
onTrySdkPersist,
|
|
107
|
+
onTrySdkDelete,
|
|
102
108
|
}: UseDomEditCommitsParams) {
|
|
103
109
|
const resolveImportedFontAsset = useCallback(
|
|
104
110
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
@@ -149,6 +155,10 @@ export function useDomEditCommits({
|
|
|
149
155
|
|
|
150
156
|
if (options?.shouldSave && !options.shouldSave()) return;
|
|
151
157
|
|
|
158
|
+
// Validate layout values BEFORE any persist path runs. The SDK cutover
|
|
159
|
+
// path (onTrySdkPersist) returns early on success, so leaving this check
|
|
160
|
+
// after it let invalid numeric values bypass the guard whenever the
|
|
161
|
+
// cutover flag was on.
|
|
152
162
|
const patchTarget = buildDomEditPatchTarget(selection);
|
|
153
163
|
const patchBody = { target: patchTarget, operations };
|
|
154
164
|
const unsafeFields = findUnsafeDomPatchValues(patchBody);
|
|
@@ -158,6 +168,23 @@ export function useDomEditCommits({
|
|
|
158
168
|
throw new Error(`DOM patch contains unsafe values: ${fields}`);
|
|
159
169
|
}
|
|
160
170
|
|
|
171
|
+
// Skip the SDK path when prepareContent is set (e.g. @font-face injection
|
|
172
|
+
// for a custom font): sdkCutoverPersist serializes only the patched DOM
|
|
173
|
+
// and would drop the injected content. Let the server path run prepareContent.
|
|
174
|
+
if (
|
|
175
|
+
onTrySdkPersist &&
|
|
176
|
+
!options?.prepareContent &&
|
|
177
|
+
(await onTrySdkPersist(selection, operations, originalContent, targetPath, {
|
|
178
|
+
label: options?.label,
|
|
179
|
+
coalesceKey: options?.coalesceKey,
|
|
180
|
+
skipRefresh: options?.skipRefresh,
|
|
181
|
+
}))
|
|
182
|
+
) {
|
|
183
|
+
// SDK handled it — its in-memory doc is already current, so do NOT
|
|
184
|
+
// forceReload (that would echo-reload the session we just wrote).
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
161
188
|
// Mark the save timestamp before the file write so the SSE file-change
|
|
162
189
|
// handler suppresses the reload even if the event arrives before the
|
|
163
190
|
// response (the server writes the file and emits SSE during the fetch).
|
|
@@ -220,7 +247,7 @@ export function useDomEditCommits({
|
|
|
220
247
|
coalesceKey: options?.coalesceKey,
|
|
221
248
|
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
222
249
|
});
|
|
223
|
-
|
|
250
|
+
forceReloadSdkSession?.();
|
|
224
251
|
|
|
225
252
|
if (!options?.skipRefresh) {
|
|
226
253
|
reloadPreview();
|
|
@@ -234,7 +261,8 @@ export function useDomEditCommits({
|
|
|
234
261
|
domEditSaveTimestampRef,
|
|
235
262
|
reloadPreview,
|
|
236
263
|
showToast,
|
|
237
|
-
|
|
264
|
+
forceReloadSdkSession,
|
|
265
|
+
onTrySdkPersist,
|
|
238
266
|
],
|
|
239
267
|
);
|
|
240
268
|
|
|
@@ -295,8 +323,9 @@ export function useDomEditCommits({
|
|
|
295
323
|
projectIdRef,
|
|
296
324
|
reloadPreview,
|
|
297
325
|
clearDomSelection,
|
|
326
|
+
onTrySdkDelete,
|
|
327
|
+
forceReloadSdkSession,
|
|
298
328
|
commitPositionPatchToHtml,
|
|
299
|
-
onElementDeleted,
|
|
300
329
|
});
|
|
301
330
|
|
|
302
331
|
return {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldUseSdkCutover } from "../utils/sdkCutover";
|
|
3
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
4
|
+
|
|
5
|
+
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
6
|
+
type: "inline-style",
|
|
7
|
+
property,
|
|
8
|
+
value,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const attrOp = (property: string, value: string): PatchOperation => ({
|
|
12
|
+
type: "attribute",
|
|
13
|
+
property,
|
|
14
|
+
value,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("shouldUseSdkCutover", () => {
|
|
18
|
+
it("returns false when flag is disabled", () => {
|
|
19
|
+
expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns false when no SDK session", () => {
|
|
23
|
+
expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns false when selection has no hfId", () => {
|
|
27
|
+
expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false);
|
|
28
|
+
expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns false when ops array is empty", () => {
|
|
32
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns true when all conditions met with supported op types", () => {
|
|
36
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true);
|
|
37
|
+
expect(
|
|
38
|
+
shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red"), attrOp("data-x", "1")]),
|
|
39
|
+
).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
import type { Composition } from "@hyperframes/sdk";
|
|
2
1
|
import type { TimelineElement } from "../player";
|
|
3
2
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
4
3
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
5
4
|
import type { RightPanelTab } from "../utils/studioHelpers";
|
|
6
5
|
import type { PatchTarget } from "../utils/sourcePatcher";
|
|
7
6
|
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
|
7
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
8
|
+
import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover";
|
|
9
|
+
import { runResolverShadow } from "../utils/sdkResolverShadow";
|
|
8
10
|
import { useAskAgentModal } from "./useAskAgentModal";
|
|
9
11
|
import { useDomSelection } from "./useDomSelection";
|
|
10
12
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
|
11
13
|
import { useDomEditCommits } from "./useDomEditCommits";
|
|
12
|
-
import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow";
|
|
13
14
|
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
14
15
|
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
15
16
|
import { useDomEditWiring } from "./useDomEditWiring";
|
|
@@ -60,8 +61,8 @@ export interface UseDomEditSessionParams {
|
|
|
60
61
|
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
|
|
61
62
|
selectSidebarTab?: (tab: SidebarTab) => void;
|
|
62
63
|
getSidebarTab?: () => SidebarTab;
|
|
63
|
-
/** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
|
|
64
64
|
sdkSession?: Composition | null;
|
|
65
|
+
forceReloadSdkSession?: () => void;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
// ── Hook ──
|
|
@@ -101,6 +102,7 @@ export function useDomEditSession({
|
|
|
101
102
|
selectSidebarTab,
|
|
102
103
|
getSidebarTab,
|
|
103
104
|
sdkSession,
|
|
105
|
+
forceReloadSdkSession,
|
|
104
106
|
}: UseDomEditSessionParams) {
|
|
105
107
|
void _setRefreshKey;
|
|
106
108
|
void _readProjectFile;
|
|
@@ -195,6 +197,8 @@ export function useDomEditSession({
|
|
|
195
197
|
onFileContentChanged: updateEditingFileContent,
|
|
196
198
|
showToast,
|
|
197
199
|
sdkSession,
|
|
200
|
+
writeProjectFile,
|
|
201
|
+
forceReloadSdkSession,
|
|
198
202
|
});
|
|
199
203
|
|
|
200
204
|
// ── DOM commit handlers ──
|
|
@@ -234,10 +238,38 @@ export function useDomEditSession({
|
|
|
234
238
|
clearDomSelection,
|
|
235
239
|
refreshDomEditSelectionFromPreview,
|
|
236
240
|
buildDomSelectionFromTarget,
|
|
237
|
-
|
|
238
|
-
|
|
241
|
+
forceReloadSdkSession,
|
|
242
|
+
onTrySdkPersist: sdkSession
|
|
243
|
+
? (selection, operations, originalContent, targetPath, options) => {
|
|
244
|
+
// Resolver shadow runs regardless of the cutover flag — decoupled tripwire.
|
|
245
|
+
runResolverShadow(sdkSession, selection.hfId, operations);
|
|
246
|
+
return sdkCutoverPersist(
|
|
247
|
+
selection,
|
|
248
|
+
operations,
|
|
249
|
+
originalContent,
|
|
250
|
+
targetPath,
|
|
251
|
+
sdkSession,
|
|
252
|
+
{
|
|
253
|
+
editHistory,
|
|
254
|
+
writeProjectFile,
|
|
255
|
+
reloadPreview,
|
|
256
|
+
domEditSaveTimestampRef,
|
|
257
|
+
compositionPath: activeCompPath,
|
|
258
|
+
},
|
|
259
|
+
options,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
: undefined,
|
|
263
|
+
onTrySdkDelete: sdkSession
|
|
264
|
+
? (hfId, originalContent, targetPath) =>
|
|
265
|
+
sdkDeletePersist(hfId, originalContent, targetPath, sdkSession, {
|
|
266
|
+
editHistory,
|
|
267
|
+
writeProjectFile,
|
|
268
|
+
reloadPreview,
|
|
269
|
+
domEditSaveTimestampRef,
|
|
270
|
+
compositionPath: activeCompPath,
|
|
271
|
+
})
|
|
239
272
|
: undefined,
|
|
240
|
-
onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined,
|
|
241
273
|
});
|
|
242
274
|
|
|
243
275
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
|
@@ -42,15 +42,11 @@ export function useDomGeometryCommits({
|
|
|
42
42
|
}: UseDomGeometryCommitsParams) {
|
|
43
43
|
const handleDomPathOffsetCommit = useCallback(
|
|
44
44
|
(selection: DomEditSelection, next: { x: number; y: number }) => {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
gsapBlocked,
|
|
51
|
-
}),
|
|
52
|
-
);
|
|
53
|
-
if (gsapBlocked) {
|
|
45
|
+
// ponytail: GSAP-targeted elements are blocked (no SDK position-in-script op); CSS-path
|
|
46
|
+
// elements fall through to commitPositionPatchToHtml → persistDomEditOperations →
|
|
47
|
+
// onTrySdkPersist and are already SDK-cut-over as setStyle/setAttribute (§3.3 done).
|
|
48
|
+
// Upgrade path for GSAP: add a moveElementGsap SDK op in a separate SDK PR.
|
|
49
|
+
if (isElementGsapTargeted(previewIframeRef.current, selection.element)) {
|
|
54
50
|
const error = new Error(GSAP_CSS_FALLBACK_BLOCKED_MESSAGE);
|
|
55
51
|
showToast(error.message, "error");
|
|
56
52
|
return Promise.reject(error);
|