@hyperframes/studio 0.6.98 → 0.6.100
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-BkT9VKwz.js +296 -0
- package/dist/assets/{index-D-bS9Dxx.js → index-CKWBqyRd.js} +1 -1
- package/dist/assets/{index-D-ET9M0b.js → index-gpSohHUn.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +5 -4
- package/src/App.tsx +12 -13
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/renders/useRenderQueue.ts +12 -8
- package/src/hooks/useDomEditCommits.ts +6 -9
- package/src/hooks/useDomEditSession.ts +8 -0
- package/src/hooks/useSdkSelectionSync.ts +25 -0
- package/src/hooks/useSdkSession.test.ts +20 -0
- package/src/hooks/useSdkSession.ts +98 -0
- package/src/utils/sdkShadow.test.ts +146 -0
- package/src/utils/sdkShadow.ts +206 -0
- package/dist/assets/index-Ce3pBm_I.js +0 -252
|
@@ -1 +1 @@
|
|
|
1
|
-
import{g as P}from"./index-
|
|
1
|
+
import{g as P}from"./index-BkT9VKwz.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
|
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-BkT9VKwz.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-B62bDCQv.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.100",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
"@phosphor-icons/react": "^2.1.10",
|
|
34
34
|
"bpm-detective": "^2.0.5",
|
|
35
35
|
"mediabunny": "^1.45.3",
|
|
36
|
-
"@hyperframes/core": "0.6.
|
|
37
|
-
"@hyperframes/
|
|
36
|
+
"@hyperframes/core": "0.6.100",
|
|
37
|
+
"@hyperframes/sdk": "0.6.100",
|
|
38
|
+
"@hyperframes/player": "0.6.100"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
41
|
"@types/react": "19",
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"vite": "^6.4.2",
|
|
49
50
|
"vitest": "^3.2.4",
|
|
50
51
|
"zustand": "^5.0.0",
|
|
51
|
-
"@hyperframes/producer": "0.6.
|
|
52
|
+
"@hyperframes/producer": "0.6.100"
|
|
52
53
|
},
|
|
53
54
|
"peerDependencies": {
|
|
54
55
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -13,6 +13,8 @@ import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
|
|
|
13
13
|
import { useTimelineEditing } from "./hooks/useTimelineEditing";
|
|
14
14
|
import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
|
|
15
15
|
import { useDomEditSession } from "./hooks/useDomEditSession";
|
|
16
|
+
import { useSdkSession } from "./hooks/useSdkSession";
|
|
17
|
+
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
|
|
16
18
|
import { useBlockHandlers } from "./hooks/useBlockHandlers";
|
|
17
19
|
import { useAppHotkeys } from "./hooks/useAppHotkeys";
|
|
18
20
|
import { useClipboard } from "./hooks/useClipboard";
|
|
@@ -265,6 +267,7 @@ export function StudioApp() {
|
|
|
265
267
|
() => leftSidebarRef.current?.getTab() ?? "compositions",
|
|
266
268
|
[],
|
|
267
269
|
);
|
|
270
|
+
const sdkSession = useSdkSession(projectId, activeCompPath);
|
|
268
271
|
const domEditSession = useDomEditSession({
|
|
269
272
|
projectId,
|
|
270
273
|
activeCompPath,
|
|
@@ -299,6 +302,7 @@ export function StudioApp() {
|
|
|
299
302
|
openSourceForSelection: fileManager.openSourceForSelection,
|
|
300
303
|
selectSidebarTab: selectSidebarTabStable,
|
|
301
304
|
getSidebarTab: getSidebarTabStable,
|
|
305
|
+
sdkSession,
|
|
302
306
|
});
|
|
303
307
|
domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
|
|
304
308
|
clearDomSelectionRef.current = domEditSession.clearDomSelection;
|
|
@@ -314,6 +318,12 @@ export function StudioApp() {
|
|
|
314
318
|
domEditSession.handleGsapRemoveKeyframe(a.id, p);
|
|
315
319
|
}
|
|
316
320
|
};
|
|
321
|
+
useSdkSelectionSync(
|
|
322
|
+
sdkSession,
|
|
323
|
+
domEditSession.domEditSelection,
|
|
324
|
+
domEditSession.domEditGroupSelections,
|
|
325
|
+
);
|
|
326
|
+
|
|
317
327
|
useCaptionDetection({
|
|
318
328
|
projectId,
|
|
319
329
|
activeCompPath,
|
|
@@ -419,17 +429,6 @@ export function StudioApp() {
|
|
|
419
429
|
applyDomSelection: domEditSession.applyDomSelection,
|
|
420
430
|
initialState: initialUrlStateRef.current,
|
|
421
431
|
});
|
|
422
|
-
const { jobs, isRendering, deleteRender, clearCompleted, startRender } = renderQueue;
|
|
423
|
-
const stableRenderQueue = useMemo(
|
|
424
|
-
() => ({
|
|
425
|
-
jobs,
|
|
426
|
-
isRendering,
|
|
427
|
-
deleteRender,
|
|
428
|
-
clearCompleted,
|
|
429
|
-
startRender: startRender as (options: unknown) => Promise<void>,
|
|
430
|
-
}),
|
|
431
|
-
[jobs, isRendering, deleteRender, clearCompleted, startRender],
|
|
432
|
-
);
|
|
433
432
|
const studioCtxValue = buildStudioContextValue({
|
|
434
433
|
projectId: projectId!,
|
|
435
434
|
activeCompPath,
|
|
@@ -445,7 +444,7 @@ export function StudioApp() {
|
|
|
445
444
|
editHistory,
|
|
446
445
|
handleUndo: appHotkeys.handleUndo,
|
|
447
446
|
handleRedo: appHotkeys.handleRedo,
|
|
448
|
-
renderQueue
|
|
447
|
+
renderQueue,
|
|
449
448
|
compositionDimensions,
|
|
450
449
|
waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
|
|
451
450
|
handlePreviewIframeRef,
|
|
@@ -485,7 +484,7 @@ export function StudioApp() {
|
|
|
485
484
|
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
|
|
486
485
|
inspectorButtonActive={inspectorButtonActive}
|
|
487
486
|
inspectorPanelActive={inspectorPanelActive}
|
|
488
|
-
onExport={() => void renderQueue.startRender()}
|
|
487
|
+
onExport={() => void renderQueue.startRender(undefined)}
|
|
489
488
|
/>
|
|
490
489
|
{previewPersistence.domEditSaveQueuePaused && (
|
|
491
490
|
<SaveQueuePausedBanner
|
|
@@ -88,4 +88,13 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
|
|
|
88
88
|
|
|
89
89
|
export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
|
|
90
90
|
|
|
91
|
+
// Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK
|
|
92
|
+
// session alongside the server patch path and logs mismatches via telemetry.
|
|
93
|
+
// Default false in production; enable via VITE_STUDIO_SDK_SHADOW_ENABLED=true.
|
|
94
|
+
export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
|
|
95
|
+
env,
|
|
96
|
+
["VITE_STUDIO_SDK_SHADOW_ENABLED"],
|
|
97
|
+
false,
|
|
98
|
+
);
|
|
99
|
+
|
|
91
100
|
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|
2
2
|
import { trackStudioRenderStart } from "../../telemetry/events";
|
|
3
3
|
|
|
4
4
|
export interface RenderJob {
|
|
@@ -238,11 +238,15 @@ export function useRenderQueue(projectId: string | null) {
|
|
|
238
238
|
};
|
|
239
239
|
}, [projectId]);
|
|
240
240
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
241
|
+
const isRendering = jobs.some((j) => j.status === "rendering");
|
|
242
|
+
return useMemo(
|
|
243
|
+
() => ({
|
|
244
|
+
jobs,
|
|
245
|
+
isRendering,
|
|
246
|
+
deleteRender,
|
|
247
|
+
clearCompleted,
|
|
248
|
+
startRender: startRender as (options: unknown) => Promise<void>,
|
|
249
|
+
}),
|
|
250
|
+
[jobs, isRendering, deleteRender, clearCompleted, startRender],
|
|
251
|
+
);
|
|
248
252
|
}
|
|
@@ -9,14 +9,12 @@ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/ed
|
|
|
9
9
|
import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
|
|
10
10
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
11
11
|
import type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
12
|
+
import type { PatchOperation } from "../utils/sourcePatcher";
|
|
12
13
|
import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
|
|
13
14
|
import { useDomEditTextCommits } from "./useDomEditTextCommits";
|
|
14
15
|
import { useDomGeometryCommits } from "./useDomGeometryCommits";
|
|
15
16
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
16
17
|
|
|
17
|
-
// Re-export so existing consumers keep their import path
|
|
18
|
-
export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
|
|
19
|
-
|
|
20
18
|
// ── Helpers ──
|
|
21
19
|
|
|
22
20
|
function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
|
@@ -40,8 +38,6 @@ function formatPatchRejectionMessage(body: { error?: string; fields?: string[] }
|
|
|
40
38
|
return `Couldn't save edit: ${body.error}${suffix}`;
|
|
41
39
|
}
|
|
42
40
|
|
|
43
|
-
// ── Types ──
|
|
44
|
-
|
|
45
41
|
interface RecordEditInput {
|
|
46
42
|
label: string;
|
|
47
43
|
kind: EditHistoryKind;
|
|
@@ -49,8 +45,6 @@ interface RecordEditInput {
|
|
|
49
45
|
files: Record<string, { before: string; after: string }>;
|
|
50
46
|
}
|
|
51
47
|
|
|
52
|
-
export type { PersistDomEditOperations } from "./domEditCommitTypes";
|
|
53
|
-
|
|
54
48
|
export interface UseDomEditCommitsParams {
|
|
55
49
|
activeCompPath: string | null;
|
|
56
50
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
@@ -77,10 +71,10 @@ export interface UseDomEditCommitsParams {
|
|
|
77
71
|
target: HTMLElement,
|
|
78
72
|
options?: { preferClipAncestor?: boolean },
|
|
79
73
|
) => Promise<DomEditSelection | null>;
|
|
74
|
+
/** Stage 7 Step 3b: called after a successful server-side element patch. */
|
|
75
|
+
onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
|
|
80
76
|
}
|
|
81
77
|
|
|
82
|
-
// ── Hook ──
|
|
83
|
-
|
|
84
78
|
export function useDomEditCommits({
|
|
85
79
|
activeCompPath,
|
|
86
80
|
previewIframeRef,
|
|
@@ -99,6 +93,7 @@ export function useDomEditCommits({
|
|
|
99
93
|
clearDomSelection,
|
|
100
94
|
refreshDomEditSelectionFromPreview,
|
|
101
95
|
buildDomSelectionFromTarget,
|
|
96
|
+
onDomEditPersisted,
|
|
102
97
|
}: UseDomEditCommitsParams) {
|
|
103
98
|
const resolveImportedFontAsset = useCallback(
|
|
104
99
|
(fontFamilyValue: string): ImportedFontAsset | null => {
|
|
@@ -220,6 +215,7 @@ export function useDomEditCommits({
|
|
|
220
215
|
coalesceKey: options?.coalesceKey,
|
|
221
216
|
files: { [targetPath]: { before: originalContent, after: finalContent } },
|
|
222
217
|
});
|
|
218
|
+
onDomEditPersisted?.(selection, operations);
|
|
223
219
|
|
|
224
220
|
if (!options?.skipRefresh) {
|
|
225
221
|
reloadPreview();
|
|
@@ -233,6 +229,7 @@ export function useDomEditCommits({
|
|
|
233
229
|
domEditSaveTimestampRef,
|
|
234
230
|
reloadPreview,
|
|
235
231
|
showToast,
|
|
232
|
+
onDomEditPersisted,
|
|
236
233
|
],
|
|
237
234
|
);
|
|
238
235
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
1
2
|
import type { TimelineElement } from "../player";
|
|
2
3
|
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
|
3
4
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
@@ -8,6 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
|
|
|
8
9
|
import { useDomSelection } from "./useDomSelection";
|
|
9
10
|
import { usePreviewInteraction } from "./usePreviewInteraction";
|
|
10
11
|
import { useDomEditCommits } from "./useDomEditCommits";
|
|
12
|
+
import { runShadowDispatch } from "../utils/sdkShadow";
|
|
11
13
|
import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
12
14
|
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
13
15
|
import { useDomEditWiring } from "./useDomEditWiring";
|
|
@@ -58,6 +60,8 @@ export interface UseDomEditSessionParams {
|
|
|
58
60
|
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
|
|
59
61
|
selectSidebarTab?: (tab: SidebarTab) => void;
|
|
60
62
|
getSidebarTab?: () => SidebarTab;
|
|
63
|
+
/** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
|
|
64
|
+
sdkSession?: Composition | null;
|
|
61
65
|
}
|
|
62
66
|
|
|
63
67
|
// ── Hook ──
|
|
@@ -96,6 +100,7 @@ export function useDomEditSession({
|
|
|
96
100
|
openSourceForSelection,
|
|
97
101
|
selectSidebarTab,
|
|
98
102
|
getSidebarTab,
|
|
103
|
+
sdkSession,
|
|
99
104
|
}: UseDomEditSessionParams) {
|
|
100
105
|
void _setRefreshKey;
|
|
101
106
|
void _readProjectFile;
|
|
@@ -227,6 +232,9 @@ export function useDomEditSession({
|
|
|
227
232
|
clearDomSelection,
|
|
228
233
|
refreshDomEditSelectionFromPreview,
|
|
229
234
|
buildDomSelectionFromTarget,
|
|
235
|
+
onDomEditPersisted: sdkSession
|
|
236
|
+
? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
|
|
237
|
+
: undefined,
|
|
230
238
|
});
|
|
231
239
|
|
|
232
240
|
// ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
|
+
|
|
5
|
+
function toHfIds(group: DomEditSelection[], primary: DomEditSelection | null): string[] {
|
|
6
|
+
const source = group.length > 0 ? group : primary ? [primary] : [];
|
|
7
|
+
return source.flatMap((s) => (s.hfId ? [s.hfId] : []));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Stage 7 Step 2 — mirrors Studio canvas selection into the SDK session.
|
|
12
|
+
*
|
|
13
|
+
* Calls session.setSelection(hfIds) whenever domEditSelection or
|
|
14
|
+
* domEditGroupSelections changes. Pure effect; no existing hook modified.
|
|
15
|
+
*/
|
|
16
|
+
export function useSdkSelectionSync(
|
|
17
|
+
session: Composition | null,
|
|
18
|
+
domEditSelection: DomEditSelection | null,
|
|
19
|
+
domEditGroupSelections: DomEditSelection[],
|
|
20
|
+
): void {
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!session) return;
|
|
23
|
+
session.setSelection(toHfIds(domEditGroupSelections, domEditSelection));
|
|
24
|
+
}, [session, domEditSelection, domEditGroupSelections]);
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldReloadSdkSession } from "./useSdkSession";
|
|
3
|
+
|
|
4
|
+
describe("shouldReloadSdkSession", () => {
|
|
5
|
+
it("reloads when the changed file is the active composition", () => {
|
|
6
|
+
expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, "scenes/intro.html")).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("ignores changes to other files", () => {
|
|
10
|
+
expect(shouldReloadSdkSession({ path: "styles/main.css" }, "scenes/intro.html")).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("ignores changes when no composition is active", () => {
|
|
14
|
+
expect(shouldReloadSdkSession({ path: "scenes/intro.html" }, null)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("ignores payloads with no resolvable path", () => {
|
|
18
|
+
expect(shouldReloadSdkSession({}, "scenes/intro.html")).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
3
|
+
import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
|
|
4
|
+
import type { Composition } from "@hyperframes/sdk";
|
|
5
|
+
import { readStudioFileChangePath } from "../components/editor/manualEdits";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* True when an external file-change payload targets the active composition and
|
|
9
|
+
* the SDK session must be re-opened to pick up the new content.
|
|
10
|
+
*/
|
|
11
|
+
export function shouldReloadSdkSession(payload: unknown, activeCompPath: string | null): boolean {
|
|
12
|
+
if (!activeCompPath) return false;
|
|
13
|
+
return readStudioFileChangePath(payload) === activeCompPath;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Stage 7 Step 3a — SDK session wired to the active composition.
|
|
18
|
+
*
|
|
19
|
+
* Creates an SDK Composition backed by createHttpAdapter on every
|
|
20
|
+
* (projectId, activeCompPath) change, disposes the old one on cleanup, and
|
|
21
|
+
* re-opens it when the active composition file changes on disk (code editor,
|
|
22
|
+
* agent, or server-side patch) so the in-memory linkedom document never goes
|
|
23
|
+
* stale. The persist queue writes back to `activeCompPath` (not the
|
|
24
|
+
* "composition.html" default).
|
|
25
|
+
*
|
|
26
|
+
* The session is idle until Step 3c routes dispatch ops through it; re-opening
|
|
27
|
+
* is therefore purely additive — no SDK self-write exists yet, so there is no
|
|
28
|
+
* persist echo. Step 3c must add self-write suppression once dispatch writes.
|
|
29
|
+
*/
|
|
30
|
+
export function useSdkSession(
|
|
31
|
+
projectId: string | null,
|
|
32
|
+
activeCompPath: string | null,
|
|
33
|
+
): Composition | null {
|
|
34
|
+
const [session, setSession] = useState<Composition | null>(null);
|
|
35
|
+
const [reloadToken, setReloadToken] = useState(0);
|
|
36
|
+
|
|
37
|
+
// ── Re-open on external change to the active composition ──
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
if (!activeCompPath) return;
|
|
40
|
+
const handler = (payload?: unknown) => {
|
|
41
|
+
if (shouldReloadSdkSession(payload, activeCompPath)) {
|
|
42
|
+
setReloadToken((t) => t + 1);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
if (import.meta.hot) {
|
|
46
|
+
import.meta.hot.on("hf:file-change", handler);
|
|
47
|
+
return () => import.meta.hot?.off?.("hf:file-change", handler);
|
|
48
|
+
}
|
|
49
|
+
// SSE fallback for the embedded studio server.
|
|
50
|
+
const es = new EventSource("/api/events");
|
|
51
|
+
es.addEventListener("file-change", handler);
|
|
52
|
+
return () => es.close();
|
|
53
|
+
}, [activeCompPath]);
|
|
54
|
+
|
|
55
|
+
// ── Open / re-open the session ──
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!projectId || !activeCompPath) {
|
|
58
|
+
setSession(null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let cancelled = false;
|
|
63
|
+
let comp: Composition | null = null;
|
|
64
|
+
|
|
65
|
+
const adapter = createHttpAdapter({
|
|
66
|
+
projectFilesUrl: `/api/projects/${projectId}`,
|
|
67
|
+
});
|
|
68
|
+
adapter
|
|
69
|
+
.read(activeCompPath)
|
|
70
|
+
.then(async (content) => {
|
|
71
|
+
if (cancelled || typeof content !== "string") return;
|
|
72
|
+
comp = await openComposition(content, {
|
|
73
|
+
persist: adapter,
|
|
74
|
+
persistPath: activeCompPath,
|
|
75
|
+
});
|
|
76
|
+
comp.on("persist:error", (e) => {
|
|
77
|
+
console.warn("[sdk] persist:error", e.error);
|
|
78
|
+
});
|
|
79
|
+
// Cleanup may have fired while openComposition was awaited; dispose immediately.
|
|
80
|
+
if (cancelled) {
|
|
81
|
+
comp.dispose();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
setSession(comp);
|
|
85
|
+
})
|
|
86
|
+
.catch(() => {
|
|
87
|
+
if (!cancelled) setSession(null);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return () => {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
const c = comp;
|
|
93
|
+
if (c) void c.flush().finally(() => c.dispose());
|
|
94
|
+
};
|
|
95
|
+
}, [projectId, activeCompPath, reloadToken]);
|
|
96
|
+
|
|
97
|
+
return session;
|
|
98
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { patchOpsToSdkEditOps, SdkShadowMismatch } from "./sdkShadow";
|
|
3
|
+
import type { PatchOperation } from "./sourcePatcher";
|
|
4
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
5
|
+
|
|
6
|
+
const BASE_HTML = /* html */ `<!DOCTYPE html>
|
|
7
|
+
<html><body>
|
|
8
|
+
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
|
|
9
|
+
</body></html>`;
|
|
10
|
+
|
|
11
|
+
describe("patchOpsToSdkEditOps", () => {
|
|
12
|
+
it("maps inline-style ops to a single setStyle EditOp", () => {
|
|
13
|
+
const ops: PatchOperation[] = [
|
|
14
|
+
{ type: "inline-style", property: "color", value: "#00f" },
|
|
15
|
+
{ type: "inline-style", property: "opacity", value: "0.5" },
|
|
16
|
+
];
|
|
17
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
18
|
+
expect(result).toHaveLength(1);
|
|
19
|
+
expect(result[0]).toEqual({
|
|
20
|
+
type: "setStyle",
|
|
21
|
+
target: "hf-box",
|
|
22
|
+
styles: { color: "#00f", opacity: "0.5" },
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("maps text-content op to setText EditOp", () => {
|
|
27
|
+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }];
|
|
28
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
29
|
+
expect(result).toHaveLength(1);
|
|
30
|
+
expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("maps attribute op to setAttribute with data- prefix", () => {
|
|
34
|
+
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
|
|
35
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
36
|
+
expect(result).toHaveLength(1);
|
|
37
|
+
expect(result[0]).toEqual({
|
|
38
|
+
type: "setAttribute",
|
|
39
|
+
target: "hf-box",
|
|
40
|
+
name: "data-name",
|
|
41
|
+
value: "hero",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("maps html-attribute op to setAttribute without prefix", () => {
|
|
46
|
+
const ops: PatchOperation[] = [
|
|
47
|
+
{ type: "html-attribute", property: "contenteditable", value: "true" },
|
|
48
|
+
];
|
|
49
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
50
|
+
expect(result).toHaveLength(1);
|
|
51
|
+
expect(result[0]).toEqual({
|
|
52
|
+
type: "setAttribute",
|
|
53
|
+
target: "hf-box",
|
|
54
|
+
name: "contenteditable",
|
|
55
|
+
value: "true",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("handles null value for attribute removal", () => {
|
|
60
|
+
const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }];
|
|
61
|
+
const result = patchOpsToSdkEditOps("hf-box", ops);
|
|
62
|
+
expect(result[0]).toEqual({
|
|
63
|
+
type: "setAttribute",
|
|
64
|
+
target: "hf-box",
|
|
65
|
+
name: "hidden",
|
|
66
|
+
value: null,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns empty array for unknown op types", () => {
|
|
71
|
+
const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
|
|
72
|
+
expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("sdkShadowDispatch (integration)", () => {
|
|
77
|
+
it("applies ops and returns no mismatches when SDK matches expected values", async () => {
|
|
78
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
79
|
+
const session = await openComposition(BASE_HTML);
|
|
80
|
+
|
|
81
|
+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
|
|
82
|
+
const result = sdkShadowDispatch(session, "hf-box", ops);
|
|
83
|
+
|
|
84
|
+
expect(result.dispatched).toBe(true);
|
|
85
|
+
expect(result.mismatches).toHaveLength(0);
|
|
86
|
+
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns dispatched:false when hfId not found in session", async () => {
|
|
90
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
91
|
+
const session = await openComposition(BASE_HTML);
|
|
92
|
+
|
|
93
|
+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
|
|
94
|
+
const result = sdkShadowDispatch(session, "hf-missing", ops);
|
|
95
|
+
|
|
96
|
+
expect(result.dispatched).toBe(false);
|
|
97
|
+
expect(result.mismatches).toHaveLength(1);
|
|
98
|
+
expect(result.mismatches[0]).toMatchObject<SdkShadowMismatch>({
|
|
99
|
+
kind: "element_not_found",
|
|
100
|
+
hfId: "hf-missing",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("applies text op and reads back via session.getElement", async () => {
|
|
105
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
106
|
+
const session = await openComposition(BASE_HTML);
|
|
107
|
+
|
|
108
|
+
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }];
|
|
109
|
+
sdkShadowDispatch(session, "hf-box", ops);
|
|
110
|
+
|
|
111
|
+
expect(session.getElement("hf-box")?.text).toBe("Updated");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("applies attribute op and reads back via session.getElement", async () => {
|
|
115
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
116
|
+
const session = await openComposition(BASE_HTML);
|
|
117
|
+
|
|
118
|
+
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
|
|
119
|
+
sdkShadowDispatch(session, "hf-box", ops);
|
|
120
|
+
|
|
121
|
+
expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("returns dispatch_error when dispatch throws — does not propagate", async () => {
|
|
125
|
+
const { sdkShadowDispatch } = await import("./sdkShadow");
|
|
126
|
+
const session = await openComposition(BASE_HTML);
|
|
127
|
+
// Poison dispatch so it throws on any call
|
|
128
|
+
session.dispatch = () => {
|
|
129
|
+
throw new Error("sdk internal error");
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }];
|
|
133
|
+
let result: ReturnType<typeof sdkShadowDispatch> | undefined;
|
|
134
|
+
expect(() => {
|
|
135
|
+
result = sdkShadowDispatch(session, "hf-box", ops);
|
|
136
|
+
}).not.toThrow();
|
|
137
|
+
|
|
138
|
+
expect(result!.dispatched).toBe(false);
|
|
139
|
+
expect(result!.mismatches).toHaveLength(1);
|
|
140
|
+
expect(result!.mismatches[0]).toMatchObject<SdkShadowMismatch>({
|
|
141
|
+
kind: "dispatch_error",
|
|
142
|
+
hfId: "hf-box",
|
|
143
|
+
error: expect.stringContaining("sdk internal error"),
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|