@hyperframes/studio 0.8.89 → 0.8.90

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.8.89",
3
+ "version": "0.8.90",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -60,12 +60,12 @@
60
60
  "dockview-react": "8.3.1",
61
61
  "gsap": "^3.13.0",
62
62
  "mediabunny": "^1.45.3",
63
- "@hyperframes/parsers": "0.8.89",
64
- "@hyperframes/sdk": "0.8.89",
65
- "@hyperframes/shader-transitions": "0.8.89",
66
- "@hyperframes/core": "0.8.89",
67
- "@hyperframes/player": "0.8.89",
68
- "@hyperframes/studio-server": "0.8.89"
63
+ "@hyperframes/core": "0.8.90",
64
+ "@hyperframes/parsers": "0.8.90",
65
+ "@hyperframes/player": "0.8.90",
66
+ "@hyperframes/sdk": "0.8.90",
67
+ "@hyperframes/shader-transitions": "0.8.90",
68
+ "@hyperframes/studio-server": "0.8.90"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.89"
84
+ "@hyperframes/producer": "0.8.90"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -61,10 +61,8 @@ export function useInlineTextEditing(
61
61
  const elementUnderPress = (event: { clientX: number; clientY: number }) => {
62
62
  const iframe = actions?.previewIframeRef?.current;
63
63
  if (!iframe) return null;
64
- // Studio suppresses pointer events inside the composition so the canvas
65
- // overlay can own input, which means a plain elementFromPoint only ever
66
- // finds wrappers. This helper lifts that for the length of the hit test,
67
- // and is the same one the canvas uses to decide what was clicked.
64
+ // The same hit test the canvas uses to decide what was clicked; a plain
65
+ // elementFromPoint only finds wrappers, since Studio suppresses pointer events.
68
66
  return getPreviewTargetFromPointer(
69
67
  iframe,
70
68
  event.clientX,
@@ -17,6 +17,27 @@ export function onPreviewContentReplaced(
17
17
  };
18
18
  }
19
19
 
20
+ export function markScenesStale(iframe: HTMLIFrameElement | null, files: readonly string[]): void {
21
+ const doc = iframe?.contentDocument;
22
+ const meta = doc?.querySelector<HTMLMetaElement>('meta[name="hf-scene-parts"]');
23
+ if (!doc || !meta) return;
24
+ let parts: { shared: string; scenes: Record<string, string> };
25
+ try {
26
+ parts = JSON.parse(meta.content);
27
+ } catch {
28
+ return;
29
+ }
30
+ for (const file of files) {
31
+ const hosts = doc.querySelectorAll(`[data-composition-file="${CSS.escape(file)}"]`);
32
+ if (hosts.length === 0 && file.endsWith(".html")) parts.shared = "";
33
+ for (const host of hosts) {
34
+ const scene = host.closest("[data-hf-scene]")?.getAttribute("data-hf-scene");
35
+ if (scene && scene in parts.scenes) parts.scenes[scene] = "";
36
+ }
37
+ }
38
+ meta.content = JSON.stringify(parts);
39
+ }
40
+
20
41
  /** Null when the preview cannot swap; rejects when superseded, cancelled or a full reload is needed. */
21
42
  export function sceneSwapFor(
22
43
  iframe: HTMLIFrameElement,
@@ -259,6 +259,64 @@ describe("applyUndoRestoreToPreview", () => {
259
259
  expect(reloadPreview).toHaveBeenCalledTimes(1);
260
260
  });
261
261
 
262
+ it("blanks a restored scene's live hash before reloading, so the scene swap takes it", () => {
263
+ const { iframe, doc } = buildLiveIframe(
264
+ `<div data-hf-scene="scene0" data-composition-file="scenes/intro.html"><h1>Rep0</h1></div>` +
265
+ `<div data-hf-scene="scene1" data-composition-file="scenes/outro.html"></div>`,
266
+ );
267
+ const meta = doc.createElement("meta");
268
+ meta.name = "hf-scene-parts";
269
+ meta.content = JSON.stringify({ shared: "s", scenes: { scene0: "h0", scene1: "h1" } });
270
+ doc.head.append(meta);
271
+ let partsAtReload: unknown;
272
+ const reloadPreview = vi.fn(() => (partsAtReload = JSON.parse(meta.content)));
273
+ const files = {
274
+ "scenes/intro.html": { previous: "<h1>Rep0</h1>", restored: "<h1>Alpha</h1>" },
275
+ };
276
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
277
+ expect(partsAtReload).toEqual({ shared: "s", scenes: { scene0: "", scene1: "h1" } });
278
+
279
+ const withRoot = {
280
+ ...files,
281
+ [ROOT]: { previous: wrap("<p>b</p>"), restored: wrap("<p>a</p>") },
282
+ };
283
+ applyUndoRestoreToPreview(iframe, ROOT, withRoot, 3, reloadPreview);
284
+ // A restored page outside every scene forces the full reload, never a partial swap.
285
+ expect(partsAtReload).toEqual({ shared: "", scenes: { scene0: "", scene1: "h1" } });
286
+ });
287
+
288
+ it("leaves the scene hashes alone on a soft restore, so the next edit can still swap", () => {
289
+ const { iframe, doc } = buildLiveIframe(`<div id="a" style="color: red">t</div>`);
290
+ const meta = doc.createElement("meta");
291
+ meta.name = "hf-scene-parts";
292
+ meta.content = JSON.stringify({ shared: "s", scenes: { scene0: "h0" } });
293
+ doc.head.append(meta);
294
+ const files = {
295
+ [ROOT]: {
296
+ previous: wrap(`<div id="a" style="color: red">t</div>`),
297
+ restored: wrap(`<div id="a" style="color: blue">t</div>`),
298
+ },
299
+ };
300
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, vi.fn())).toBe("soft");
301
+ expect(JSON.parse(meta.content)).toEqual({ shared: "s", scenes: { scene0: "h0" } });
302
+ });
303
+
304
+ it.each([
305
+ ["a restored stylesheet leaves the shared hash", "styles.css", '{"shared":"s","scenes":{}}'],
306
+ ["an unreadable manifest still reloads", "scenes/intro.html", "not json"],
307
+ ])("%s", (_name, path, content) => {
308
+ const { iframe, doc } = buildLiveIframe(`<div id="a">t</div>`);
309
+ const meta = doc.createElement("meta");
310
+ meta.name = "hf-scene-parts";
311
+ meta.content = content;
312
+ doc.head.append(meta);
313
+ const reloadPreview = vi.fn();
314
+ const files = { [path]: { previous: "a", restored: "b" } };
315
+ expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
316
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
317
+ expect(meta.content).toBe(content);
318
+ });
319
+
262
320
  it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
263
321
  const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
264
322
  const reloadPreview = vi.fn();
@@ -8,6 +8,7 @@ import {
8
8
  extractGsapScriptText,
9
9
  findGsapScriptElements,
10
10
  } from "./gsapSoftReload";
11
+ import { markScenesStale } from "../player/sceneSwap";
11
12
 
12
13
  type PreviewWindow = Window & {
13
14
  __player?: { seek?: (t: number) => void };
@@ -184,7 +185,7 @@ export function applyUndoRestoreToPreview(
184
185
  activeCompPath: string | null,
185
186
  files: Record<string, UndoRestoreFile> | undefined,
186
187
  currentTime: number,
187
- reloadPreview: () => void,
188
+ reload: () => void,
188
189
  ): "soft" | "full" {
189
190
  // The master view carries a NULL activeCompPath but the root iframe shows
190
191
  // index.html — the codebase-wide convention (`activeCompPath || "index.html"`).
@@ -192,6 +193,10 @@ export function applyUndoRestoreToPreview(
192
193
  // full-reloaded: the original "undo always blinks".
193
194
  const activeDocPath = activeCompPath ?? "index.html";
194
195
  const paths = files ? Object.keys(files) : [];
196
+ const reloadPreview = () => {
197
+ markScenesStale(iframe, paths);
198
+ reload();
199
+ };
195
200
  // Soft path only covers the single active-comp document in the root iframe.
196
201
  if (!iframe || !files || paths.length !== 1 || paths[0] !== activeDocPath) {
197
202
  reloadPreview();
@@ -3,6 +3,7 @@
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import {
5
5
  coversComposition,
6
+ getAllPreviewTargetsFromPointer,
6
7
  getPreviewTargetFromPointer,
7
8
  pauseStudioPreviewPlayback,
8
9
  } from "./studioPreviewHelpers";
@@ -141,6 +142,34 @@ describe("getPreviewTargetFromPointer", () => {
141
142
  iframe.remove();
142
143
  });
143
144
 
145
+ it("hit-tests without adding styles to the preview document", () => {
146
+ const { iframe, doc } = createPreviewIframe();
147
+ doc.body.innerHTML = `<main data-composition-id="scene"><h1 id="headline">Title</h1></main>`;
148
+ const scene = doc.querySelector<HTMLElement>("main")!;
149
+ const headline = doc.getElementById("headline")!;
150
+ stubRect(iframe, domRect(0, 0, 400, 300));
151
+ stubRect(scene, domRect(0, 0, 400, 300));
152
+ stubRect(headline, domRect(40, 40, 160, 48));
153
+ const stylesAtHitTest: number[] = [];
154
+ doc.elementsFromPoint = () => {
155
+ stylesAtHitTest.push(doc.querySelectorAll("style").length + doc.adoptedStyleSheets.length);
156
+ return [headline, scene];
157
+ };
158
+ // First call runs the once-per-document inheritance probe, a body mutation.
159
+ expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
160
+ const observer = new MutationObserver(() => {});
161
+ observer.observe(doc, { subtree: true, childList: true, attributes: true });
162
+
163
+ expect(getPreviewTargetFromPointer(iframe, 80, 64, "index.html")).toBe(headline);
164
+ expect(getAllPreviewTargetsFromPointer(iframe, 80, 64, "index.html")).toEqual([headline]);
165
+ const mutations = observer.takeRecords();
166
+ observer.disconnect();
167
+
168
+ expect(stylesAtHitTest).toEqual([0, 0, 0]);
169
+ expect(mutations).toEqual([]);
170
+ iframe.remove();
171
+ });
172
+
144
173
  it("skips candidates hidden from author hit-testing by inherited pointer-events:none", () => {
145
174
  const { iframe, doc } = createPreviewIframe();
146
175
 
@@ -70,28 +70,6 @@ function resolvePreviewLocalPointer(
70
70
  };
71
71
  }
72
72
 
73
- const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
74
-
75
- function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
76
- try {
77
- const style = doc.createElement("style");
78
- style.id = POINTER_EVENTS_OVERRIDE_ID;
79
- style.textContent = "* { pointer-events: auto !important; }";
80
- doc.head.appendChild(style);
81
- return style;
82
- } catch {
83
- return null;
84
- }
85
- }
86
-
87
- function removePointerEventsOverride(style: HTMLStyleElement | null): void {
88
- try {
89
- style?.remove();
90
- } catch {
91
- // cross-origin or detached doc
92
- }
93
- }
94
-
95
73
  const pointerEventsInheritanceFallbackByDocument = new WeakMap<Document, boolean>();
96
74
 
97
75
  function needsPointerEventsInheritanceFallback(doc: Document, win: Window): boolean {
@@ -222,45 +200,33 @@ export function getPreviewTargetFromPointer(
222
200
  const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
223
201
  if (!localPointer) return null;
224
202
 
225
- let overrideStyle = forcePointerEventsAuto(doc);
226
- try {
227
- if (typeof doc.elementsFromPoint === "function") {
228
- const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
229
- removePointerEventsOverride(overrideStyle);
230
- overrideStyle = null;
231
- const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath);
232
- const visualTarget =
233
- candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
234
- if (visualTarget) return visualTarget;
235
- }
236
-
237
- // Belt-and-suspenders: elementsFromPoint is universally supported in the
238
- // browsers this ships in, so the override is already removed by this
239
- // point in practice — but guard the environment without it too, so
240
- // hasAuthorPointerEventsNone below never reads a forced-auto value.
241
- removePointerEventsOverride(overrideStyle);
242
- overrideStyle = null;
243
-
244
- // No element hit (e.g. empty space inside an animated group's overlay) — fall
245
- // back to the group whose member-union contains the point, so the whole group
246
- // area is hoverable/selectable, not just where a member currently sits.
247
- const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
248
- if (
249
- groupHit &&
250
- !hasAuthorPointerEventsNone(groupHit) &&
251
- getDomLayerPatchTarget(groupHit, activeCompositionPath)
252
- )
253
- return groupHit;
254
-
255
- const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
256
- if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
257
- if (hasAuthorPointerEventsNone(fallback)) return null;
258
- if (!isElementComputedVisible(fallback)) return null;
259
- if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
260
- return fallback;
261
- } finally {
262
- removePointerEventsOverride(overrideStyle);
203
+ // Hit-test without touching the preview document: any style added here, even
204
+ // briefly, restyles every element in the film on each click and hover.
205
+ if (typeof doc.elementsFromPoint === "function") {
206
+ const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
207
+ const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath);
208
+ const visualTarget =
209
+ candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null;
210
+ if (visualTarget) return visualTarget;
263
211
  }
212
+
213
+ // No element hit (e.g. empty space inside an animated group's overlay) — fall
214
+ // back to the group whose member-union contains the point, so the whole group
215
+ // area is hoverable/selectable, not just where a member currently sits.
216
+ const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
217
+ if (
218
+ groupHit &&
219
+ !hasAuthorPointerEventsNone(groupHit) &&
220
+ getDomLayerPatchTarget(groupHit, activeCompositionPath)
221
+ )
222
+ return groupHit;
223
+
224
+ const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
225
+ if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
226
+ if (hasAuthorPointerEventsNone(fallback)) return null;
227
+ if (!isElementComputedVisible(fallback)) return null;
228
+ if (isFullBleedTarget(fallback, localPointer.viewport)) return null;
229
+ return fallback;
264
230
  }
265
231
 
266
232
  /** Returns all independently-selectable elements at the pointer (topmost first). */
@@ -283,27 +249,18 @@ export function getAllPreviewTargetsFromPointer(
283
249
  const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
284
250
  if (!localPointer) return [];
285
251
 
286
- let overrideStyle = forcePointerEventsAuto(doc);
287
- try {
288
- if (typeof doc.elementsFromPoint === "function") {
289
- const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
290
- removePointerEventsOverride(overrideStyle);
291
- overrideStyle = null;
292
- return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
293
- (el) => !isFullBleedTarget(el, localPointer.viewport),
294
- );
295
- }
296
- const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
297
- if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
298
- removePointerEventsOverride(overrideStyle);
299
- overrideStyle = null;
300
- if (hasAuthorPointerEventsNone(fallback)) return [];
301
- if (!isElementComputedVisible(fallback)) return [];
302
- if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
303
- return [fallback];
304
- } finally {
305
- removePointerEventsOverride(overrideStyle);
252
+ if (typeof doc.elementsFromPoint === "function") {
253
+ const elements = doc.elementsFromPoint(localPointer.x, localPointer.y);
254
+ return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter(
255
+ (el) => !isFullBleedTarget(el, localPointer.viewport),
256
+ );
306
257
  }
258
+ const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
259
+ if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
260
+ if (hasAuthorPointerEventsNone(fallback)) return [];
261
+ if (!isElementComputedVisible(fallback)) return [];
262
+ if (isFullBleedTarget(fallback, localPointer.viewport)) return [];
263
+ return [fallback];
307
264
  }
308
265
 
309
266
  function objectLike(value: unknown): object | null {