@hyperframes/studio 0.6.5 → 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.
- package/dist/assets/{hyperframes-player-CzwFysqv.js → hyperframes-player-D0Yi3xMP.js} +2 -2
- package/dist/assets/index-Ckqo37Co.css +1 -0
- package/dist/assets/index-Yvtxngdi.js +116 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +54 -31
- package/src/components/StudioGlobalDragOverlay.tsx +26 -0
- package/src/components/StudioHeader.tsx +128 -3
- package/src/components/StudioRightPanel.tsx +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +1 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +27 -36
- package/src/components/editor/domEditingElement.ts +1 -0
- package/src/components/editor/manualEdits.test.ts +39 -466
- package/src/components/editor/manualEdits.ts +6 -168
- package/src/components/editor/manualEditsDom.ts +361 -1
- package/src/components/editor/manualEditsParsing.ts +2 -240
- package/src/components/editor/manualEditsTypes.ts +1 -40
- package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/sidebar/CompositionsTab.tsx +9 -3
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/hooks/useAppHotkeys.ts +1 -4
- package/src/hooks/useDomEditCommits.ts +82 -77
- package/src/hooks/useDomEditSession.ts +4 -16
- package/src/hooks/useFileManager.ts +10 -1
- package/src/hooks/useManifestPersistence.ts +51 -187
- package/src/hooks/usePanelLayout.ts +10 -3
- package/src/hooks/usePreviewInteraction.ts +0 -1
- package/src/hooks/useStudioUrlState.ts +188 -0
- package/src/player/components/Player.tsx +15 -1
- package/src/player/components/PlayerControls.test.ts +17 -0
- package/src/player/components/PlayerControls.tsx +347 -56
- package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +37 -10
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
- package/src/player/hooks/useTimelinePlayer.ts +97 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
- package/src/player/lib/playbackAdapter.test.ts +50 -0
- package/src/player/lib/playbackAdapter.ts +2 -2
- package/src/player/lib/playbackTypes.ts +1 -1
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.ts +63 -7
- package/src/player/store/playerStore.test.ts +105 -1
- package/src/player/store/playerStore.ts +39 -1
- package/src/utils/projectRouting.test.ts +15 -0
- package/src/utils/projectRouting.ts +46 -9
- package/src/utils/sourcePatcher.ts +50 -14
- package/src/utils/studioPreviewHelpers.test.ts +56 -0
- package/src/utils/studioPreviewHelpers.ts +51 -13
- package/src/utils/studioUiPreferences.test.ts +3 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/src/utils/studioUrlState.test.ts +249 -0
- package/src/utils/studioUrlState.ts +135 -0
- package/dist/assets/index-Bs6NmE0o.js +0 -117
- package/dist/assets/index-Dswa2GJ2.css +0 -1
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
+
});
|