@hyperframes/studio 0.7.16 → 0.7.18
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-sCOjSz40.js → index-B7_M3NXS.js} +1 -1
- package/dist/assets/{index-C4cUbqql.js → index-D0468l1X.js} +192 -192
- package/dist/assets/{index-D2SMcOdY.js → index-mZiDOLTB.js} +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +379 -238
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -1
- package/src/hooks/useDomEditSession.ts +12 -0
- package/src/hooks/useDomSelection.ts +4 -1
- package/src/hooks/useStudioSelectionPublisher.ts +102 -0
- package/src/utils/studioSelectionSnapshot.test.ts +47 -0
- package/src/utils/studioSelectionSnapshot.ts +72 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.18",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -45,11 +45,11 @@
|
|
|
45
45
|
"dompurify": "^3.2.4",
|
|
46
46
|
"marked": "^14.1.4",
|
|
47
47
|
"mediabunny": "^1.45.3",
|
|
48
|
-
"@hyperframes/
|
|
49
|
-
"@hyperframes/player": "0.7.
|
|
50
|
-
"@hyperframes/
|
|
51
|
-
"@hyperframes/studio-server": "0.7.
|
|
52
|
-
"@hyperframes/
|
|
48
|
+
"@hyperframes/parsers": "0.7.18",
|
|
49
|
+
"@hyperframes/player": "0.7.18",
|
|
50
|
+
"@hyperframes/sdk": "0.7.18",
|
|
51
|
+
"@hyperframes/studio-server": "0.7.18",
|
|
52
|
+
"@hyperframes/core": "0.7.18"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@types/react": "19",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"vite": "^6.4.2",
|
|
65
65
|
"vitest": "^3.2.4",
|
|
66
66
|
"zustand": "^5.0.0",
|
|
67
|
-
"@hyperframes/producer": "0.7.
|
|
67
|
+
"@hyperframes/producer": "0.7.18"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -83,7 +83,7 @@ export function StudioApp() {
|
|
|
83
83
|
const [previewIframe, setPreviewIframe] = useState<HTMLIFrameElement | null>(null);
|
|
84
84
|
const [compositionLoading, setCompositionLoading] = useState(true);
|
|
85
85
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
86
|
-
const [, setPreviewDocumentVersion] = useState(0);
|
|
86
|
+
const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0);
|
|
87
87
|
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
|
|
88
88
|
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
89
89
|
const activeCompPathRef = useRef(activeCompPath);
|
|
@@ -296,6 +296,7 @@ export function StudioApp() {
|
|
|
296
296
|
projectIdRef: fileManager.projectIdRef,
|
|
297
297
|
previewIframe,
|
|
298
298
|
refreshKey,
|
|
299
|
+
previewDocumentVersion,
|
|
299
300
|
rightPanelTab: panelLayout.rightPanelTab,
|
|
300
301
|
applyStudioManualEditsToPreviewRef: previewPersistence.applyStudioManualEditsToPreviewRef,
|
|
301
302
|
syncPreviewHistoryHotkey: appHotkeys.syncPreviewHistoryHotkey,
|
|
@@ -18,6 +18,7 @@ import { useGsapScriptCommits } from "./useGsapScriptCommits";
|
|
|
18
18
|
import { useGsapCacheVersion } from "./useGsapTweenCache";
|
|
19
19
|
import { useDomEditWiring } from "./useDomEditWiring";
|
|
20
20
|
import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
|
21
|
+
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
|
21
22
|
|
|
22
23
|
// ── Types ──
|
|
23
24
|
|
|
@@ -54,6 +55,7 @@ export interface UseDomEditSessionParams {
|
|
|
54
55
|
projectIdRef: React.MutableRefObject<string | null>;
|
|
55
56
|
previewIframe: HTMLIFrameElement | null;
|
|
56
57
|
refreshKey: number;
|
|
58
|
+
previewDocumentVersion: number;
|
|
57
59
|
rightPanelTab: RightPanelTab;
|
|
58
60
|
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
|
|
59
61
|
(iframe: HTMLIFrameElement) => Promise<void>
|
|
@@ -96,6 +98,7 @@ export function useDomEditSession({
|
|
|
96
98
|
projectIdRef,
|
|
97
99
|
previewIframe,
|
|
98
100
|
refreshKey,
|
|
101
|
+
previewDocumentVersion,
|
|
99
102
|
rightPanelTab,
|
|
100
103
|
applyStudioManualEditsToPreviewRef,
|
|
101
104
|
syncPreviewHistoryHotkey,
|
|
@@ -168,6 +171,15 @@ export function useDomEditSession({
|
|
|
168
171
|
domEditSelection,
|
|
169
172
|
});
|
|
170
173
|
|
|
174
|
+
useStudioSelectionPublisher({
|
|
175
|
+
projectId,
|
|
176
|
+
domEditSelection,
|
|
177
|
+
domEditSelectionRef,
|
|
178
|
+
refreshKey,
|
|
179
|
+
previewDocumentVersion,
|
|
180
|
+
refreshDomEditSelectionFromPreview,
|
|
181
|
+
});
|
|
182
|
+
|
|
171
183
|
// ── GSAP cache (hoisted so both useGsapScriptCommits and useDomEditWiring share the same instance) ──
|
|
172
184
|
|
|
173
185
|
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
|
|
@@ -381,7 +381,10 @@ export function useDomSelection({
|
|
|
381
381
|
if (!doc) return;
|
|
382
382
|
|
|
383
383
|
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
384
|
-
if (!element)
|
|
384
|
+
if (!element) {
|
|
385
|
+
applyDomSelection(null, { revealPanel: false });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
385
388
|
|
|
386
389
|
const nextSelection = await buildDomSelectionFromTarget(element);
|
|
387
390
|
if (nextSelection) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { useEffect, useRef, type MutableRefObject } from "react";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
import { usePlayerStore } from "../player";
|
|
4
|
+
import { buildStudioSelectionSnapshot } from "../utils/studioSelectionSnapshot";
|
|
5
|
+
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
6
|
+
|
|
7
|
+
interface UseStudioSelectionPublisherParams {
|
|
8
|
+
projectId: string | null;
|
|
9
|
+
domEditSelection: DomEditSelection | null;
|
|
10
|
+
domEditSelectionRef: MutableRefObject<DomEditSelection | null>;
|
|
11
|
+
refreshKey: number;
|
|
12
|
+
previewDocumentVersion: number;
|
|
13
|
+
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function reportSelectionPublishError(error: unknown): void {
|
|
17
|
+
if (error instanceof Error && error.name === "AbortError") return;
|
|
18
|
+
const errorName = error instanceof Error ? error.name : typeof error;
|
|
19
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
20
|
+
trackStudioEvent("studio_selection_publish_failed", {
|
|
21
|
+
error_name: errorName,
|
|
22
|
+
error_message: errorMessage.slice(0, 500),
|
|
23
|
+
});
|
|
24
|
+
// eslint-disable-next-line no-console
|
|
25
|
+
console.warn("[Studio] Failed to update agent selection context", error);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function putSelection(projectId: string, selection: unknown, signal?: AbortSignal): Promise<void> {
|
|
29
|
+
return fetch(`/api/projects/${encodeURIComponent(projectId)}/selection`, {
|
|
30
|
+
method: "PUT",
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: JSON.stringify({ selection }),
|
|
33
|
+
signal,
|
|
34
|
+
}).then(() => undefined);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useStudioSelectionPublisher({
|
|
38
|
+
projectId,
|
|
39
|
+
domEditSelection,
|
|
40
|
+
domEditSelectionRef,
|
|
41
|
+
refreshKey,
|
|
42
|
+
previewDocumentVersion,
|
|
43
|
+
refreshDomEditSelectionFromPreview,
|
|
44
|
+
}: UseStudioSelectionPublisherParams): void {
|
|
45
|
+
const lastSelectionRefreshKeyRef = useRef(refreshKey);
|
|
46
|
+
const pendingSelectionRefreshKeyRef = useRef<number | null>(null);
|
|
47
|
+
|
|
48
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!projectId) return;
|
|
51
|
+
const selection = domEditSelection?.element.isConnected
|
|
52
|
+
? buildStudioSelectionSnapshot({
|
|
53
|
+
projectId,
|
|
54
|
+
selection: domEditSelection,
|
|
55
|
+
currentTime: usePlayerStore.getState().currentTime,
|
|
56
|
+
})
|
|
57
|
+
: null;
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
void putSelection(projectId, selection, controller.signal).catch(reportSelectionPublishError);
|
|
60
|
+
return () => controller.abort();
|
|
61
|
+
}, [domEditSelection, projectId]);
|
|
62
|
+
|
|
63
|
+
// Clear server-side agent context when Studio leaves a project. Without this,
|
|
64
|
+
// a long-running multi-project preview server can keep serving the last
|
|
65
|
+
// selected element for a project after its tab/session unmounts.
|
|
66
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!projectId) return;
|
|
69
|
+
return () => {
|
|
70
|
+
void putSelection(projectId, null).catch(reportSelectionPublishError);
|
|
71
|
+
};
|
|
72
|
+
}, [projectId]);
|
|
73
|
+
|
|
74
|
+
// On external file edits, the iframe reloads while React keeps the previous
|
|
75
|
+
// DOM selection object alive. Clear the agent-facing snapshot immediately so
|
|
76
|
+
// `preview --context` never serves a detached or stale target, then let the
|
|
77
|
+
// post-load preview document refresh below re-resolve the selection if it
|
|
78
|
+
// still exists in the new document.
|
|
79
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (lastSelectionRefreshKeyRef.current === refreshKey) return;
|
|
82
|
+
lastSelectionRefreshKeyRef.current = refreshKey;
|
|
83
|
+
pendingSelectionRefreshKeyRef.current = domEditSelectionRef.current ? refreshKey : null;
|
|
84
|
+
if (!projectId || !domEditSelectionRef.current) return;
|
|
85
|
+
const controller = new AbortController();
|
|
86
|
+
void putSelection(projectId, null, controller.signal).catch(reportSelectionPublishError);
|
|
87
|
+
return () => controller.abort();
|
|
88
|
+
}, [domEditSelectionRef, projectId, refreshKey]);
|
|
89
|
+
|
|
90
|
+
// `refreshPreviewDocumentVersion` ticks after iframe load and shortly after.
|
|
91
|
+
// Consume one pending refresh per external reload: enough to re-resolve the
|
|
92
|
+
// selected element once the new document is queryable, without republishing
|
|
93
|
+
// the same snapshot on every follow-up 80/300ms tick.
|
|
94
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (pendingSelectionRefreshKeyRef.current === null) return;
|
|
97
|
+
pendingSelectionRefreshKeyRef.current = null;
|
|
98
|
+
const selection = domEditSelectionRef.current;
|
|
99
|
+
if (!selection) return;
|
|
100
|
+
void refreshDomEditSelectionFromPreview(selection);
|
|
101
|
+
}, [domEditSelectionRef, previewDocumentVersion, refreshDomEditSelectionFromPreview]);
|
|
102
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildStudioSelectionSnapshot } from "./studioSelectionSnapshot";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
4
|
+
|
|
5
|
+
describe("buildStudioSelectionSnapshot", () => {
|
|
6
|
+
it("serializes a DOM edit selection without the live HTMLElement", () => {
|
|
7
|
+
const selection = {
|
|
8
|
+
element: { tagName: "H1" } as HTMLElement,
|
|
9
|
+
id: null,
|
|
10
|
+
hfId: "hero-title",
|
|
11
|
+
selector: ".title",
|
|
12
|
+
selectorIndex: 0,
|
|
13
|
+
label: "Hero title",
|
|
14
|
+
tagName: "h1",
|
|
15
|
+
sourceFile: "index.html",
|
|
16
|
+
compositionPath: "index.html",
|
|
17
|
+
isCompositionHost: false,
|
|
18
|
+
isInsideLockedComposition: false,
|
|
19
|
+
boundingBox: { x: 10, y: 20, width: 300, height: 64 },
|
|
20
|
+
textContent: "Launch faster",
|
|
21
|
+
dataAttributes: { "data-hf-id": "hero-title" },
|
|
22
|
+
inlineStyles: { color: "white" },
|
|
23
|
+
computedStyles: { "font-size": "48px" },
|
|
24
|
+
textFields: [],
|
|
25
|
+
capabilities: { canSelect: true, canEditStyles: true },
|
|
26
|
+
} as DomEditSelection;
|
|
27
|
+
|
|
28
|
+
const snapshot = buildStudioSelectionSnapshot({
|
|
29
|
+
projectId: "demo",
|
|
30
|
+
selection,
|
|
31
|
+
currentTime: 1.25,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(snapshot).toMatchObject({
|
|
35
|
+
schemaVersion: 1,
|
|
36
|
+
projectId: "demo",
|
|
37
|
+
compositionPath: "index.html",
|
|
38
|
+
sourceFile: "index.html",
|
|
39
|
+
currentTime: 1.25,
|
|
40
|
+
target: { hfId: "hero-title", selector: ".title", selectorIndex: 0 },
|
|
41
|
+
thumbnailUrl:
|
|
42
|
+
"/api/projects/demo/thumbnail/index.html?t=1.25&format=png&selector=.title&selectorIndex=0",
|
|
43
|
+
});
|
|
44
|
+
expect(JSON.stringify(snapshot)).not.toContain("HTMLElement");
|
|
45
|
+
expect(snapshot).not.toHaveProperty("element");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { StudioSelectionSnapshot } from "@hyperframes/studio-server";
|
|
2
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
|
|
4
|
+
function round3(value: number): number {
|
|
5
|
+
return Math.round(value * 1000) / 1000;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function thumbnailUrl({
|
|
9
|
+
projectId,
|
|
10
|
+
selection,
|
|
11
|
+
currentTime,
|
|
12
|
+
}: {
|
|
13
|
+
projectId: string;
|
|
14
|
+
selection: DomEditSelection;
|
|
15
|
+
currentTime: number;
|
|
16
|
+
}): string {
|
|
17
|
+
const compPath = encodeURIComponent(
|
|
18
|
+
selection.compositionPath || selection.sourceFile || "index.html",
|
|
19
|
+
);
|
|
20
|
+
const params = new URLSearchParams({
|
|
21
|
+
t: String(round3(currentTime)),
|
|
22
|
+
format: "png",
|
|
23
|
+
});
|
|
24
|
+
if (selection.selector) params.set("selector", selection.selector);
|
|
25
|
+
if (selection.selectorIndex != null) params.set("selectorIndex", String(selection.selectorIndex));
|
|
26
|
+
return `/api/projects/${encodeURIComponent(projectId)}/thumbnail/${compPath}?${params.toString()}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildStudioSelectionSnapshot({
|
|
30
|
+
projectId,
|
|
31
|
+
selection,
|
|
32
|
+
currentTime,
|
|
33
|
+
}: {
|
|
34
|
+
projectId: string;
|
|
35
|
+
selection: DomEditSelection;
|
|
36
|
+
currentTime: number;
|
|
37
|
+
}): StudioSelectionSnapshot {
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
projectId,
|
|
41
|
+
compositionPath: selection.compositionPath,
|
|
42
|
+
sourceFile: selection.sourceFile,
|
|
43
|
+
currentTime: round3(currentTime),
|
|
44
|
+
target: {
|
|
45
|
+
id: selection.id,
|
|
46
|
+
hfId: selection.hfId,
|
|
47
|
+
selector: selection.selector,
|
|
48
|
+
selectorIndex: selection.selectorIndex,
|
|
49
|
+
},
|
|
50
|
+
label: selection.label,
|
|
51
|
+
tagName: selection.tagName,
|
|
52
|
+
boundingBox: {
|
|
53
|
+
x: round3(selection.boundingBox.x),
|
|
54
|
+
y: round3(selection.boundingBox.y),
|
|
55
|
+
width: round3(selection.boundingBox.width),
|
|
56
|
+
height: round3(selection.boundingBox.height),
|
|
57
|
+
},
|
|
58
|
+
textContent: selection.textContent,
|
|
59
|
+
dataAttributes: { ...selection.dataAttributes },
|
|
60
|
+
inlineStyles: { ...selection.inlineStyles },
|
|
61
|
+
computedStyles: { ...selection.computedStyles },
|
|
62
|
+
textFields: selection.textFields.map((field) => ({
|
|
63
|
+
key: field.key,
|
|
64
|
+
label: field.label,
|
|
65
|
+
value: field.value,
|
|
66
|
+
tagName: field.tagName,
|
|
67
|
+
source: field.source,
|
|
68
|
+
})),
|
|
69
|
+
capabilities: { ...selection.capabilities },
|
|
70
|
+
thumbnailUrl: thumbnailUrl({ projectId, selection, currentTime }),
|
|
71
|
+
};
|
|
72
|
+
}
|