@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.16",
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/core": "0.7.16",
49
- "@hyperframes/player": "0.7.16",
50
- "@hyperframes/parsers": "0.7.16",
51
- "@hyperframes/studio-server": "0.7.16",
52
- "@hyperframes/sdk": "0.7.16"
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.16"
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) return;
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
+ }