@hyperframes/studio 0.8.100 → 0.8.101

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.
Files changed (39) hide show
  1. package/dist/assets/{hyperframes-player-fWt87gyh.js → hyperframes-player-CFJLrHRF.js} +1 -1
  2. package/dist/assets/{index-D6TjRAvz.js → index-BBNVnnyH.js} +114 -114
  3. package/dist/assets/{index-DofdzdxJ.js → index-C33EDBL2.js} +1 -1
  4. package/dist/assets/{index-D34Oy5DN.js → index-CJOzbslX.js} +1 -1
  5. package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
  6. package/dist/chunk-7X4XRP3L.js.map +1 -0
  7. package/dist/index.d.ts +91 -21
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +403 -226
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/App.tsx +2 -0
  14. package/src/components/dock/Dock.test.tsx +78 -3
  15. package/src/components/dock/Dock.tsx +45 -18
  16. package/src/components/dock/dockLayout.ts +27 -14
  17. package/src/components/dock/dockLayoutSchema.ts +11 -4
  18. package/src/components/dock/dockLayoutStore.ts +4 -2
  19. package/src/hooks/appHotkeysDispatch.ts +1 -7
  20. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
  21. package/src/hooks/useAppHotkeys.ts +18 -45
  22. package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
  23. package/src/hooks/useEditHistoryActions.test.tsx +67 -9
  24. package/src/hooks/useEditHistoryActions.ts +36 -8
  25. package/src/hooks/usePersistentEditHistory.test.ts +46 -0
  26. package/src/hooks/usePersistentEditHistory.ts +101 -19
  27. package/src/hooks/usePreviewPersistence.ts +25 -2
  28. package/src/hooks/useStudioExternalFileChanges.ts +7 -3
  29. package/src/index.ts +4 -0
  30. package/src/player/components/timelineDragDrop.test.tsx +61 -5
  31. package/src/player/components/timelineDragDrop.ts +29 -8
  32. package/src/player/components/useTimelineProviderState.tsx +1 -0
  33. package/src/utils/domEditSaveQueue.ts +7 -2
  34. package/src/utils/gsapSoftReload.ts +1 -1
  35. package/src/utils/gsapUndoRestore.test.ts +46 -0
  36. package/src/utils/gsapUndoRestore.ts +166 -91
  37. package/src/utils/studioPendingEdits.ts +14 -5
  38. package/src/utils/studioUiPreferences.ts +8 -6
  39. package/dist/chunk-U3EE4ACX.js.map +0 -1
package/dist/player.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  Player
3
- } from "./chunk-U3EE4ACX.js";
3
+ } from "./chunk-7X4XRP3L.js";
4
4
  import "./chunk-6GAO75IU.js";
5
5
  import "./chunk-S65AQH6M.js";
6
6
  import "./chunk-PZ5AY32C.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.100",
3
+ "version": "0.8.101",
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.100",
64
- "@hyperframes/sdk": "0.8.100",
65
- "@hyperframes/parsers": "0.8.100",
66
- "@hyperframes/player": "0.8.100",
67
- "@hyperframes/shader-transitions": "0.8.100",
68
- "@hyperframes/studio-server": "0.8.100"
63
+ "@hyperframes/parsers": "0.8.101",
64
+ "@hyperframes/sdk": "0.8.101",
65
+ "@hyperframes/shader-transitions": "0.8.101",
66
+ "@hyperframes/studio-server": "0.8.101",
67
+ "@hyperframes/player": "0.8.101",
68
+ "@hyperframes/core": "0.8.101"
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.100"
84
+ "@hyperframes/producer": "0.8.101"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
package/src/App.tsx CHANGED
@@ -155,6 +155,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
155
155
  previewPersistence,
156
156
  pendingTimelineEditPathRef,
157
157
  reloadPreview,
158
+ onOutsideChange: editHistory.noteOutsideChange,
158
159
  });
159
160
  const invalidateGsapCacheRef = useRef<() => void>(() => {});
160
161
  const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
@@ -254,6 +255,7 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
254
255
  writeProjectFile: fileManager.writeProjectFile,
255
256
  showToast,
256
257
  syncHistoryPreviewAfterApply: previewPersistence.syncHistoryPreviewAfterApply,
258
+ showHistoryRestoreNow: previewPersistence.showHistoryRestoreNow,
257
259
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
258
260
  handleCopy,
259
261
  handlePaste,
@@ -48,23 +48,30 @@ class ResizeObserverStub {
48
48
 
49
49
  vi.mock("./dockLayout", async (importOriginal) => {
50
50
  const actual = await importOriginal<typeof import("./dockLayout")>();
51
- return { ...actual, applySideMinimums: vi.fn(actual.applySideMinimums) };
51
+ return {
52
+ ...actual,
53
+ applySideMinimums: vi.fn(actual.applySideMinimums),
54
+ addRegisteredPanel: vi.fn(actual.addRegisteredPanel),
55
+ };
52
56
  });
53
57
  const applySideMinimums = vi.mocked(dockLayout.applySideMinimums);
58
+ const addRegisteredPanel = vi.mocked(dockLayout.addRegisteredPanel);
54
59
 
55
60
  let root: Root | null = null;
56
61
 
57
62
  function mount(
58
63
  projectId: string | null,
59
64
  titles: Partial<Record<(typeof PANEL_IDS)[number], string>> = {},
65
+ options: Omit<ComponentProps<typeof Dock.Root>, "projectId" | "children"> = {},
60
66
  ) {
61
67
  const host = document.createElement("div");
62
68
  document.body.append(host);
63
69
  root = createRoot(host);
64
70
  act(() => {
65
71
  root?.render(
66
- <Dock.Root projectId={projectId}>
67
- {PANEL_IDS.map((id) => (
72
+ <Dock.Root projectId={projectId} {...options}>
73
+ <Dock.WindowMenu />
74
+ {(options.panels ?? PANEL_IDS).map((id) => (
68
75
  <Dock.Panel key={id} id={id} title={titles[id]}>
69
76
  <div data-testid={`content-${id}`}>{id}</div>
70
77
  </Dock.Panel>
@@ -173,6 +180,16 @@ describe("Dock on React 19", () => {
173
180
  expect(useDockLayoutStore.getState().visiblePanels.has("compositions")).toBe(true);
174
181
  });
175
182
 
183
+ it("reopens a panel whose usual neighbour is only closed exactly as before: no new position", () => {
184
+ mount("p1");
185
+ const { closePanel, togglePanel } = useDockLayoutStore.getState();
186
+ for (const id of ["compositions", "assets", "code", "catalog"] as const)
187
+ act(() => closePanel(id));
188
+ addRegisteredPanel.mockClear();
189
+ act(() => togglePanel("assets"));
190
+ expect(addRegisteredPanel).toHaveBeenCalledWith(expect.anything(), "assets", undefined);
191
+ });
192
+
176
193
  it("reopens the timeline as its own group, never as a tab of the preview", () => {
177
194
  mount("p1");
178
195
  act(() => useDockLayoutStore.getState().closePanel("timeline"));
@@ -239,6 +256,64 @@ describe("Dock wiring", () => {
239
256
  });
240
257
  });
241
258
 
259
+ // A host app mounts a subset of Studio's panels under its own storage key and width.
260
+ describe("a host's dock", () => {
261
+ const panels = ["preview", "timeline", "assets", "renders"] as const;
262
+ const hostDock = { panels, storageKey: "host-dock" };
263
+
264
+ it("builds only its panels, a side column each, and its Window menu lists only them", () => {
265
+ const host = mount("p1", {}, hostDock);
266
+ expect(useDockLayoutStore.getState().openPanels).toEqual(new Set(panels));
267
+ expect(useDockLayoutStore.getState().visiblePanels).toEqual(new Set(panels));
268
+ expect(dockApi?.getPanel("assets")?.group.id).not.toBe(dockApi?.getPanel("renders")?.group.id);
269
+ const menu = [...host.querySelectorAll("button")].find((b) => b.textContent === "Window");
270
+ act(() => menu?.click());
271
+ const items = [...host.querySelectorAll('[role="menuitemcheckbox"]')].map((i) => i.textContent);
272
+ expect(items).toEqual(["✓Preview", "✓Timeline", "✓Assets", "✓Renders"]);
273
+ });
274
+
275
+ it("keeps its layout alone under its own key, never in Studio's preferences", () => {
276
+ mount("p1", {}, hostDock);
277
+ act(() => useDockLayoutStore.getState().closePanel("renders"));
278
+ act(() => {
279
+ vi.advanceTimersByTime(400);
280
+ });
281
+ const stored = readStudioUiPreferences(undefined, "p1", "host-dock").dockLayout;
282
+ expect(Object.keys(stored?.panels ?? {}).sort()).toEqual(["assets", "preview", "timeline"]);
283
+ expect(readStudioUiPreferences(undefined, "p1").dockLayout).toBeUndefined();
284
+ });
285
+
286
+ it("falls back to its default when a stored layout names a panel it lacks", () => {
287
+ mount("p1");
288
+ act(() => {
289
+ vi.advanceTimersByTime(400);
290
+ });
291
+ const studio = readStudioUiPreferences(undefined, "p1").dockLayout;
292
+ act(() => root?.unmount());
293
+ root = null;
294
+ localStorage.setItem("host-dock:p1", JSON.stringify({ dockLayout: studio }));
295
+ mount("p1", {}, hostDock);
296
+ expect(useDockLayoutStore.getState().openPanels).toEqual(new Set(panels));
297
+ });
298
+
299
+ it("reopens a panel beside the preview when the panel it reopens near is not in the dock", () => {
300
+ mount("p1", {}, hostDock);
301
+ act(() => useDockLayoutStore.getState().closePanel("assets"));
302
+ act(() => useDockLayoutStore.getState().togglePanel("assets"));
303
+ expect(useDockLayoutStore.getState().visiblePanels.has("assets")).toBe(true);
304
+ });
305
+
306
+ it("sizes its sides against its own width, not the window's", () => {
307
+ mount(null, {}, { ...hostDock, dockWidth: () => 700 });
308
+ applySideMinimums.mockClear();
309
+ const dockObservers = [...liveObservers].filter(({ target }) =>
310
+ target?.classList.contains("hf-dock"),
311
+ );
312
+ act(() => dockObservers.forEach(({ callback }) => callback()));
313
+ expect(applySideMinimums).toHaveBeenCalledWith(expect.anything(), 700);
314
+ });
315
+ });
316
+
242
317
  describe("parseDockLayout", () => {
243
318
  it("rejects shapes that are not a dock layout", () => {
244
319
  expect(parseDockLayout(null)).toBeNull();
@@ -20,7 +20,7 @@ import { DockStripActions } from "./DockStripActions";
20
20
  import { DockTab } from "./DockTab";
21
21
  import { installTabFill } from "./dockTabFill";
22
22
  import { addRegisteredPanel, applySideMinimums, buildEditLayout } from "./dockLayout";
23
- import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
23
+ import { DOCK_PANEL_COMPONENT, parseDockLayout } from "./dockLayoutSchema";
24
24
  import { useDockLayoutStore, type DockController, type DockSnapshot } from "./dockLayoutStore";
25
25
  import {
26
26
  PANEL_DEFINITIONS,
@@ -85,10 +85,20 @@ function snapshot(api: DockviewApi): DockSnapshot {
85
85
  };
86
86
  }
87
87
 
88
- function createController(api: DockviewApi): DockController {
88
+ interface DockOptions {
89
+ panels: readonly PanelId[];
90
+ storageKey?: string | undefined;
91
+ dockWidth: () => number;
92
+ }
93
+
94
+ function createController(api: DockviewApi, options: DockOptions): DockController {
89
95
  const open = (id: PanelId) => {
90
96
  if (api.getPanel(id)) return;
91
- const { zone, reopen } = PANEL_DEFINITIONS[id];
97
+ const { zone, reopen: preferred } = PANEL_DEFINITIONS[id];
98
+ const reopen: PanelDefinition["reopen"] =
99
+ zone === "center" || options.panels.includes(preferred.near)
100
+ ? preferred
101
+ : { near: "preview", direction: zone };
92
102
  // Side columns are tab groups; preview and timeline are separate groups in the centre.
93
103
  const sibling =
94
104
  zone === "center"
@@ -114,12 +124,13 @@ function createController(api: DockviewApi): DockController {
114
124
  },
115
125
  setTitle: (id, title) => api.getPanel(id)?.api.setTitle(title),
116
126
  setGroupVisible: (id, visible) => api.getPanel(id)?.group.api.setVisible(visible),
117
- reset: () => buildEditLayout(api, window.innerWidth),
127
+ reset: () => buildEditLayout(api, options.dockWidth(), options.panels),
118
128
  };
119
129
  }
120
130
 
121
- function restoreOrBuild(api: DockviewApi, projectId: string | null) {
122
- const stored = readStudioUiPreferences(undefined, projectId).dockLayout;
131
+ function restoreOrBuild(api: DockviewApi, projectId: string | null, options: DockOptions) {
132
+ const { dockLayout } = readStudioUiPreferences(undefined, projectId, options.storageKey);
133
+ const stored = parseDockLayout(dockLayout, options.panels);
123
134
  if (stored) {
124
135
  try {
125
136
  api.fromJSON(stored);
@@ -128,10 +139,24 @@ function restoreOrBuild(api: DockviewApi, projectId: string | null) {
128
139
  /* a layout the schema accepted but dockview cannot load: start over */
129
140
  }
130
141
  }
131
- buildEditLayout(api, window.innerWidth);
142
+ buildEditLayout(api, options.dockWidth(), options.panels);
132
143
  }
133
144
 
134
- function Root({ projectId, children }: { projectId: string | null; children: ReactNode }) {
145
+ const windowWidth = () => window.innerWidth;
146
+
147
+ function Root({
148
+ projectId,
149
+ panels = PANEL_IDS,
150
+ storageKey,
151
+ dockWidth = windowWidth,
152
+ children,
153
+ }: {
154
+ projectId: string | null;
155
+ panels?: readonly PanelId[];
156
+ storageKey?: string;
157
+ dockWidth?: () => number;
158
+ children: ReactNode;
159
+ }) {
135
160
  const [slots, setSlots] = useState<Slots>({});
136
161
  const registerSlot = useCallback((id: PanelId, element: HTMLElement | null) => {
137
162
  setSlots((prev) => {
@@ -149,16 +174,17 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
149
174
  const onReady = useCallback(
150
175
  ({ api }: DockviewReadyEvent) => {
151
176
  disposeRef.current();
152
- restoreOrBuild(api, projectId);
153
- applySideMinimums(api);
177
+ const options = { panels, storageKey, dockWidth };
178
+ restoreOrBuild(api, projectId, options);
179
+ applySideMinimums(api, dockWidth());
154
180
  const root = api.groups[0]?.element.closest<HTMLElement>(".hf-dock");
155
181
  const disposeAccessibility = root ? installDockAccessibility(api, root) : () => {};
156
182
  const disposeTabFill = root ? installTabFill(api, root) : () => {};
157
- // The dock spans the window (buildEditLayout sizes against it too); its own box lags a resize.
158
- const resizeObserver = new ResizeObserver(() => applySideMinimums(api, window.innerWidth));
183
+ // Sides size against dockWidth, the window by default; the dock's own box lags a resize.
184
+ const resizeObserver = new ResizeObserver(() => applySideMinimums(api, dockWidth()));
159
185
  if (root) resizeObserver.observe(root);
160
186
  const store = useDockLayoutStore.getState();
161
- store.attach(createController(api));
187
+ store.attach(createController(api, options), panels);
162
188
  store.sync(snapshot(api));
163
189
  const pending = store.takePendingActivation();
164
190
  if (pending) api.getPanel(pending)?.api.setActive();
@@ -167,7 +193,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
167
193
  const persist = () => {
168
194
  clearTimeout(timer);
169
195
  timer = setTimeout(() => {
170
- writeStudioUiPreferences({ dockLayout: api.toJSON() }, undefined, projectId);
196
+ writeStudioUiPreferences({ dockLayout: api.toJSON() }, undefined, projectId, storageKey);
171
197
  }, PERSIST_DEBOUNCE_MS);
172
198
  };
173
199
  // dockview does not fire onDidLayoutChange for add/remove/activate,
@@ -178,11 +204,11 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
178
204
  };
179
205
  const subscriptions = [
180
206
  api.onDidAddPanel(() => {
181
- applySideMinimums(api);
207
+ applySideMinimums(api, dockWidth());
182
208
  onDockChange();
183
209
  }),
184
210
  api.onDidMovePanel(() => {
185
- applySideMinimums(api);
211
+ applySideMinimums(api, dockWidth());
186
212
  onDockChange();
187
213
  }),
188
214
  api.onDidRemovePanel(onDockChange),
@@ -198,7 +224,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea
198
224
  useDockLayoutStore.getState().detach();
199
225
  };
200
226
  },
201
- [projectId],
227
+ [projectId, panels, storageKey, dockWidth],
202
228
  );
203
229
 
204
230
  return (
@@ -235,6 +261,7 @@ function Panel({ id, title, children }: { id: PanelId; title?: string; children:
235
261
 
236
262
  function WindowMenu() {
237
263
  const openPanels = useDockLayoutStore((state) => state.openPanels);
264
+ const panels = useDockLayoutStore((state) => state.panels);
238
265
  const togglePanel = useDockLayoutStore((state) => state.togglePanel);
239
266
  const resetLayout = useDockLayoutStore((state) => state.resetLayout);
240
267
  const [open, setOpen] = useState(false);
@@ -265,7 +292,7 @@ function WindowMenu() {
265
292
  role="menu"
266
293
  className="absolute right-0 top-8 z-50 w-44 rounded-md border border-neutral-800 bg-neutral-900 py-1 shadow-lg"
267
294
  >
268
- {PANEL_IDS.map((id) => (
295
+ {panels.map((id) => (
269
296
  <button
270
297
  key={id}
271
298
  type="button"
@@ -1,6 +1,6 @@
1
1
  import type { Direction, DockviewApi } from "dockview-react";
2
2
  import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema";
3
- import { PANEL_DEFINITIONS, isPanelId, type PanelId } from "./panelRegistry";
3
+ import { PANEL_DEFINITIONS, PANEL_IDS, isPanelId, type PanelId } from "./panelRegistry";
4
4
 
5
5
  const MIN_PREVIEW_W = 360;
6
6
  const MIN_PREVIEW_H = 200;
@@ -8,6 +8,8 @@ const MIN_TIMELINE_H = 100;
8
8
  const MIN_SIDE_W = 200;
9
9
  const MIN_SIDE_W_FLOOR = 120;
10
10
  const DEFAULT_TIMELINE_H = 360;
11
+ const DEFAULT_LEFT = ["compositions", "assets", "code", "catalog"] as const;
12
+ const DEFAULT_RIGHT = ["design", "layers", "renders", "variables"] as const;
11
13
 
12
14
  /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */
13
15
  export function defaultSideWidths(viewportWidth: number) {
@@ -82,24 +84,35 @@ export function addRegisteredPanel(
82
84
  });
83
85
  }
84
86
 
87
+ function addSideColumn(
88
+ api: DockviewApi,
89
+ ids: readonly PanelId[],
90
+ panels: readonly PanelId[],
91
+ direction: "left" | "right",
92
+ ) {
93
+ const [first, ...rest] = ids.filter((id) => panels.includes(id));
94
+ if (!first) return undefined;
95
+ addRegisteredPanel(api, first, { referencePanel: "preview", direction });
96
+ for (const id of rest)
97
+ addRegisteredPanel(api, id, { referencePanel: first, direction: "within" });
98
+ api.getPanel(first)?.api.setActive();
99
+ return first;
100
+ }
101
+
85
102
  /** The default Edit layout: [library | preview | inspector] over a full-width timeline. */
86
- export function buildEditLayout(api: DockviewApi, viewportWidth: number) {
103
+ export function buildEditLayout(
104
+ api: DockviewApi,
105
+ viewportWidth: number,
106
+ panels: readonly PanelId[] = PANEL_IDS,
107
+ ) {
87
108
  api.clear();
88
109
  const widths = defaultSideWidths(viewportWidth);
89
110
  addRegisteredPanel(api, "preview");
90
111
  addRegisteredPanel(api, "timeline", { referencePanel: "preview", direction: "below" });
91
- addRegisteredPanel(api, "compositions", { referencePanel: "preview", direction: "left" });
92
- for (const id of ["assets", "code", "catalog"] as const) {
93
- addRegisteredPanel(api, id, { referencePanel: "compositions", direction: "within" });
94
- }
95
- addRegisteredPanel(api, "design", { referencePanel: "preview", direction: "right" });
96
- for (const id of ["layers", "renders", "variables"] as const) {
97
- addRegisteredPanel(api, id, { referencePanel: "design", direction: "within" });
98
- }
99
- api.getPanel("compositions")?.api.setActive();
100
- api.getPanel("design")?.api.setActive();
112
+ const left = addSideColumn(api, DEFAULT_LEFT, panels, "left");
113
+ const right = addSideColumn(api, DEFAULT_RIGHT, panels, "right");
101
114
  applySideMinimums(api, viewportWidth);
102
- api.getPanel("compositions")?.group.api.setSize({ width: widths.left });
103
- api.getPanel("design")?.group.api.setSize({ width: widths.right });
115
+ if (left) api.getPanel(left)?.group.api.setSize({ width: widths.left });
116
+ if (right) api.getPanel(right)?.group.api.setSize({ width: widths.right });
104
117
  api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H });
105
118
  }
@@ -1,5 +1,5 @@
1
1
  import type { SerializedDockview } from "dockview-react";
2
- import { isPanelId } from "./panelRegistry";
2
+ import { PANEL_IDS, isPanelId, type PanelId } from "./panelRegistry";
3
3
 
4
4
  export const DOCK_PANEL_COMPONENT = "panel";
5
5
 
@@ -25,7 +25,10 @@ function hasGridShape(grid: Record<string, unknown>): boolean {
25
25
  return oriented && typeof grid.width === "number" && typeof grid.height === "number";
26
26
  }
27
27
 
28
- function panelsAreRegistered(panels: Record<string, unknown>): boolean {
28
+ function panelsAreRegistered(
29
+ panels: Record<string, unknown>,
30
+ allowed: readonly PanelId[],
31
+ ): boolean {
29
32
  const ids = Object.keys(panels);
30
33
  return (
31
34
  ids.length > 0 &&
@@ -33,6 +36,7 @@ function panelsAreRegistered(panels: Record<string, unknown>): boolean {
33
36
  const state = panels[id];
34
37
  return (
35
38
  isPanelId(id) &&
39
+ allowed.includes(id) &&
36
40
  isRecord(state) &&
37
41
  state.id === id &&
38
42
  state.contentComponent === DOCK_PANEL_COMPONENT
@@ -52,10 +56,13 @@ function collectFloatingViews(floating: unknown, into: unknown[]): boolean {
52
56
  }
53
57
 
54
58
  /** Parses a stored layout at the trust boundary; null (stale or foreign shape) means use the default preset. */
55
- export function parseDockLayout(value: unknown): SerializedDockview | null {
59
+ export function parseDockLayout(
60
+ value: unknown,
61
+ allowed: readonly PanelId[] = PANEL_IDS,
62
+ ): SerializedDockview | null {
56
63
  if (!isRecord(value) || !isRecord(value.grid) || !isRecord(value.panels)) return null;
57
64
  const { grid, panels } = value;
58
- if (!hasGridShape(grid) || !panelsAreRegistered(panels)) return null;
65
+ if (!hasGridShape(grid) || !panelsAreRegistered(panels, allowed)) return null;
59
66
 
60
67
  const views: unknown[] = [];
61
68
  if (!collectLeafViews(grid.root, views)) return null;
@@ -33,10 +33,11 @@ type LastActive = Partial<Record<PanelZone, PanelId>>;
33
33
 
34
34
  interface DockLayoutState extends DockSnapshot {
35
35
  controller: DockController | null;
36
+ panels: readonly PanelId[];
36
37
  lastActive: LastActive;
37
38
  /** An activation requested before the dock mounted; Dock.Root applies it on ready. */
38
39
  pendingActivation: PanelId | null;
39
- attach: (controller: DockController) => void;
40
+ attach: (controller: DockController, panels?: readonly PanelId[]) => void;
40
41
  detach: () => void;
41
42
  sync: (snapshot: DockSnapshot) => void;
42
43
  takePendingActivation: () => PanelId | null;
@@ -49,12 +50,13 @@ interface DockLayoutState extends DockSnapshot {
49
50
 
50
51
  export const useDockLayoutStore = create<DockLayoutState>((set, get) => ({
51
52
  controller: null,
53
+ panels: PANEL_IDS,
52
54
  openPanels: new Set(PANEL_IDS),
53
55
  visiblePanels: new Set(PANEL_IDS),
54
56
  activePanel: null,
55
57
  lastActive: {},
56
58
  pendingActivation: null,
57
- attach: (controller) => set({ controller }),
59
+ attach: (controller, panels = PANEL_IDS) => set({ controller, panels }),
58
60
  detach: () => set({ controller: null, lastActive: {} }),
59
61
  sync: (snapshot) =>
60
62
  set((state) => {
@@ -14,13 +14,7 @@ import { STUDIO_PLAIN_KEYS } from "../player/components/studioShortcuts";
14
14
  // following useTimelineDeleteOps's precedent. Pure functions, no hooks — the
15
15
  // hook still owns the actual keydown listeners and calls into these.
16
16
 
17
- /** Exported so useAppHotkeys's own history-only preview listener can reuse
18
- * the same undo/redo key arbitration without duplicating it. */
19
- export function handleUndoRedoKey(
20
- event: KeyboardEvent,
21
- onUndo: () => void,
22
- onRedo: () => void,
23
- ): boolean {
17
+ function handleUndoRedoKey(event: KeyboardEvent, onUndo: () => void, onRedo: () => void): boolean {
24
18
  const key = event.key.toLowerCase();
25
19
  if (key === "z" && !event.shiftKey) {
26
20
  event.preventDefault();
@@ -7,10 +7,15 @@ import type { DomEditSelection } from "../components/editor/domEditing";
7
7
  import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { useAppHotkeys } from "./useAppHotkeys";
9
9
  import { mountReactHarness } from "./domSelectionTestHarness";
10
+ import { trackStudioEvent } from "../utils/studioTelemetry";
11
+
12
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
10
13
 
11
14
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
15
 
13
16
  const domDelete = vi.fn(async () => undefined);
17
+ const historyUndo = vi.fn(async () => ({ ok: false }));
18
+ const historyRedo = vi.fn(async () => ({ ok: false }));
14
19
  let root: Root | null = null;
15
20
  let sync: ((iframe: HTMLIFrameElement | null) => void) | null = null;
16
21
 
@@ -35,8 +40,8 @@ function Harness() {
35
40
  domEditSelectionRef: selectionRef,
36
41
  clearDomSelectionRef: useRef<() => void>(() => undefined),
37
42
  editHistory: {
38
- undo: vi.fn(async () => ({ ok: false })),
39
- redo: vi.fn(async () => ({ ok: false })),
43
+ undo: historyUndo,
44
+ redo: historyRedo,
40
45
  state: { undo: [], redo: [] },
41
46
  },
42
47
  readOptionalProjectFile: vi.fn(async () => ""),
@@ -62,6 +67,9 @@ afterEach(() => {
62
67
  document.body.innerHTML = "";
63
68
  usePlayerStore.getState().reset();
64
69
  domDelete.mockClear();
70
+ historyUndo.mockClear();
71
+ historyRedo.mockClear();
72
+ vi.mocked(trackStudioEvent).mockClear();
65
73
  });
66
74
 
67
75
  describe("preview iframe hotkey forwarding", () => {
@@ -87,4 +95,61 @@ describe("preview iframe hotkey forwarding", () => {
87
95
 
88
96
  expect(domDelete).toHaveBeenCalledTimes(1);
89
97
  });
98
+
99
+ function mountWithPreview(): Window & typeof globalThis {
100
+ root = mountReactHarness(<Harness />);
101
+ const iframe = document.createElement("iframe");
102
+ document.body.append(iframe);
103
+ act(() => sync?.(iframe));
104
+ const inner = iframe.contentWindow as (Window & typeof globalThis) | null;
105
+ if (!inner) throw new Error("expected an iframe window");
106
+ return inner;
107
+ }
108
+
109
+ async function press(inner: Window & typeof globalThis, init: KeyboardEventInit) {
110
+ await act(async () => {
111
+ inner.document.body.dispatchEvent(
112
+ new inner.KeyboardEvent("keydown", { ...init, bubbles: true }),
113
+ );
114
+ });
115
+ }
116
+
117
+ it("takes one tracked undo step per Cmd+Z pressed inside the preview", async () => {
118
+ await press(mountWithPreview(), { key: "z", ctrlKey: true });
119
+
120
+ expect(historyUndo).toHaveBeenCalledTimes(1);
121
+ expect(trackStudioEvent).toHaveBeenCalledTimes(1);
122
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "undo" });
123
+ });
124
+
125
+ it.each([
126
+ ["Shift+Cmd+Z", { key: "Z", metaKey: true, shiftKey: true }],
127
+ ["Ctrl+Y", { key: "y", ctrlKey: true }],
128
+ ])("takes one tracked redo step per %s pressed inside the preview", async (_, init) => {
129
+ await press(mountWithPreview(), init);
130
+
131
+ expect(historyRedo).toHaveBeenCalledTimes(1);
132
+ expect(trackStudioEvent).toHaveBeenCalledTimes(1);
133
+ expect(trackStudioEvent).toHaveBeenCalledWith("keyboard_shortcut", { action: "redo" });
134
+ });
135
+
136
+ it("stops handling preview keys once the app unmounts", async () => {
137
+ root = mountReactHarness(<Harness />);
138
+ const iframe = document.createElement("iframe");
139
+ document.body.append(iframe);
140
+ const inner = iframe.contentWindow as (Window & typeof globalThis) | null;
141
+ if (!inner) throw new Error("expected an iframe window");
142
+ const add = vi.spyOn(inner, "addEventListener");
143
+ const remove = vi.spyOn(inner, "removeEventListener");
144
+ act(() => sync?.(iframe));
145
+ act(() => root?.unmount());
146
+ root = null;
147
+
148
+ await press(inner, { key: "z", ctrlKey: true });
149
+
150
+ expect(historyUndo).not.toHaveBeenCalled();
151
+ // happy-dom removes a listener whatever its capture flag; a browser does not, so pin the flag.
152
+ expect(add).toHaveBeenCalled();
153
+ expect(remove.mock.calls).toEqual(add.mock.calls);
154
+ });
90
155
  });