@hyperframes/studio 0.8.56 → 0.8.58
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-zGyOcwwh.js +464 -0
- package/dist/assets/{index-DFlILHJe.js → index-BcE2OSVA.js} +1 -1
- package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-CxR7F2YQ.js → index-ZJaRCDYV.js} +1 -1
- package/dist/assets/{index-BlkJ_Axe.js → index-d8ferjRc.js} +194 -194
- package/dist/index.d.ts +124 -9
- package/dist/index.html +2 -2
- package/dist/index.js +15686 -14882
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -0
- package/src/components/EditorShell.tsx +3 -1
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +0 -15
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +65 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/SnapToolbar.test.tsx +25 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +48 -6
- package/src/components/nle/NLEPreview.test.ts +67 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.tsx +4 -16
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/previewReadOnly.test.tsx +1 -0
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/hooks/useCompositionDimensions.test.tsx +37 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +108 -0
- package/src/hooks/useSdkSession.ts +77 -14
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/index.ts +21 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +175 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -572
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
- package/src/player/index.ts +7 -0
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.58",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,11 +51,11 @@
|
|
|
51
51
|
"dockview-react": "8.3.1",
|
|
52
52
|
"gsap": "^3.13.0",
|
|
53
53
|
"mediabunny": "^1.45.3",
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/
|
|
56
|
-
"@hyperframes/
|
|
57
|
-
"@hyperframes/
|
|
58
|
-
"@hyperframes/
|
|
54
|
+
"@hyperframes/core": "0.8.58",
|
|
55
|
+
"@hyperframes/parsers": "0.8.58",
|
|
56
|
+
"@hyperframes/studio-server": "0.8.58",
|
|
57
|
+
"@hyperframes/sdk": "0.8.58",
|
|
58
|
+
"@hyperframes/player": "0.8.58"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
"vite": "^6.4.2",
|
|
72
72
|
"vitest": "^4.1.11",
|
|
73
73
|
"zustand": "^5.0.0",
|
|
74
|
-
"@hyperframes/producer": "0.8.
|
|
74
|
+
"@hyperframes/producer": "0.8.58"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import { usePlayerStore } from "./player";
|
|
|
7
7
|
import { StudioOverlays } from "./components/StudioOverlays";
|
|
8
8
|
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
|
|
9
9
|
import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
|
|
10
|
+
import { ProjectUnreachableBanner } from "./components/ProjectUnreachableBanner";
|
|
10
11
|
import { useCaptionStore } from "./captions/store";
|
|
11
12
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
12
13
|
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
|
|
@@ -475,6 +476,9 @@ export function StudioApp({ readOnlyPreview = false, readOnlyPreviewReason }: St
|
|
|
475
476
|
/>
|
|
476
477
|
)}
|
|
477
478
|
<ExternalFileConflictBanner coordinator={externalFileChanges} />
|
|
479
|
+
{sdkHandle.unreachableProject && (
|
|
480
|
+
<ProjectUnreachableBanner projectId={sdkHandle.unreachableProject} />
|
|
481
|
+
)}
|
|
478
482
|
<EditorShell
|
|
479
483
|
readOnlyPreview={readOnlyPreview}
|
|
480
484
|
readOnlyPreviewReason={readOnlyPreviewReason}
|
|
@@ -172,6 +172,7 @@ export function EditorShell({
|
|
|
172
172
|
activeCompositionPath={activeCompPath}
|
|
173
173
|
onIframeRef={handlePreviewIframeRef}
|
|
174
174
|
onCompIdToSrcChange={setCompIdToSrc}
|
|
175
|
+
onPreviewReloadFailed={(message) => showToast(message, "error")}
|
|
175
176
|
onCompositionLoadingChange={setCompositionLoading}
|
|
176
177
|
onCompositionChange={(compPath) => {
|
|
177
178
|
// Sync activeCompPath when the user drills down via the timeline or
|
|
@@ -264,7 +265,7 @@ function EditorShellBody({
|
|
|
264
265
|
onDuplicateClip,
|
|
265
266
|
canPasteClip,
|
|
266
267
|
}: EditorShellBodyProps) {
|
|
267
|
-
const { compositionStack, updateCompositionStack } = useNLEContext();
|
|
268
|
+
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
|
|
268
269
|
|
|
269
270
|
// The caption track's blocks are seek targets; CaptionTimeline took an onSeek
|
|
270
271
|
// prop that nothing ever passed, so clicking a block did nothing.
|
|
@@ -284,6 +285,7 @@ function EditorShellBody({
|
|
|
284
285
|
|
|
285
286
|
return (
|
|
286
287
|
<div
|
|
288
|
+
ref={containerRef}
|
|
287
289
|
// Shell canvas is a step LIGHTER than the near-black panel cards so the
|
|
288
290
|
// gaps between panels read as visible seams (CapCut-style).
|
|
289
291
|
className="flex flex-col flex-1 min-h-0 bg-panel-surface"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { ProjectUnreachableBanner } from "./ProjectUnreachableBanner";
|
|
6
|
+
|
|
7
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
|
+
|
|
9
|
+
let root: Root | null = null;
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (root) act(() => root?.unmount());
|
|
13
|
+
root = null;
|
|
14
|
+
document.body.replaceChildren();
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
vi.unstubAllGlobals();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function stubProjects(projects: unknown): void {
|
|
20
|
+
vi.stubGlobal(
|
|
21
|
+
"fetch",
|
|
22
|
+
vi.fn(async () => new Response(JSON.stringify({ projects }), { status: 200 })),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function renderBanner(projectId: string): Promise<string> {
|
|
27
|
+
const host = document.createElement("div");
|
|
28
|
+
document.body.append(host);
|
|
29
|
+
root = createRoot(host);
|
|
30
|
+
await act(async () => {
|
|
31
|
+
root?.render(<ProjectUnreachableBanner projectId={projectId} />);
|
|
32
|
+
});
|
|
33
|
+
await act(async () => {
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
35
|
+
});
|
|
36
|
+
return host.textContent ?? "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("ProjectUnreachableBanner", () => {
|
|
40
|
+
// The production case: the CLI host serves exactly one project, and it is not
|
|
41
|
+
// this tab's. Naming both sides is the whole point — the user needs to know
|
|
42
|
+
// their project still exists and which one this Studio actually opened.
|
|
43
|
+
it("names both projects when this Studio serves a different one", async () => {
|
|
44
|
+
stubProjects([{ id: "out-video1", title: "out-video1" }]);
|
|
45
|
+
const text = await renderBanner("out-fb1");
|
|
46
|
+
|
|
47
|
+
expect(text).toContain("out-video1");
|
|
48
|
+
expect(text).toContain("out-fb1");
|
|
49
|
+
expect(text).toContain("This Studio is serving");
|
|
50
|
+
expect(text).not.toContain("renamed");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// With several projects a 404 cannot be told apart from a rename or a
|
|
54
|
+
// deletion, so the banner must not claim a cause. "Could be gone" is the
|
|
55
|
+
// honest ceiling, and it is a weaker claim than "is gone".
|
|
56
|
+
it("does not claim a cause when several projects are served", async () => {
|
|
57
|
+
stubProjects([
|
|
58
|
+
{ id: "a", title: "a" },
|
|
59
|
+
{ id: "b", title: "b" },
|
|
60
|
+
]);
|
|
61
|
+
const text = await renderBanner("out-fb1");
|
|
62
|
+
|
|
63
|
+
expect(text).toContain("renamed, moved, or deleted");
|
|
64
|
+
expect(text).not.toContain("This Studio is serving");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// A server that answers with this tab's own project while the read 404s is
|
|
68
|
+
// not the stale-tab case, so the single-project wording would be false.
|
|
69
|
+
it("falls back to the general wording when the served project is this one", async () => {
|
|
70
|
+
stubProjects([{ id: "out-fb1", title: "out-fb1" }]);
|
|
71
|
+
const text = await renderBanner("out-fb1");
|
|
72
|
+
|
|
73
|
+
expect(text).toContain("renamed, moved, or deleted");
|
|
74
|
+
expect(text).not.toContain("This Studio is serving");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("says nothing it cannot back up when the project list cannot be read", async () => {
|
|
78
|
+
vi.stubGlobal(
|
|
79
|
+
"fetch",
|
|
80
|
+
vi.fn(async () => new Response("boom", { status: 500 })),
|
|
81
|
+
);
|
|
82
|
+
const text = await renderBanner("out-fb1");
|
|
83
|
+
|
|
84
|
+
expect(text).toContain("renamed, moved, or deleted");
|
|
85
|
+
expect(text).not.toContain("This Studio is serving");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("renders nothing until the project list resolves", async () => {
|
|
89
|
+
let resolveList: ((value: Response) => void) | undefined;
|
|
90
|
+
vi.stubGlobal(
|
|
91
|
+
"fetch",
|
|
92
|
+
vi.fn(
|
|
93
|
+
() =>
|
|
94
|
+
new Promise<Response>((resolve) => {
|
|
95
|
+
resolveList = resolve;
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
const host = document.createElement("div");
|
|
100
|
+
document.body.append(host);
|
|
101
|
+
root = createRoot(host);
|
|
102
|
+
await act(async () => {
|
|
103
|
+
root?.render(<ProjectUnreachableBanner projectId="out-fb1" />);
|
|
104
|
+
});
|
|
105
|
+
expect(host.textContent).toBe("");
|
|
106
|
+
|
|
107
|
+
await act(async () => {
|
|
108
|
+
resolveList?.(new Response(JSON.stringify({ projects: [{ id: "other" }] }), { status: 200 }));
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
110
|
+
});
|
|
111
|
+
expect(host.textContent).toContain("This Studio is serving");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface ServedProject {
|
|
4
|
+
id: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface ProjectUnreachableBannerProps {
|
|
9
|
+
/** The project this tab was opened for and the server cannot resolve. */
|
|
10
|
+
projectId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Shown when the running Studio cannot resolve this tab's project id.
|
|
15
|
+
*
|
|
16
|
+
* Why this exists: a tab whose project the server does not serve fails every
|
|
17
|
+
* read with a 404, so every edit silently takes the server path and nothing
|
|
18
|
+
* lands. The user sees a normal-looking editor that does not save. In the
|
|
19
|
+
* CLI-embedded host — the only host that reports telemetry, since
|
|
20
|
+
* `telemetry/policy.ts` suppresses Vite dev — the cause is almost always that
|
|
21
|
+
* this Studio is serving a *different* project: `hyperframes preview` reuses
|
|
22
|
+
* port 3002, so starting it on another folder takes the port from under an
|
|
23
|
+
* open tab, and the project it was pointed at is untouched on disk.
|
|
24
|
+
*
|
|
25
|
+
* The wording is deliberately narrower than "this project is gone". We can see
|
|
26
|
+
* which project this server serves; we cannot see why it does not serve this
|
|
27
|
+
* one, and nothing here is lost. See
|
|
28
|
+
* `docs/hyperframes/plans/sdk/2026-09-20-stale-project-id-remaining-doors-plan.md` §C.
|
|
29
|
+
*/
|
|
30
|
+
export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBannerProps) {
|
|
31
|
+
const [served, setServed] = useState<ServedProject[] | null>(null);
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
// A list we could not read is not evidence of anything, so a failed fetch
|
|
36
|
+
// leaves `served` empty and the banner falls back to the vague wording
|
|
37
|
+
// rather than claiming this Studio serves nothing.
|
|
38
|
+
fetch("/api/projects")
|
|
39
|
+
.then((res) => (res.ok ? res.json() : { projects: [] }))
|
|
40
|
+
.then((data: { projects?: ServedProject[] }) => {
|
|
41
|
+
if (!cancelled) setServed(data.projects ?? []);
|
|
42
|
+
})
|
|
43
|
+
.catch(() => {
|
|
44
|
+
if (!cancelled) setServed([]);
|
|
45
|
+
});
|
|
46
|
+
return () => {
|
|
47
|
+
cancelled = true;
|
|
48
|
+
};
|
|
49
|
+
}, [projectId]);
|
|
50
|
+
|
|
51
|
+
// Withheld until the list arrives: which wording applies is a claim about
|
|
52
|
+
// what this server serves, and guessing it would flash the wrong one.
|
|
53
|
+
if (served === null) return null;
|
|
54
|
+
|
|
55
|
+
// Exactly one project served, and it is not this tab's. Under the CLI host
|
|
56
|
+
// that is the whole truth, and it names both sides. With several projects we
|
|
57
|
+
// cannot tell a rename from a deletion, so we do not pretend to.
|
|
58
|
+
const soleProject = served.length === 1 && served[0].id !== projectId ? served[0] : null;
|
|
59
|
+
const servedLabel = soleProject?.title ?? soleProject?.id;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
role="alert"
|
|
64
|
+
className="hf-backdrop-in absolute left-1/2 top-14 z-92 flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-amber-500/30 bg-amber-950/85 px-4 py-2 text-[12px] font-medium text-amber-100 shadow-lg shadow-black/30"
|
|
65
|
+
>
|
|
66
|
+
{soleProject ? (
|
|
67
|
+
<>
|
|
68
|
+
<span>
|
|
69
|
+
This Studio is serving <strong>{servedLabel}</strong>. This tab was opened for{" "}
|
|
70
|
+
<strong>{projectId}</strong>.
|
|
71
|
+
</span>
|
|
72
|
+
{/* Reloading re-runs the mount-time hash check, which rewrites the hash
|
|
73
|
+
to the project this server actually serves. That is the same
|
|
74
|
+
fallback a fresh load performs, so the button promises nothing
|
|
75
|
+
beyond what a reload already does. */}
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
onClick={() => window.location.reload()}
|
|
79
|
+
className="shrink-0 rounded-sm border border-amber-300/20 px-2 py-1 text-[11px] text-amber-100 transition-colors hover:bg-amber-400/10 active:scale-[0.98]"
|
|
80
|
+
>
|
|
81
|
+
Open {servedLabel}
|
|
82
|
+
</button>
|
|
83
|
+
</>
|
|
84
|
+
) : (
|
|
85
|
+
<span>Couldn't open this project — it may have been renamed, moved, or deleted.</span>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -20,7 +20,6 @@ import { ChildRectOutlines, OffCanvasIndicators } from "./OffCanvasIndicators";
|
|
|
20
20
|
import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
|
|
21
21
|
import { useDomEditNudge } from "./useDomEditNudge";
|
|
22
22
|
import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
|
|
23
|
-
import { GridOverlay } from "./GridOverlay";
|
|
24
23
|
import type { GestureRecordingState } from "./GestureRecordControl";
|
|
25
24
|
import { DomEditGroupChrome, DomEditSelectionChrome } from "./DomEditSelectionChrome";
|
|
26
25
|
import { hugRectForElement } from "./domEditOverlayCrop";
|
|
@@ -84,8 +83,6 @@ interface DomEditOverlayProps {
|
|
|
84
83
|
) => Promise<void> | void;
|
|
85
84
|
onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
|
|
86
85
|
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
87
|
-
gridVisible?: boolean;
|
|
88
|
-
gridSpacing?: number;
|
|
89
86
|
recordingState?: GestureRecordingState;
|
|
90
87
|
onToggleRecording?: () => void;
|
|
91
88
|
onMarqueeSelect?: (selections: DomEditSelection[], additive: boolean) => void;
|
|
@@ -125,8 +122,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
125
122
|
onCanvasPointerLeave,
|
|
126
123
|
onSelectionChange,
|
|
127
124
|
onBlockedMove,
|
|
128
|
-
gridVisible = false,
|
|
129
|
-
gridSpacing = 50,
|
|
130
125
|
onManualDragStart,
|
|
131
126
|
onPathOffsetCommit,
|
|
132
127
|
onGroupPathOffsetCommit,
|
|
@@ -555,16 +550,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
555
550
|
/>
|
|
556
551
|
)}
|
|
557
552
|
<ZOrderCrossedFlash rect={zOrderFlashRect} />
|
|
558
|
-
<GridOverlay
|
|
559
|
-
visible={gridVisible}
|
|
560
|
-
spacing={gridSpacing}
|
|
561
|
-
scaleX={compRect.scaleX}
|
|
562
|
-
scaleY={compRect.scaleY}
|
|
563
|
-
compositionLeft={compRect.left}
|
|
564
|
-
compositionTop={compRect.top}
|
|
565
|
-
compositionWidth={compRect.width}
|
|
566
|
-
compositionHeight={compRect.height}
|
|
567
|
-
/>
|
|
568
553
|
<SnapGuideOverlay
|
|
569
554
|
snapGuidesRef={snapGuidesRef}
|
|
570
555
|
compositionLeft={compRect.left}
|
|
@@ -1,28 +1,17 @@
|
|
|
1
|
-
// fallow-ignore-file unused-file
|
|
2
1
|
import { memo } from "react";
|
|
2
|
+
import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// fallow-ignore-next-line complexity
|
|
16
|
-
export const GridOverlay = memo(function GridOverlay({
|
|
17
|
-
visible,
|
|
18
|
-
spacing,
|
|
19
|
-
scaleX,
|
|
20
|
-
scaleY,
|
|
21
|
-
compositionLeft,
|
|
22
|
-
compositionTop,
|
|
23
|
-
compositionWidth,
|
|
24
|
-
compositionHeight,
|
|
25
|
-
}: GridOverlayProps) {
|
|
4
|
+
export const GridOverlay = memo(function GridOverlay() {
|
|
5
|
+
const { state } = usePreviewOverlayContext();
|
|
6
|
+
const { gridVisible: visible, gridSpacing: spacing } = state.snapPrefs;
|
|
7
|
+
const {
|
|
8
|
+
scaleX,
|
|
9
|
+
scaleY,
|
|
10
|
+
left: compositionLeft,
|
|
11
|
+
top: compositionTop,
|
|
12
|
+
width: compositionWidth,
|
|
13
|
+
height: compositionHeight,
|
|
14
|
+
} = state.compositionRect;
|
|
26
15
|
if (!visible || spacing <= 0) return null;
|
|
27
16
|
|
|
28
17
|
const overlaySpacingX = spacing * scaleX;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { expect, it, vi } from "vitest";
|
|
5
|
+
import { MotionPathOverlay } from "./MotionPathOverlay";
|
|
6
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
7
|
+
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
8
|
+
import type { DomEditSelection } from "./domEditing";
|
|
9
|
+
|
|
10
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
11
|
+
|
|
12
|
+
vi.mock("../../contexts/DomEditContext", () => ({
|
|
13
|
+
useDomEditContext: () => ({ selectedGsapAnimations: [] }),
|
|
14
|
+
}));
|
|
15
|
+
vi.mock("./motionPathSelection", () => ({
|
|
16
|
+
selectorFor: () => "#box",
|
|
17
|
+
editableAnimationId: () => null,
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("./useMotionPathData", async (importOriginal) => ({
|
|
20
|
+
...(await importOriginal<typeof import("./useMotionPathData")>()),
|
|
21
|
+
useMotionPathData: () => ({
|
|
22
|
+
rect: null,
|
|
23
|
+
geometry: null,
|
|
24
|
+
geometryResolved: true,
|
|
25
|
+
visibleInPreview: true,
|
|
26
|
+
home: null,
|
|
27
|
+
pScale: 1,
|
|
28
|
+
}),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
it("re-evaluates set-destination availability when a reload swaps the live iframe", () => {
|
|
32
|
+
const host = document.createElement("div");
|
|
33
|
+
const before = document.createElement("iframe");
|
|
34
|
+
const after = document.createElement("iframe");
|
|
35
|
+
document.body.append(host, before, after);
|
|
36
|
+
Reflect.set(after.contentWindow as object, "MotionPathPlugin", {});
|
|
37
|
+
const ref = { current: before };
|
|
38
|
+
const root = createRoot(host);
|
|
39
|
+
const selection = { element: document.createElement("div") } as unknown as DomEditSelection;
|
|
40
|
+
try {
|
|
41
|
+
act(() => usePreviewIframeStore.getState().setIframe(before));
|
|
42
|
+
act(() =>
|
|
43
|
+
root.render(
|
|
44
|
+
<MotionPathOverlay
|
|
45
|
+
iframeRef={ref}
|
|
46
|
+
selection={selection}
|
|
47
|
+
compositionSize={{ width: 1920, height: 1080 }}
|
|
48
|
+
isPlaying={false}
|
|
49
|
+
/>,
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(false);
|
|
53
|
+
|
|
54
|
+
act(() => {
|
|
55
|
+
usePreviewIframeStore.getState().setIframe(after);
|
|
56
|
+
});
|
|
57
|
+
expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(true);
|
|
58
|
+
} finally {
|
|
59
|
+
act(() => root.unmount());
|
|
60
|
+
usePreviewIframeStore.setState({ iframe: null });
|
|
61
|
+
host.remove();
|
|
62
|
+
before.remove();
|
|
63
|
+
after.remove();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
@@ -3,6 +3,7 @@ import { memo, useEffect, useRef, useState, type RefObject } from "react";
|
|
|
3
3
|
import type { DomEditSelection } from "./domEditing";
|
|
4
4
|
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
5
5
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
6
|
+
import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
|
|
6
7
|
import { parkPlayheadOnKeyframe } from "../../hooks/gsapDragCommit";
|
|
7
8
|
import { commitWholePropertyOffset } from "../../hooks/gsapWholePropertyOffsetCommit";
|
|
8
9
|
import { nearestPointOnPath, type MotionNodeRef } from "./motionPathGeometry";
|
|
@@ -141,7 +142,8 @@ export const MotionPathOverlay = memo(function MotionPathOverlay({
|
|
|
141
142
|
// No one-element selector means the path could only be authored onto the
|
|
142
143
|
// element's class siblings, so the toolbar toggle stays hidden instead of
|
|
143
144
|
// arming a press that the effect below would silently drop.
|
|
144
|
-
const
|
|
145
|
+
const livePreviewIframe = useLivePreviewIframe();
|
|
146
|
+
const canCreate = createMode && !!createSelector && hasMotionPathPlugin(livePreviewIframe);
|
|
145
147
|
|
|
146
148
|
// Publish whether the selected element can take a path so the preview toolbar
|
|
147
149
|
// shows its "set destination" toggle. Drops to false when this overlay unmounts
|
|
@@ -4,6 +4,7 @@ import React, { act } from "react";
|
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { PreviewGuides } from "./PreviewGuides";
|
|
7
|
+
import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
|
|
7
8
|
import { usePreviewGuidesStore } from "./previewGuidesStore";
|
|
8
9
|
|
|
9
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -26,7 +27,11 @@ function render(rulerVisible: boolean, safeMarginsVisible: boolean) {
|
|
|
26
27
|
const host = document.createElement("div");
|
|
27
28
|
document.body.append(host);
|
|
28
29
|
act(() => {
|
|
29
|
-
createRoot(host).render(
|
|
30
|
+
createRoot(host).render(
|
|
31
|
+
<PreviewOverlayProvider>
|
|
32
|
+
<PreviewGuides />
|
|
33
|
+
</PreviewOverlayProvider>,
|
|
34
|
+
);
|
|
30
35
|
});
|
|
31
36
|
return host;
|
|
32
37
|
}
|
|
@@ -1,30 +1,28 @@
|
|
|
1
1
|
import { memo, useRef, type RefObject } from "react";
|
|
2
2
|
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
3
|
-
import { RULER_GUTTER_PX
|
|
3
|
+
import { RULER_GUTTER_PX } from "./previewGuidesStore";
|
|
4
4
|
import { SAFE_BOX_PERCENTS, safeBoxInsetPercent } from "../../utils/previewSafeMargins";
|
|
5
|
-
|
|
6
|
-
interface PreviewGuidesProps {
|
|
7
|
-
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
8
|
-
}
|
|
5
|
+
import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
|
|
9
6
|
|
|
10
7
|
const TICKS = Array.from({ length: 11 }, (_, i) => i * 10);
|
|
11
8
|
const INK = "color-mix(in srgb, white 70%, transparent)";
|
|
12
9
|
|
|
13
10
|
/** Ruler and safe-margin boxes drawn over the preview pane, never inside the composition. */
|
|
14
|
-
export const PreviewGuides = memo(function PreviewGuides(
|
|
15
|
-
const
|
|
16
|
-
const safeMarginsVisible =
|
|
11
|
+
export const PreviewGuides = memo(function PreviewGuides() {
|
|
12
|
+
const { state } = usePreviewOverlayContext();
|
|
13
|
+
const { rulerVisible, safeMarginsVisible } = state;
|
|
17
14
|
if (!rulerVisible && !safeMarginsVisible) return null;
|
|
18
15
|
return (
|
|
19
16
|
<ActiveGuides
|
|
20
|
-
iframeRef={iframeRef}
|
|
17
|
+
iframeRef={state.iframeRef}
|
|
21
18
|
rulerVisible={rulerVisible}
|
|
22
19
|
safeMarginsVisible={safeMarginsVisible}
|
|
23
20
|
/>
|
|
24
21
|
);
|
|
25
22
|
});
|
|
26
23
|
|
|
27
|
-
interface ActiveGuidesProps
|
|
24
|
+
interface ActiveGuidesProps {
|
|
25
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
28
26
|
rulerVisible: boolean;
|
|
29
27
|
safeMarginsVisible: boolean;
|
|
30
28
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { GridOverlay } from "./GridOverlay";
|
|
7
|
+
import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
|
|
8
|
+
import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
|
|
9
|
+
|
|
10
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = "";
|
|
14
|
+
window.localStorage.clear();
|
|
15
|
+
usePreviewIframeStore.getState().setIframe(null);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function compositionFrame() {
|
|
19
|
+
const iframe = document.createElement("iframe");
|
|
20
|
+
iframe.getBoundingClientRect = () => new DOMRect(12, 18, 640, 360);
|
|
21
|
+
const root = iframe.contentDocument?.createElement("div");
|
|
22
|
+
root?.setAttribute("data-composition-id", "main");
|
|
23
|
+
root?.setAttribute("data-width", "1280");
|
|
24
|
+
root?.setAttribute("data-height", "720");
|
|
25
|
+
if (root) iframe.contentDocument?.body.append(root);
|
|
26
|
+
document.body.append(iframe);
|
|
27
|
+
return iframe;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("PreviewOverlayProvider", () => {
|
|
31
|
+
it("derives grid geometry after the iframe prop becomes available", async () => {
|
|
32
|
+
window.localStorage.setItem(
|
|
33
|
+
"hf-studio-ui-preferences",
|
|
34
|
+
JSON.stringify({ gridVisible: true, gridSpacing: 20 }),
|
|
35
|
+
);
|
|
36
|
+
const host = document.createElement("div");
|
|
37
|
+
document.body.append(host);
|
|
38
|
+
const root = createRoot(host);
|
|
39
|
+
|
|
40
|
+
act(() => {
|
|
41
|
+
root.render(
|
|
42
|
+
<PreviewOverlayProvider iframe={null}>
|
|
43
|
+
<GridOverlay />
|
|
44
|
+
</PreviewOverlayProvider>,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
expect(host.querySelector('[aria-hidden="true"]')).not.toBeNull();
|
|
48
|
+
|
|
49
|
+
const iframe = compositionFrame();
|
|
50
|
+
act(() => {
|
|
51
|
+
root.render(
|
|
52
|
+
<PreviewOverlayProvider iframe={iframe}>
|
|
53
|
+
<GridOverlay />
|
|
54
|
+
</PreviewOverlayProvider>,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
await act(async () => {
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const grid = host.querySelector<HTMLElement>('[aria-hidden="true"]');
|
|
62
|
+
expect([grid?.style.left, grid?.style.top, grid?.style.width, grid?.style.height]).toEqual([
|
|
63
|
+
"12px",
|
|
64
|
+
"18px",
|
|
65
|
+
"640px",
|
|
66
|
+
"360px",
|
|
67
|
+
]);
|
|
68
|
+
expect(grid?.style.backgroundSize).toBe("20px 20px");
|
|
69
|
+
act(() => root.unmount());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("does not clear an iframe store entry owned elsewhere on unmount", () => {
|
|
73
|
+
const ownerIframe = compositionFrame();
|
|
74
|
+
usePreviewIframeStore.getState().setIframe(ownerIframe);
|
|
75
|
+
const host = document.createElement("div");
|
|
76
|
+
document.body.append(host);
|
|
77
|
+
const root = createRoot(host);
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
root.render(
|
|
81
|
+
<PreviewOverlayProvider iframe={null}>
|
|
82
|
+
<GridOverlay />
|
|
83
|
+
</PreviewOverlayProvider>,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
act(() => root.unmount());
|
|
87
|
+
|
|
88
|
+
expect(usePreviewIframeStore.getState().iframe).toBe(ownerIframe);
|
|
89
|
+
});
|
|
90
|
+
});
|