@hyperframes/studio 0.6.6 → 0.6.7

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 (55) hide show
  1. package/dist/assets/{hyperframes-player-T-ME1rqL.js → hyperframes-player-D0Yi3xMP.js} +2 -2
  2. package/dist/assets/{index-Bne9FFeo.css → index-Ckqo37Co.css} +1 -1
  3. package/dist/assets/index-Yvtxngdi.js +116 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +54 -31
  7. package/src/components/StudioGlobalDragOverlay.tsx +26 -0
  8. package/src/components/StudioRightPanel.tsx +0 -2
  9. package/src/components/editor/DomEditOverlay.test.ts +1 -0
  10. package/src/components/editor/DomEditOverlay.tsx +2 -1
  11. package/src/components/editor/PropertyPanel.tsx +27 -36
  12. package/src/components/editor/domEditingElement.ts +1 -0
  13. package/src/components/editor/manualEdits.test.ts +39 -466
  14. package/src/components/editor/manualEdits.ts +6 -168
  15. package/src/components/editor/manualEditsDom.ts +361 -1
  16. package/src/components/editor/manualEditsParsing.ts +2 -240
  17. package/src/components/editor/manualEditsTypes.ts +1 -40
  18. package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
  19. package/src/components/nle/NLEPreview.tsx +1 -1
  20. package/src/components/sidebar/CompositionsTab.tsx +9 -3
  21. package/src/contexts/DomEditContext.tsx +3 -0
  22. package/src/contexts/FileManagerContext.tsx +3 -0
  23. package/src/hooks/useAppHotkeys.ts +1 -4
  24. package/src/hooks/useDomEditCommits.ts +82 -77
  25. package/src/hooks/useDomEditSession.ts +4 -16
  26. package/src/hooks/useFileManager.ts +10 -1
  27. package/src/hooks/useManifestPersistence.ts +51 -187
  28. package/src/hooks/usePanelLayout.ts +10 -3
  29. package/src/hooks/usePreviewInteraction.ts +0 -1
  30. package/src/hooks/useStudioUrlState.ts +188 -0
  31. package/src/player/components/Player.tsx +15 -1
  32. package/src/player/components/PlayerControls.test.ts +17 -0
  33. package/src/player/components/PlayerControls.tsx +61 -0
  34. package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
  35. package/src/player/hooks/usePlaybackKeyboard.ts +18 -15
  36. package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
  37. package/src/player/hooks/useTimelinePlayer.ts +76 -18
  38. package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
  39. package/src/player/lib/playbackAdapter.test.ts +50 -0
  40. package/src/player/lib/playbackAdapter.ts +2 -2
  41. package/src/player/lib/playbackTypes.ts +1 -1
  42. package/src/player/lib/timelineDOM.ts +4 -2
  43. package/src/player/lib/timelineIframeHelpers.ts +63 -7
  44. package/src/player/store/playerStore.test.ts +105 -1
  45. package/src/player/store/playerStore.ts +12 -1
  46. package/src/utils/projectRouting.test.ts +15 -0
  47. package/src/utils/projectRouting.ts +46 -9
  48. package/src/utils/sourcePatcher.ts +50 -14
  49. package/src/utils/studioPreviewHelpers.test.ts +56 -0
  50. package/src/utils/studioPreviewHelpers.ts +51 -13
  51. package/src/utils/studioUiPreferences.test.ts +3 -0
  52. package/src/utils/studioUiPreferences.ts +4 -0
  53. package/src/utils/studioUrlState.test.ts +249 -0
  54. package/src/utils/studioUrlState.ts +135 -0
  55. package/dist/assets/index-DYqqzECY.js +0 -117
@@ -1,24 +1,61 @@
1
1
  const PROJECT_HASH_PREFIX = "#project/";
2
2
 
3
+ export interface ProjectHashRoute {
4
+ projectId: string;
5
+ params: URLSearchParams;
6
+ }
7
+
8
+ function decodeHashProjectId(value: string): string {
9
+ try {
10
+ return decodeURIComponent(value);
11
+ } catch {
12
+ return value;
13
+ }
14
+ }
15
+
16
+ function normalizeHashParams(
17
+ params?: URLSearchParams | Record<string, string | null | undefined>,
18
+ ): URLSearchParams {
19
+ if (!params) return new URLSearchParams();
20
+ if (params instanceof URLSearchParams) return params;
21
+
22
+ const next = new URLSearchParams();
23
+ for (const [key, value] of Object.entries(params)) {
24
+ if (!key || value == null || value === "") continue;
25
+ next.set(key, value);
26
+ }
27
+ return next;
28
+ }
29
+
3
30
  export function encodeProjectId(projectId: string): string {
4
31
  return encodeURIComponent(projectId);
5
32
  }
6
33
 
7
- export function buildProjectHash(projectId: string): string {
8
- return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}`;
34
+ export function buildProjectHash(
35
+ projectId: string,
36
+ params?: URLSearchParams | Record<string, string | null | undefined>,
37
+ ): string {
38
+ const search = normalizeHashParams(params).toString();
39
+ return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}${search ? `?${search}` : ""}`;
9
40
  }
10
41
 
11
- export function parseProjectIdFromHash(hash: string): string | null {
42
+ export function parseProjectHashRoute(hash: string): ProjectHashRoute | null {
12
43
  if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
13
44
 
14
- const encodedProjectId = hash.slice(PROJECT_HASH_PREFIX.length);
45
+ const route = hash.slice(PROJECT_HASH_PREFIX.length);
46
+ const queryIndex = route.indexOf("?");
47
+ const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route;
15
48
  if (!encodedProjectId || encodedProjectId.includes("/")) return null;
16
49
 
17
- try {
18
- return decodeURIComponent(encodedProjectId);
19
- } catch {
20
- return encodedProjectId;
21
- }
50
+ const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : "";
51
+ return {
52
+ projectId: decodeHashProjectId(encodedProjectId),
53
+ params: new URLSearchParams(rawParams),
54
+ };
55
+ }
56
+
57
+ export function parseProjectIdFromHash(hash: string): string | null {
58
+ return parseProjectHashRoute(hash)?.projectId ?? null;
22
59
  }
23
60
 
24
61
  export function buildProjectApiPath(projectId: string, suffix = ""): string {
@@ -71,7 +71,7 @@ function splitInlineStyleDeclarations(style: string): string[] {
71
71
  export interface PatchOperation {
72
72
  type: "inline-style" | "attribute" | "text-content";
73
73
  property: string;
74
- value: string;
74
+ value: string | null;
75
75
  }
76
76
 
77
77
  export interface PatchTarget {
@@ -133,7 +133,12 @@ export function resolveSourceFile(
133
133
  /**
134
134
  * Apply a style property change to an element's inline style in the HTML source.
135
135
  */
136
- function patchInlineStyle(html: string, elementId: string, prop: string, value: string): string {
136
+ function patchInlineStyle(
137
+ html: string,
138
+ elementId: string,
139
+ prop: string,
140
+ value: string | null,
141
+ ): string {
137
142
  // Find the element tag with this id
138
143
  const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
139
144
  const match = idPattern.exec(html);
@@ -143,7 +148,12 @@ function patchInlineStyle(html: string, elementId: string, prop: string, value:
143
148
  return patchInlineStyleInTag(html, tag, prop, value);
144
149
  }
145
150
 
146
- function patchInlineStyleInTag(html: string, tag: string, prop: string, value: string): string {
151
+ function patchInlineStyleInTag(
152
+ html: string,
153
+ tag: string,
154
+ prop: string,
155
+ value: string | null,
156
+ ): string {
147
157
  if (!tag) return html;
148
158
 
149
159
  // Check if there's an existing style attribute
@@ -160,16 +170,22 @@ function patchInlineStyleInTag(html: string, tag: string, prop: string, value: s
160
170
  const val = part.slice(colon + 1).trim();
161
171
  if (key) props.set(key, val);
162
172
  }
163
- // Update/add the property
164
- props.set(prop, value);
165
- // Rebuild style string
173
+ // Update/add or remove the property
174
+ if (value === null) {
175
+ props.delete(prop);
176
+ } else {
177
+ props.set(prop, value);
178
+ }
179
+ // Rebuild style string; keep style="" if empty (harmless)
166
180
  const newStyle = Array.from(props.entries())
167
181
  .map(([k, v]) => `${k}: ${escapeStyleAttributeValue(v, quote)}`)
168
182
  .join("; ");
169
183
  const newTag = tag.replace(styleMatch[0], `style=${quote}${newStyle}${quote}`);
170
184
  return html.replace(tag, newTag);
171
185
  } else {
172
- // No existing style — add one
186
+ // No existing style attribute
187
+ if (value === null) return html; // nothing to remove
188
+ // Add one
173
189
  const newTag =
174
190
  tag.replace(/>$/, "") + ` style="${prop}: ${escapeStyleAttributeValue(value, '"')}"`;
175
191
  return html.replace(tag, newTag);
@@ -180,7 +196,7 @@ function patchInlineStyleByTarget(
180
196
  html: string,
181
197
  target: PatchTarget,
182
198
  prop: string,
183
- value: string,
199
+ value: string | null,
184
200
  ): string {
185
201
  const match = findTagByTarget(html, target);
186
202
  if (!match) return html;
@@ -277,15 +293,23 @@ function patchAttributeByTarget(
277
293
  html: string,
278
294
  target: PatchTarget,
279
295
  attr: string,
280
- value: string,
296
+ value: string | null,
281
297
  ): string {
282
298
  const match = findTagByTarget(html, target);
283
299
  if (!match) return html;
284
300
 
285
301
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
286
- const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
302
+ const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
287
303
  const tag = match.tag;
288
304
 
305
+ if (value === null) {
306
+ // Remove the attribute if present
307
+ if (!attrPattern.test(tag)) return html;
308
+ const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
309
+ const newTag = tag.replace(removePattern, "");
310
+ return replaceTagAtMatch(html, match, newTag);
311
+ }
312
+
289
313
  if (attrPattern.test(tag)) {
290
314
  const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`);
291
315
  return replaceTagAtMatch(html, match, newTag);
@@ -298,14 +322,26 @@ function patchAttributeByTarget(
298
322
  /**
299
323
  * Apply an attribute change to an element in the HTML source.
300
324
  */
301
- function patchAttribute(html: string, elementId: string, attr: string, value: string): string {
325
+ function patchAttribute(
326
+ html: string,
327
+ elementId: string,
328
+ attr: string,
329
+ value: string | null,
330
+ ): string {
302
331
  const idPattern = new RegExp(`(<[^>]*\\bid=(["'])${escapeRegex(elementId)}\\2[^>]*)>`, "i");
303
332
  const match = idPattern.exec(html);
304
333
  if (!match) return html;
305
334
 
306
335
  const tag = match[1];
307
336
  const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
308
- const attrPattern = new RegExp(`\\b${fullAttr}=(["'])([^"']*)\\1`);
337
+ const attrPattern = new RegExp(`\\b${escapeRegex(fullAttr)}=(["'])([^"']*)\\1`);
338
+
339
+ if (value === null) {
340
+ if (!attrPattern.test(tag)) return html;
341
+ const removePattern = new RegExp(`\\s+${escapeRegex(fullAttr)}=(["'])[^"']*\\1`);
342
+ const newTag = tag.replace(removePattern, "");
343
+ return html.replace(tag, newTag);
344
+ }
309
345
 
310
346
  if (attrPattern.test(tag)) {
311
347
  // Update existing attribute
@@ -381,7 +417,7 @@ export function applyPatch(html: string, elementId: string, op: PatchOperation):
381
417
  case "attribute":
382
418
  return patchAttribute(html, elementId, op.property, op.value);
383
419
  case "text-content":
384
- return patchTextContent(html, elementId, op.value);
420
+ return op.value !== null ? patchTextContent(html, elementId, op.value) : html;
385
421
  default:
386
422
  return html;
387
423
  }
@@ -401,7 +437,7 @@ export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchO
401
437
  case "attribute":
402
438
  return patchAttributeByTarget(html, target, op.property, op.value);
403
439
  case "text-content":
404
- return patchTextContentByTarget(html, target, op.value);
440
+ return op.value !== null ? patchTextContentByTarget(html, target, op.value) : html;
405
441
  default:
406
442
  return html;
407
443
  }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { pauseStudioPreviewPlayback } from "./studioPreviewHelpers";
3
+
4
+ describe("pauseStudioPreviewPlayback", () => {
5
+ it("pauses through __player without pausing sibling timelines directly", () => {
6
+ const playerPause = vi.fn();
7
+ const timelinePause = vi.fn();
8
+ const siblingPause = vi.fn();
9
+
10
+ const iframe = {
11
+ contentWindow: {
12
+ __player: {
13
+ getTime: () => 4.25,
14
+ pause: playerPause,
15
+ },
16
+ __timeline: {
17
+ time: () => 4.25,
18
+ pause: timelinePause,
19
+ },
20
+ __timelines: {
21
+ root: {
22
+ pause: siblingPause,
23
+ },
24
+ },
25
+ },
26
+ } as unknown as HTMLIFrameElement;
27
+
28
+ expect(pauseStudioPreviewPlayback(iframe)).toBe(4.25);
29
+ expect(playerPause).toHaveBeenCalledTimes(1);
30
+ expect(timelinePause).not.toHaveBeenCalled();
31
+ expect(siblingPause).not.toHaveBeenCalled();
32
+ });
33
+
34
+ it("falls back to pausing timelines directly when __player is unavailable", () => {
35
+ const timelinePause = vi.fn();
36
+ const siblingPause = vi.fn();
37
+
38
+ const iframe = {
39
+ contentWindow: {
40
+ __timeline: {
41
+ time: () => 2.5,
42
+ pause: timelinePause,
43
+ },
44
+ __timelines: {
45
+ root: {
46
+ pause: siblingPause,
47
+ },
48
+ },
49
+ },
50
+ } as unknown as HTMLIFrameElement;
51
+
52
+ expect(pauseStudioPreviewPlayback(iframe)).toBe(2.5);
53
+ expect(timelinePause).toHaveBeenCalledTimes(1);
54
+ expect(siblingPause).toHaveBeenCalledTimes(1);
55
+ });
56
+ });
@@ -1,5 +1,9 @@
1
1
  import type { DomEditViewport, DomEditSelection } from "../components/editor/domEditing";
2
2
  import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing";
3
+ import {
4
+ getDomLayerPatchTarget,
5
+ isElementComputedVisible,
6
+ } from "../components/editor/domEditingElement";
3
7
  import { usePlayerStore, liveTime } from "../player";
4
8
  import { getEventTargetElement } from "./studioHelpers";
5
9
 
@@ -56,6 +60,28 @@ export function getPreviewLocalPointer(
56
60
  return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
57
61
  }
58
62
 
63
+ const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
64
+
65
+ function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
66
+ try {
67
+ const style = doc.createElement("style");
68
+ style.id = POINTER_EVENTS_OVERRIDE_ID;
69
+ style.textContent = "* { pointer-events: auto !important; }";
70
+ doc.head.appendChild(style);
71
+ return style;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
78
+ try {
79
+ style?.remove();
80
+ } catch {
81
+ // cross-origin or detached doc
82
+ }
83
+ }
84
+
59
85
  export function getPreviewTargetFromPointer(
60
86
  iframe: HTMLIFrameElement,
61
87
  clientX: number,
@@ -75,17 +101,25 @@ export function getPreviewTargetFromPointer(
75
101
  const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
76
102
  if (!localPointer) return null;
77
103
 
78
- if (typeof doc.elementsFromPoint === "function") {
79
- const visualTarget = resolveVisualDomEditSelectionTarget(
80
- doc.elementsFromPoint(localPointer.x, localPointer.y),
81
- {
82
- activeCompositionPath,
83
- },
84
- );
85
- if (visualTarget) return visualTarget;
86
- }
104
+ const overrideStyle = forcePointerEventsAuto(doc);
105
+ try {
106
+ if (typeof doc.elementsFromPoint === "function") {
107
+ const visualTarget = resolveVisualDomEditSelectionTarget(
108
+ doc.elementsFromPoint(localPointer.x, localPointer.y),
109
+ {
110
+ activeCompositionPath,
111
+ },
112
+ );
113
+ if (visualTarget) return visualTarget;
114
+ }
87
115
 
88
- return getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
116
+ const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
117
+ if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
118
+ if (!isElementComputedVisible(fallback)) return null;
119
+ return fallback;
120
+ } finally {
121
+ removePointerEventsOverride(overrideStyle);
122
+ }
89
123
  }
90
124
 
91
125
  export function buildRasterClickSelectionContext(
@@ -160,11 +194,15 @@ export function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): nu
160
194
  if (!win) return null;
161
195
 
162
196
  try {
163
- let pausedTime: number | null = null;
164
197
  const player = objectLike(Reflect.get(win, "__player"));
165
- pausedTime = readPlaybackTime(player, "getTime") ?? pausedTime;
166
- callPlaybackMethod(player, "pause");
198
+ const playerPausedTime = readPlaybackTime(player, "getTime");
199
+ const playerPause = player ? Reflect.get(player, "pause") : null;
200
+ if (typeof playerPause === "function") {
201
+ callPlaybackMethod(player, "pause");
202
+ return playerPausedTime;
203
+ }
167
204
 
205
+ let pausedTime: number | null = null;
168
206
  const timeline = objectLike(Reflect.get(win, "__timeline"));
169
207
  pausedTime = pausedTime ?? readPlaybackTime(timeline, "time");
170
208
  callPlaybackMethod(timeline, "pause");
@@ -21,11 +21,13 @@ describe("studio UI preferences", () => {
21
21
 
22
22
  writeStudioUiPreferences({ timelineVisible: false }, storage);
23
23
  writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
24
+ writeStudioUiPreferences({ audioMuted: true }, storage);
24
25
  writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
25
26
 
26
27
  expect(readStudioUiPreferences(storage)).toEqual({
27
28
  timelineVisible: false,
28
29
  playbackRate: 1.5,
30
+ audioMuted: true,
29
31
  previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
30
32
  });
31
33
  });
@@ -38,6 +40,7 @@ describe("studio UI preferences", () => {
38
40
  leftCollapsed: "yes",
39
41
  timelineVisible: true,
40
42
  playbackRate: Number.NaN,
43
+ audioMuted: "false",
41
44
  previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
42
45
  }),
43
46
  );
@@ -8,6 +8,7 @@ export interface StudioUiPreferences {
8
8
  leftCollapsed?: boolean;
9
9
  timelineVisible?: boolean;
10
10
  playbackRate?: number;
11
+ audioMuted?: boolean;
11
12
  previewZoom?: StoredPreviewZoomState;
12
13
  }
13
14
 
@@ -44,6 +45,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
44
45
  if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
45
46
  preferences.playbackRate = parsed.playbackRate;
46
47
  }
48
+ if (typeof parsed.audioMuted === "boolean") {
49
+ preferences.audioMuted = parsed.audioMuted;
50
+ }
47
51
  if (isRecord(parsed.previewZoom)) {
48
52
  const { zoomPercent, panX, panY } = parsed.previewZoom;
49
53
  if (
@@ -0,0 +1,249 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ buildStudioHash,
8
+ normalizeStudioCompositionPath,
9
+ normalizeStudioUrlPanelTab,
10
+ parseStudioUrlStateFromHash,
11
+ } from "./studioUrlState";
12
+ import { useStudioUrlState } from "../hooks/useStudioUrlState";
13
+ import { usePlayerStore } from "../player";
14
+
15
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ function resetPlayerStore() {
18
+ usePlayerStore.setState({
19
+ isPlaying: false,
20
+ currentTime: 0,
21
+ duration: 0,
22
+ timelineReady: false,
23
+ elements: [],
24
+ selectedElementId: null,
25
+ requestedSeekTime: null,
26
+ });
27
+ }
28
+
29
+ afterEach(() => {
30
+ vi.useRealTimers();
31
+ document.body.innerHTML = "";
32
+ window.history.replaceState(null, "", "/");
33
+ resetPlayerStore();
34
+ });
35
+
36
+ function renderStudioUrlStateHarness(
37
+ props: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {},
38
+ ) {
39
+ const host = document.createElement("div");
40
+ document.body.append(host);
41
+ const root = createRoot(host);
42
+ const baseProps: React.ComponentProps<typeof StudioUrlStateHarness> = {
43
+ projectId: "demo",
44
+ activeCompPath: null,
45
+ currentTime: 0,
46
+ duration: 30,
47
+ isPlaying: false,
48
+ compositionLoading: false,
49
+ refreshKey: 0,
50
+ previewIframeRef: { current: null },
51
+ rightPanelTab: "renders",
52
+ rightCollapsed: true,
53
+ timelineVisible: true,
54
+ activeCompPathHydrated: true,
55
+ domEditSelection: null,
56
+ buildDomSelectionFromTarget: () => null,
57
+ applyDomSelection: () => {},
58
+ initialState: {
59
+ activeCompPath: null,
60
+ currentTime: 4.2,
61
+ rightPanelTab: null,
62
+ rightCollapsed: null,
63
+ timelineVisible: null,
64
+ selection: null,
65
+ },
66
+ };
67
+
68
+ const render = (nextProps: Partial<React.ComponentProps<typeof StudioUrlStateHarness>> = {}) => {
69
+ act(() => {
70
+ root.render(
71
+ React.createElement(StudioUrlStateHarness, {
72
+ ...baseProps,
73
+ ...props,
74
+ ...nextProps,
75
+ }),
76
+ );
77
+ });
78
+ };
79
+
80
+ render();
81
+ return {
82
+ rerender: render,
83
+ unmount: () =>
84
+ act(() => {
85
+ root.unmount();
86
+ }),
87
+ };
88
+ }
89
+
90
+ function StudioUrlStateHarness(props: Parameters<typeof useStudioUrlState>[0]) {
91
+ useStudioUrlState(props);
92
+ return null;
93
+ }
94
+
95
+ describe("studio url state", () => {
96
+ it("parses persisted studio state from project hash", () => {
97
+ const state = parseStudioUrlStateFromHash(
98
+ "#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.25&tab=design&rc=0&tv=1&selFile=index.html&selId=hero",
99
+ );
100
+
101
+ expect(state.activeCompPath).toBe("compositions/title.html");
102
+ expect(state.currentTime).toBe(4.25);
103
+ expect(state.rightPanelTab).toBe("design");
104
+ expect(state.rightCollapsed).toBe(false);
105
+ expect(state.timelineVisible).toBe(true);
106
+ expect(state.selection).toEqual({
107
+ sourceFile: "index.html",
108
+ id: "hero",
109
+ selector: undefined,
110
+ selectorIndex: undefined,
111
+ });
112
+ });
113
+
114
+ it("builds a project hash with persisted studio state", () => {
115
+ expect(
116
+ buildStudioHash("demo", {
117
+ activeCompPath: "compositions/title.html",
118
+ currentTime: 4.2571,
119
+ rightPanelTab: "layers",
120
+ rightCollapsed: true,
121
+ timelineVisible: false,
122
+ selection: {
123
+ sourceFile: "index.html",
124
+ selector: ".card",
125
+ selectorIndex: 2,
126
+ },
127
+ }),
128
+ ).toBe(
129
+ "#project/demo?v=1&comp=compositions%2Ftitle.html&t=4.257&tab=layers&rc=1&tv=0&selFile=index.html&selSelector=.card&selIndex=2",
130
+ );
131
+ });
132
+
133
+ it("falls back cleanly on invalid values", () => {
134
+ const state = parseStudioUrlStateFromHash("#project/demo?tab=nope&t=abc&rc=9&tv=7");
135
+
136
+ expect(state.activeCompPath).toBeNull();
137
+ expect(state.currentTime).toBeNull();
138
+ expect(state.rightPanelTab).toBeNull();
139
+ expect(state.rightCollapsed).toBeNull();
140
+ expect(state.timelineVisible).toBeNull();
141
+ expect(state.selection).toBeNull();
142
+ });
143
+
144
+ it("normalizes stale composition paths to the master composition", () => {
145
+ expect(
146
+ normalizeStudioCompositionPath("compositions/missing.html", [
147
+ "index.html",
148
+ "compositions/title.html",
149
+ ]),
150
+ ).toBeNull();
151
+ expect(
152
+ normalizeStudioCompositionPath("compositions/title.html", [
153
+ "index.html",
154
+ "compositions/title.html",
155
+ ]),
156
+ ).toBe("compositions/title.html");
157
+ });
158
+
159
+ it("normalizes url tabs against feature flags", () => {
160
+ expect(normalizeStudioUrlPanelTab("renders")).toBe("renders");
161
+ expect(normalizeStudioUrlPanelTab("layers", { inspectorPanelsEnabled: false })).toBe("renders");
162
+ expect(normalizeStudioUrlPanelTab("motion", { motionPanelEnabled: false })).toBe("design");
163
+ });
164
+
165
+ it("hydrates seek first, preserves the initial url state, then restores selection", () => {
166
+ vi.useFakeTimers();
167
+ window.history.replaceState(null, "", "#project/demo?t=4.2&tab=design&selId=hero");
168
+ const requestSeek = vi.fn();
169
+ usePlayerStore.setState({ requestSeek });
170
+ const selectedElement = document.createElement("div");
171
+ selectedElement.id = "hero";
172
+ document.body.append(selectedElement);
173
+ const previewDoc = document.implementation.createHTMLDocument("preview");
174
+ previewDoc.body.append(selectedElement);
175
+ const applyDomSelection = vi.fn();
176
+ const restoredSelection = {
177
+ element: selectedElement,
178
+ id: "hero",
179
+ selector: "#hero",
180
+ selectorIndex: 0,
181
+ sourceFile: "index.html",
182
+ tagName: "div",
183
+ label: "Hero",
184
+ textContent: "",
185
+ textFields: [],
186
+ capabilities: {
187
+ canEditText: false,
188
+ canEditLayout: true,
189
+ canApplyManualOffset: true,
190
+ canApplyManualSize: true,
191
+ canApplyManualRotation: true,
192
+ canAdjustOpacity: true,
193
+ canAdjustFill: true,
194
+ canAdjustBorderRadius: true,
195
+ canAdjustStroke: true,
196
+ canAdjustShadow: true,
197
+ canAdjustZIndex: true,
198
+ },
199
+ computedStyle: {
200
+ display: "block",
201
+ position: "absolute",
202
+ },
203
+ };
204
+
205
+ const harness = renderStudioUrlStateHarness({
206
+ previewIframeRef: {
207
+ current: { contentDocument: previewDoc } as HTMLIFrameElement,
208
+ },
209
+ rightPanelTab: "design",
210
+ rightCollapsed: false,
211
+ applyDomSelection,
212
+ buildDomSelectionFromTarget: () => restoredSelection,
213
+ initialState: {
214
+ activeCompPath: null,
215
+ currentTime: 4.2,
216
+ rightPanelTab: "design",
217
+ rightCollapsed: false,
218
+ timelineVisible: true,
219
+ selection: { id: "hero" },
220
+ },
221
+ });
222
+
223
+ expect(requestSeek).toHaveBeenCalledWith(4.2);
224
+ expect(applyDomSelection).not.toHaveBeenCalled();
225
+ expect(window.location.hash).toContain("t=4.2");
226
+ expect(window.location.hash).toContain("tab=design");
227
+
228
+ act(() => {
229
+ vi.advanceTimersByTime(250);
230
+ });
231
+ expect(window.location.hash).toContain("t=4.2");
232
+ expect(applyDomSelection).not.toHaveBeenCalled();
233
+
234
+ harness.rerender({ currentTime: 4.2 });
235
+ act(() => {
236
+ vi.advanceTimersByTime(250);
237
+ });
238
+ expect(applyDomSelection).toHaveBeenCalledWith(restoredSelection, { revealPanel: false });
239
+
240
+ harness.rerender({ currentTime: 4.2, domEditSelection: restoredSelection });
241
+ act(() => {
242
+ vi.advanceTimersByTime(250);
243
+ });
244
+ expect(window.location.hash).toContain("t=4.2");
245
+ expect(window.location.hash).toContain("selId=hero");
246
+
247
+ harness.unmount();
248
+ });
249
+ });