@hyperframes/studio 0.8.129 → 0.8.131

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.129",
3
+ "version": "0.8.131",
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.129",
64
- "@hyperframes/parsers": "0.8.129",
65
- "@hyperframes/player": "0.8.129",
66
- "@hyperframes/shader-transitions": "0.8.129",
67
- "@hyperframes/studio-server": "0.8.129",
68
- "@hyperframes/sdk": "0.8.129"
63
+ "@hyperframes/core": "0.8.131",
64
+ "@hyperframes/parsers": "0.8.131",
65
+ "@hyperframes/player": "0.8.131",
66
+ "@hyperframes/shader-transitions": "0.8.131",
67
+ "@hyperframes/sdk": "0.8.131",
68
+ "@hyperframes/studio-server": "0.8.131"
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.129"
84
+ "@hyperframes/producer": "0.8.131"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -21,12 +21,12 @@ const mocks = vi.hoisted(() => ({
21
21
  }));
22
22
 
23
23
  vi.mock("../../contexts/StudioContext", () => ({
24
- useStudioShellContext: () => ({
24
+ useStudioShellContextOptional: () => ({
25
25
  previewIframeRef: mocks.previewIframeRef,
26
26
  activeCompPath: "index.html",
27
27
  showToast: mocks.showToast,
28
28
  }),
29
- useStudioPlaybackContext: () => ({
29
+ useStudioPlaybackContextOptional: () => ({
30
30
  refreshKey: 0,
31
31
  compositionLoading: true,
32
32
  timelineElements: [],
@@ -8,7 +8,12 @@ import {
8
8
  resolveDomEditSelection,
9
9
  type DomEditLayerItem,
10
10
  } from "./domEditing";
11
- import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
11
+ import {
12
+ useStudioPlaybackContextOptional,
13
+ useStudioShellContextOptional,
14
+ type StudioPlaybackValue,
15
+ type StudioShellValue,
16
+ } from "../../contexts/StudioContext";
12
17
  import { useDomEditContext } from "../../contexts/DomEditContext";
13
18
  import { usePlayerStore, liveTime } from "../../player";
14
19
  import {
@@ -94,11 +99,36 @@ interface CollapsedState {
94
99
  [key: string]: boolean;
95
100
  }
96
101
 
102
+ /** Studio state the panel reads; passed by hosts outside Studio's providers. Keep previewIframeRef stable,
103
+ * and bump refreshKey when the preview document is rebuilt without an iframe load. */
104
+ export type LayersPanelHost = Pick<
105
+ StudioShellValue,
106
+ "previewIframeRef" | "activeCompPath" | "showToast"
107
+ > &
108
+ Pick<StudioPlaybackValue, "timelineElements" | "isPlaying"> &
109
+ Partial<Pick<StudioPlaybackValue, "refreshKey" | "compositionLoading">>;
110
+
111
+ function useLayersPanelHost(host: LayersPanelHost | undefined): LayersPanelHost {
112
+ const shell = useStudioShellContextOptional();
113
+ const playback = useStudioPlaybackContextOptional();
114
+ if (host) return host;
115
+ if (!shell || !playback) {
116
+ throw new Error("LayersPanel needs a host prop outside Studio's shell and playback providers");
117
+ }
118
+ return { ...shell, ...playback };
119
+ }
120
+
97
121
  // fallow-ignore-next-line complexity
98
- export const LayersPanel = memo(function LayersPanel() {
99
- const { previewIframeRef, activeCompPath, showToast } = useStudioShellContext();
100
- const { refreshKey, compositionLoading, timelineElements, isPlaying } =
101
- useStudioPlaybackContext();
122
+ export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPanelHost }) {
123
+ const {
124
+ previewIframeRef,
125
+ activeCompPath,
126
+ showToast,
127
+ refreshKey,
128
+ compositionLoading,
129
+ timelineElements,
130
+ isPlaying,
131
+ } = useLayersPanelHost(host);
102
132
  const currentTime = usePlayerStore((s) => s.currentTime);
103
133
  // Flashless z commits (canvas menu, timeline lane-drag z-sync) mutate iframe
104
134
  // z-indexes with no reload and no refreshKey bump — while paused, nothing
@@ -145,8 +145,8 @@ function useMirrorLaneMoveCommit(): (
145
145
  (selectionKey, coalesceKey, resolveMove) => {
146
146
  const els = elementsRef.current;
147
147
  const element = selectionKey ? els.find((e) => (e.key ?? e.id) === selectionKey) : undefined;
148
- // Not a timeline clip (canvas-only decoration) → z-only action, unchanged.
149
- if (!element) return Promise.resolve(false);
148
+ // Canvas-only decoration, or no handler to save a lane move (no TimelineEditProvider): z-only.
149
+ if (!element || !onMoveElements) return Promise.resolve(false);
150
150
 
151
151
  const move = resolveMove(element, els);
152
152
  if (!move) return Promise.resolve(false);
@@ -62,7 +62,7 @@ async function studio({ withHistory = true } = {}) {
62
62
  const file = () => readFileSync(join(dir, "index.html"), "utf8");
63
63
  const save = (content: string) => writeFileSync(join(dir, "index.html"), content);
64
64
  const readFile = async (path: string) => readFileSync(join(dir, path), "utf8");
65
- return { dir, hook: () => hook, file, save, readFile };
65
+ return { dir, history, hook: () => hook, file, save, readFile };
66
66
  }
67
67
 
68
68
  it("an edit Studio saved is undone and redone by the project's history, with the preview's before and after", async () => {
@@ -411,6 +411,30 @@ it("a key one page holds does not join another page's edit under the same key",
411
411
  expect(file()).toBe("B");
412
412
  });
413
413
 
414
+ it("a host's write claimed under claimKey joins Studio's save under the same key: one undo takes back both", async () => {
415
+ const { dir, history, hook, file, save, readFile } = await studio();
416
+ const key = "drop:1";
417
+ writeFileSync(join(dir, "clip.mp4"), "media");
418
+ await history.claim({ kind: "person", name: "You" }, "Dropped on timeline", ["clip.mp4"], {
419
+ coalesceKey: hook().claimKey(key),
420
+ idleMs: Infinity,
421
+ });
422
+ save("B");
423
+ await act(() =>
424
+ hook().recordEdit({
425
+ label: "Dropped on timeline",
426
+ coalesceKey: key,
427
+ coalesceMs: Infinity,
428
+ files: { "index.html": { before: "A", after: "B" } },
429
+ }),
430
+ );
431
+
432
+ await act(() => hook().undo({ readFile }));
433
+
434
+ expect(file()).toBe("A");
435
+ expect(existsSync(join(dir, "clip.mp4"))).toBe(false);
436
+ });
437
+
414
438
  it("an undo before the history view shows a drag's held claim still waits on the files it wrote", async () => {
415
439
  const { hook, save, readFile } = await studio();
416
440
  const server = globalThis.fetch;
@@ -209,6 +209,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
209
209
  projectIdRef.current = projectId;
210
210
  const [own] = useState(createOwnHistory);
211
211
  const [pageKeyScope] = useState(generateId);
212
+ const claimKey = useCallback((key: string) => `${pageKeyScope}:${key}`, [pageKeyScope]);
212
213
 
213
214
  const refresh = useCallback(async () => {
214
215
  if (!projectId) return;
@@ -246,7 +247,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
246
247
  paths,
247
248
  overwrote: await overwroteVersions(files),
248
249
  ...(coalesceKey && {
249
- coalesceKey: `${pageKeyScope}:${coalesceKey}`,
250
+ coalesceKey: claimKey(coalesceKey),
250
251
  idleMs: coalesceMs ?? DEFAULT_COALESCE_MS,
251
252
  }),
252
253
  });
@@ -258,7 +259,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
258
259
  heldClaimRef.current = claimed && coalesceKey ? { paths, at: Date.now() } : null;
259
260
  await refresh();
260
261
  },
261
- [projectId, refresh, own, pageKeyScope],
262
+ [projectId, refresh, own, claimKey],
262
263
  );
263
264
 
264
265
  const step = useCallback(
@@ -341,6 +342,7 @@ export function usePersistentEditHistory({ projectId }: UsePersistentEditHistory
341
342
  redoPaths: view.forward?.paths ?? [],
342
343
  state,
343
344
  recordEdit,
345
+ claimKey,
344
346
  undo,
345
347
  redo,
346
348
  predict: own.predict,
package/src/index.ts CHANGED
@@ -206,6 +206,8 @@ export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence"
206
206
  export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
207
207
  export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyContext";
208
208
  export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
209
+ export { LayersPanel } from "./components/editor/LayersPanel";
210
+ export type { LayersPanelHost } from "./components/editor/LayersPanel";
209
211
  export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
210
212
  export { useDomEditZOrder } from "./components/editor/useDomEditZOrder";
211
213
  export type { DomEditZOrder } from "./components/editor/useDomEditZOrder";
@@ -0,0 +1,115 @@
1
+ // @vitest-environment happy-dom
2
+ // Mounts the Layers panel by package name with no Studio shell, as a host app does.
3
+ import { act, type ReactNode } from "react";
4
+ import { afterEach, expect, it, vi } from "vitest";
5
+ import {
6
+ DomEditProvider,
7
+ LayersPanel,
8
+ TimelineEditProvider,
9
+ usePlayerStore,
10
+ type LayersPanelHost,
11
+ } from "@hyperframes/studio";
12
+ import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
13
+ import { deriveTimelineStoreKeyForDomId } from "./player/lib/timelineElementHelpers";
14
+
15
+ installReactActEnvironment();
16
+
17
+ const flush = () => act(async () => {});
18
+ const rowLabels = () =>
19
+ [...document.querySelectorAll("[data-layer-index]")].map((row) => row.textContent);
20
+ const clip = (id: string, track: number) => {
21
+ const key = deriveTimelineStoreKeyForDomId(id, "index.html");
22
+ return { id: key, key, domId: id, tag: "div", start: 0, duration: 4, track };
23
+ };
24
+ const bgTrack = () => usePlayerStore.getState().elements.find((e) => e.domId === "bg")?.track;
25
+
26
+ afterEach(() => {
27
+ usePlayerStore.setState({ elements: [] });
28
+ document.body.innerHTML = "";
29
+ });
30
+
31
+ /** The host's preview (title in front of bg, rows to match) and its edit session, mounted around the panel. */
32
+ async function mountPanel(wrap: (panel: ReactNode) => ReactNode = (panel) => panel) {
33
+ const iframe = document.createElement("iframe");
34
+ document.body.appendChild(iframe);
35
+ const doc = iframe.contentDocument!;
36
+ doc.body.innerHTML =
37
+ '<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
38
+ usePlayerStore.setState({ elements: [clip("title", 0), clip("bg", 1)] });
39
+ const session = {
40
+ domEditSelection: null,
41
+ activeGroupElement: null,
42
+ applyDomSelection: vi.fn(),
43
+ updateDomEditHoverSelection: vi.fn(),
44
+ handleDomZIndexReorderCommit: vi.fn(async () => ({
45
+ durable: true,
46
+ allMatched: true,
47
+ changed: true,
48
+ })),
49
+ setActiveGroupElement: vi.fn(),
50
+ } as unknown as Parameters<typeof DomEditProvider>[0]["value"];
51
+ const host: LayersPanelHost = {
52
+ previewIframeRef: { current: iframe },
53
+ activeCompPath: null,
54
+ showToast: vi.fn(),
55
+ timelineElements: usePlayerStore.getState().elements,
56
+ isPlaying: false,
57
+ };
58
+ const root = mountReactHarness(
59
+ <DomEditProvider value={session}>{wrap(<LayersPanel host={host} />)}</DomEditProvider>,
60
+ );
61
+ await flush();
62
+ return { doc, session, root };
63
+ }
64
+
65
+ /** Drags the back row (bg) above the front one. */
66
+ async function dragBackRowToFront() {
67
+ const rows = document.querySelectorAll<HTMLElement>("[data-layer-index]");
68
+ rows.forEach((row, i) =>
69
+ vi.spyOn(row, "getBoundingClientRect").mockReturnValue(new DOMRect(0, i * 24, 200, 24)),
70
+ );
71
+ const pointer = (type: string, clientY: number) =>
72
+ new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX: 10, clientY });
73
+ const list = rows[0]!.parentElement!;
74
+ list.setPointerCapture = () => {};
75
+ act(() => void rows[1]!.dispatchEvent(pointer("pointerdown", 36)));
76
+ act(() => {
77
+ list.dispatchEvent(pointer("pointermove", 20));
78
+ list.dispatchEvent(pointer("pointermove", 2));
79
+ });
80
+ await act(async () => void list.dispatchEvent(pointer("pointerup", 2)));
81
+ await flush();
82
+ }
83
+
84
+ it("lists, selects and reorders the host's preview layers through the host's session", async () => {
85
+ const { doc, session, root } = await mountPanel();
86
+
87
+ expect(rowLabels()).toEqual(["DiTitle", "DiBg"]);
88
+
89
+ const rows = document.querySelectorAll<HTMLElement>("[data-layer-index]");
90
+ await act(async () => rows[0]!.click());
91
+ expect(session.applyDomSelection).toHaveBeenCalledWith(
92
+ expect.objectContaining({ element: doc.getElementById("title") }),
93
+ );
94
+
95
+ await dragBackRowToFront();
96
+ expect(session.handleDomZIndexReorderCommit).toHaveBeenCalledTimes(1);
97
+ const [entries] = vi.mocked(session.handleDomZIndexReorderCommit).mock.calls[0]!;
98
+ const z = Object.fromEntries(entries.map((e) => [e.element.id, e.zIndex]));
99
+ expect(z.bg).toBeGreaterThan(Number(doc.getElementById("title")!.style.zIndex));
100
+ // No TimelineEditProvider, so nothing can save a lane move: the row stays where the file says.
101
+ expect(bgTrack()).toBe(1);
102
+ await act(async () => root.unmount());
103
+ });
104
+
105
+ it("mirrors the reorder into the timeline rows through the host's move handler", async () => {
106
+ const onMoveElements = vi.fn(async () => {});
107
+ const { root } = await mountPanel((panel) => (
108
+ <TimelineEditProvider value={{ onMoveElements }}>{panel}</TimelineEditProvider>
109
+ ));
110
+
111
+ await dragBackRowToFront();
112
+ expect(onMoveElements).toHaveBeenCalledTimes(1);
113
+ expect(bgTrack()).not.toBe(1);
114
+ await act(async () => root.unmount());
115
+ });