@hyperframes/studio 0.8.137 → 0.8.138

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.137",
3
+ "version": "0.8.138",
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/core": "0.8.137",
64
- "@hyperframes/sdk": "0.8.137",
65
- "@hyperframes/player": "0.8.137",
66
- "@hyperframes/parsers": "0.8.137",
67
- "@hyperframes/studio-server": "0.8.137",
68
- "@hyperframes/shader-transitions": "0.8.137"
63
+ "@hyperframes/parsers": "0.8.138",
64
+ "@hyperframes/core": "0.8.138",
65
+ "@hyperframes/player": "0.8.138",
66
+ "@hyperframes/sdk": "0.8.138",
67
+ "@hyperframes/shader-transitions": "0.8.138",
68
+ "@hyperframes/studio-server": "0.8.138"
69
69
  },
70
70
  "devDependencies": {
71
71
  "@tailwindcss/vite": "^4.3.3",
@@ -73,6 +73,7 @@
73
73
  "@types/react-dom": "19",
74
74
  "@vitejs/plugin-react": "^4.0.0",
75
75
  "chokidar": "^4.0.3",
76
+ "esbuild": "^0.25.12",
76
77
  "fake-indexeddb": "^6.2.5",
77
78
  "puppeteer-core": "^25.2.1",
78
79
  "tailwindcss": "^4.3.3",
@@ -81,7 +82,7 @@
81
82
  "vite": "^6.4.2",
82
83
  "vitest": "^4.1.11",
83
84
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.137"
85
+ "@hyperframes/producer": "0.8.138"
85
86
  },
86
87
  "peerDependencies": {
87
88
  "react": "19",
package/src/App.tsx CHANGED
@@ -357,11 +357,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
357
357
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
358
358
  });
359
359
  const frameCapture = useStableHandlers(frameCaptureResult, projectId);
360
- const {
361
- consoleErrors,
362
- setConsoleErrors,
363
- resetErrors: resetConsoleErrors,
364
- } = useConsoleErrorCapture(previewIframe);
360
+ const { consoleErrors, setConsoleErrors } = useConsoleErrorCapture(previewIframe);
365
361
  const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
366
362
  const handleToggleRecordingRef = useRef<(method?: "button" | "keyboard") => void>(() => {});
367
363
  const domEditSessionRef = useRef(domEditSession);
@@ -385,10 +381,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
385
381
  previewIframeRef.current = iframe;
386
382
  usePreviewIframeStore.getState().setIframe(iframe);
387
383
  appHotkeys.syncPreviewHotkeys(iframe);
388
- resetConsoleErrors();
389
384
  refreshPreviewDocumentVersion();
390
385
  },
391
- [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
386
+ [appHotkeys, refreshPreviewDocumentVersion],
392
387
  );
393
388
  const rightPanel = useRightPanelIntent();
394
389
  const { inspectorPanelActive, shouldShowMotionPath, shouldShowSelectedDomBounds } =
@@ -306,4 +306,29 @@ describe("LayersPanel preview promotion", () => {
306
306
  expect(collectedRootIds()).toEqual(["a"]);
307
307
  fetchSpy.mockRestore();
308
308
  });
309
+
310
+ it("a row press starts no text selection, so dragging a row leaves no letters highlighted", () => {
311
+ const doc = new Window().document;
312
+ doc.body.innerHTML = `<div data-composition-id="a"><div id="back"></div><div id="front"></div></div>`;
313
+ const iframe = document.createElement("iframe");
314
+ Object.defineProperty(iframe, "contentDocument", { value: doc });
315
+ const item = (id: string) =>
316
+ ({
317
+ key: id,
318
+ element: doc.getElementById(id),
319
+ label: id,
320
+ depth: 0,
321
+ childCount: 0,
322
+ id,
323
+ }) as unknown as DomEditLayerItem;
324
+ mocks.collect.mockReset().mockReturnValue([item("back"), item("front")]);
325
+ mocks.previewIframeRef.current = iframe;
326
+ usePreviewIframeStore.setState({ iframe });
327
+ act(() => root.render(createElement(LayersPanel)));
328
+ act(() => void iframe.dispatchEvent(new Event("load")));
329
+
330
+ const rows = [...host.querySelectorAll("[data-layer-index]")];
331
+ expect(rows).toHaveLength(2);
332
+ expect(rows.every((row) => row.closest(".select-none"))).toBe(true);
333
+ });
309
334
  });
@@ -433,7 +433,7 @@ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPa
433
433
  </div>
434
434
  <div
435
435
  ref={scrollContainerRef}
436
- className="relative min-h-0 flex-1 overflow-y-auto py-1"
436
+ className="relative min-h-0 flex-1 select-none overflow-y-auto py-1"
437
437
  onPointerMove={handleContainerPointerMove}
438
438
  onPointerUp={handleContainerPointerUp}
439
439
  onPointerCancel={handleContainerPointerUp}
@@ -0,0 +1,87 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, expect, it } from "vitest";
6
+ import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
7
+ import { useConsoleErrorCapture } from "./useConsoleErrorCapture";
8
+
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
+
11
+ let capture: ReturnType<typeof useConsoleErrorCapture>;
12
+ function Harness({ iframe }: { iframe: HTMLIFrameElement }) {
13
+ capture = useConsoleErrorCapture(iframe);
14
+ return null;
15
+ }
16
+
17
+ function previewFrame() {
18
+ const previewWindow = Object.assign(new EventTarget(), { console: { error: () => undefined } });
19
+ const iframe = document.createElement("iframe");
20
+ Object.defineProperty(iframe, "contentWindow", { get: () => previewWindow });
21
+ return { iframe, previewWindow };
22
+ }
23
+
24
+ const shown = () => capture.consoleErrors?.map((finding) => finding.message);
25
+
26
+ let root: Root | null = null;
27
+ afterEach(async () => {
28
+ await act(async () => root?.unmount());
29
+ root = null;
30
+ });
31
+
32
+ it("shows the errors a preview raised before its load, once each", async () => {
33
+ const raisedBeforeLoad = "Uncaught Error: GSAP could not load from a or b";
34
+ const { iframe, previewWindow } = previewFrame();
35
+
36
+ root = createRoot(document.createElement("div"));
37
+ await act(async () => root?.render(React.createElement(Harness, { iframe })));
38
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raisedBeforeLoad] });
39
+ await act(async () => iframe.dispatchEvent(new Event("load")));
40
+
41
+ expect(shown()).toEqual([raisedBeforeLoad]);
42
+ });
43
+
44
+ it("shows a loaded preview's errors once when the capture attaches to it twice", async () => {
45
+ const raised = "Uncaught Error: GSAP could not load from a or b";
46
+ const { iframe, previewWindow } = previewFrame();
47
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: [raised] });
48
+
49
+ root = createRoot(document.createElement("div"));
50
+ const harness = React.createElement(Harness, { iframe });
51
+ await act(async () => root?.render(React.createElement(React.StrictMode, null, harness)));
52
+
53
+ expect(shown()).toEqual([raised]);
54
+ });
55
+
56
+ it("keeps listening after reading a preview's very long error list", async () => {
57
+ const { iframe, previewWindow } = previewFrame();
58
+ const raised = Array.from({ length: 200_000 }, (_, i) => `Uncaught Error: ${i}`);
59
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: raised });
60
+
61
+ root = createRoot(document.createElement("div"));
62
+ await act(async () => root?.render(React.createElement(Harness, { iframe })));
63
+ const live = new ErrorEvent("error", { message: "Uncaught Error: raised after attach" });
64
+ await act(async () => previewWindow.dispatchEvent(live));
65
+
66
+ expect(shown()?.length).toBe(raised.length + 1);
67
+ expect(shown()?.at(-1)).toBe("Uncaught Error: raised after attach");
68
+ });
69
+
70
+ it("clears a document's errors when its load replaces it, and when the preview goes away", async () => {
71
+ const { iframe, previewWindow } = previewFrame();
72
+ const NullableHarness = ({ frame }: { frame: HTMLIFrameElement | null }) => {
73
+ capture = useConsoleErrorCapture(frame);
74
+ return null;
75
+ };
76
+ root = createRoot(document.createElement("div"));
77
+ await act(async () => root?.render(React.createElement(NullableHarness, { frame: iframe })));
78
+ await act(async () =>
79
+ previewWindow.dispatchEvent(new ErrorEvent("error", { message: "Uncaught Error: old" })),
80
+ );
81
+ Object.assign(previewWindow, { [STUDIO_PREVIEW_ERRORS]: ["Uncaught Error: new"] });
82
+ await act(async () => iframe.dispatchEvent(new Event("load")));
83
+ expect(shown()).toEqual(["Uncaught Error: new"]);
84
+
85
+ await act(async () => root?.render(React.createElement(NullableHarness, { frame: null })));
86
+ expect(capture.consoleErrors).toBeNull();
87
+ });
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { STUDIO_PREVIEW_ERRORS } from "@hyperframes/core/studio-preview-mark";
2
3
  import type { LintFinding } from "../components/LintModal";
3
4
 
4
5
  /**
@@ -16,6 +17,7 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
16
17
 
17
18
  // eslint-disable-next-line no-restricted-syntax
18
19
  useEffect(() => {
20
+ resetErrors();
19
21
  if (!previewIframe) return;
20
22
 
21
23
  let patchedWin: (Window & typeof globalThis) | null = null;
@@ -40,49 +42,43 @@ export function useConsoleErrorCapture(previewIframe: HTMLIFrameElement | null)
40
42
 
41
43
  const attachErrorCapture = () => {
42
44
  detachErrorCapture();
45
+ resetErrors();
43
46
  try {
44
47
  const win = previewIframe.contentWindow as (Window & typeof globalThis) | null;
45
48
  if (!win) return;
46
49
  if ((win as unknown as Record<string, unknown>).__hfErrorCapture) return;
47
50
  (win as unknown as Record<string, unknown>).__hfErrorCapture = true;
48
51
  patchedWin = win;
49
- origConsoleError = win.console.error.bind(win.console);
50
- win.console.error = function (...args: unknown[]) {
51
- origConsoleError!(...args);
52
- const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
53
- if (text.includes("favicon")) return;
52
+ const record = (texts: readonly string[]) => {
53
+ if (texts.length === 0) return;
54
54
  consoleErrorsRef.current = [
55
55
  ...consoleErrorsRef.current,
56
- { severity: "error", message: text },
56
+ ...texts.map((message) => ({ severity: "error" as const, message })),
57
57
  ];
58
58
  setConsoleErrors([...consoleErrorsRef.current]);
59
59
  };
60
- errorHandler = (e: ErrorEvent) => {
61
- const text = e.message || String(e);
62
- consoleErrorsRef.current = [
63
- ...consoleErrorsRef.current,
64
- { severity: "error", message: text },
65
- ];
66
- setConsoleErrors([...consoleErrorsRef.current]);
60
+ origConsoleError = win.console.error.bind(win.console);
61
+ win.console.error = function (...args: unknown[]) {
62
+ origConsoleError!(...args);
63
+ const text = args.map((a) => (a instanceof Error ? a.message : String(a))).join(" ");
64
+ if (!text.includes("favicon")) record([text]);
67
65
  };
66
+ errorHandler = (e: ErrorEvent) => record([e.message || String(e)]);
68
67
  win.addEventListener("error", errorHandler);
68
+ const raised: unknown = Reflect.get(win, STUDIO_PREVIEW_ERRORS);
69
+ if (Array.isArray(raised)) record(raised.map(String));
69
70
  } catch {
70
71
  /* same-origin only */
71
72
  }
72
73
  };
73
74
 
74
75
  attachErrorCapture();
75
- const handleLoad = () => {
76
- consoleErrorsRef.current = [];
77
- setConsoleErrors(null);
78
- attachErrorCapture();
79
- };
80
- previewIframe.addEventListener("load", handleLoad);
76
+ previewIframe.addEventListener("load", attachErrorCapture);
81
77
  return () => {
82
- previewIframe.removeEventListener("load", handleLoad);
78
+ previewIframe.removeEventListener("load", attachErrorCapture);
83
79
  detachErrorCapture();
84
80
  };
85
- }, [previewIframe]);
81
+ }, [previewIframe, resetErrors]);
86
82
 
87
- return { consoleErrors, setConsoleErrors, resetErrors };
83
+ return { consoleErrors, setConsoleErrors };
88
84
  }
@@ -45,4 +45,13 @@ body {
45
45
  */
46
46
  height: 100vh;
47
47
  height: 100dvh;
48
+ /*
49
+ * Nothing may overflow #root. Dockview parks hidden panel overlays below
50
+ * the dock, which made the document ~2x the viewport tall; body's
51
+ * overflow:hidden only blocks user scrolling, so scrollIntoView, focus()
52
+ * and find-in-page still scrolled the viewport and cut off the header
53
+ * (repro: select a clip, open the Grade group). `clip`, not `hidden`,
54
+ * which is itself programmatically scrollable.
55
+ */
56
+ overflow: clip;
48
57
  }
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import puppeteer, { type Browser } from "puppeteer-core";
8
- import { build } from "vite";
8
+ import { build, type Plugin } from "esbuild";
9
9
  import { afterAll, beforeAll, expect, it, vi } from "vitest";
10
10
  import { findSystemChrome } from "../../vite.browser";
11
11
  import { writeFixture } from "../../tests/e2e/edit-accuracy/grid.mjs";
@@ -18,20 +18,29 @@ let browser: Browser;
18
18
  let undoBundle: string;
19
19
  let softReloadBundle: string;
20
20
 
21
+ // recast imports these, and its code that uses them is dropped from the bundle. A new
22
+ // built-in fails the build instead of turning into an empty module the way Vite makes it.
23
+ const emptyNodeBuiltins: Plugin = {
24
+ name: "empty-node-builtins",
25
+ setup(build) {
26
+ build.onResolve({ filter: /^(node:)?(fs|os)$/ }, ({ path }) => ({ path, namespace: "empty" }));
27
+ build.onLoad({ filter: /.*/, namespace: "empty" }, () => ({ contents: "module.exports = {}" }));
28
+ },
29
+ };
30
+
21
31
  async function bundle(file: string, name: string): Promise<string> {
22
32
  const out = await build({
23
- configFile: false,
24
- logLevel: "silent",
25
- resolve: {
26
- alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
27
- },
28
- build: {
29
- write: false,
30
- minify: false,
31
- lib: { entry: fileURLToPath(new URL(file, import.meta.url)), formats: ["iife"], name },
32
- },
33
+ entryPoints: [fileURLToPath(new URL(file, import.meta.url))],
34
+ bundle: true,
35
+ write: false,
36
+ platform: "browser",
37
+ format: "iife",
38
+ globalName: name,
39
+ alias: { canvas: fileURLToPath(new URL("../shims/canvasBrowserStub.js", import.meta.url)) },
40
+ plugins: [emptyNodeBuiltins],
41
+ define: { "import.meta.env": "{}" },
33
42
  });
34
- return (Array.isArray(out) ? out[0]! : (out as { output: [{ code: string }] })).output[0].code;
43
+ return out.outputFiles[0]!.text;
35
44
  }
36
45
 
37
46
  beforeAll(async () => {