@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.
- package/dist/assets/{hyperframes-player-fWt87gyh.js → hyperframes-player-CFJLrHRF.js} +1 -1
- package/dist/assets/{index-D6TjRAvz.js → index-BBNVnnyH.js} +114 -114
- package/dist/assets/{index-DofdzdxJ.js → index-C33EDBL2.js} +1 -1
- package/dist/assets/{index-D34Oy5DN.js → index-CJOzbslX.js} +1 -1
- package/dist/{chunk-U3EE4ACX.js → chunk-7X4XRP3L.js} +14 -13
- package/dist/chunk-7X4XRP3L.js.map +1 -0
- package/dist/index.d.ts +91 -21
- package/dist/index.html +1 -1
- package/dist/index.js +403 -226
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/App.tsx +2 -0
- package/src/components/dock/Dock.test.tsx +78 -3
- package/src/components/dock/Dock.tsx +45 -18
- package/src/components/dock/dockLayout.ts +27 -14
- package/src/components/dock/dockLayoutSchema.ts +11 -4
- package/src/components/dock/dockLayoutStore.ts +4 -2
- package/src/hooks/appHotkeysDispatch.ts +1 -7
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +67 -2
- package/src/hooks/useAppHotkeys.ts +18 -45
- package/src/hooks/useEditHistoryActions.paint.test.tsx +166 -0
- package/src/hooks/useEditHistoryActions.test.tsx +67 -9
- package/src/hooks/useEditHistoryActions.ts +36 -8
- package/src/hooks/usePersistentEditHistory.test.ts +46 -0
- package/src/hooks/usePersistentEditHistory.ts +101 -19
- package/src/hooks/usePreviewPersistence.ts +25 -2
- package/src/hooks/useStudioExternalFileChanges.ts +7 -3
- package/src/index.ts +4 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -5
- package/src/player/components/timelineDragDrop.ts +29 -8
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/utils/domEditSaveQueue.ts +7 -2
- package/src/utils/gsapSoftReload.ts +1 -1
- package/src/utils/gsapUndoRestore.test.ts +46 -0
- package/src/utils/gsapUndoRestore.ts +166 -91
- package/src/utils/studioPendingEdits.ts +14 -5
- package/src/utils/studioUiPreferences.ts +8 -6
- package/dist/chunk-U3EE4ACX.js.map +0 -1
package/dist/player.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
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/
|
|
64
|
-
"@hyperframes/sdk": "0.8.
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
127
|
+
reset: () => buildEditLayout(api, options.dockWidth(), options.panels),
|
|
118
128
|
};
|
|
119
129
|
}
|
|
120
130
|
|
|
121
|
-
function restoreOrBuild(api: DockviewApi, projectId: string | null) {
|
|
122
|
-
const
|
|
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,
|
|
142
|
+
buildEditLayout(api, options.dockWidth(), options.panels);
|
|
132
143
|
}
|
|
133
144
|
|
|
134
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
//
|
|
158
|
-
const resizeObserver = new ResizeObserver(() => applySideMinimums(api,
|
|
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
|
-
{
|
|
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(
|
|
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
|
-
|
|
92
|
-
|
|
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(
|
|
103
|
-
api.getPanel(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
|
39
|
-
redo:
|
|
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
|
});
|