@hyperframes/studio 0.8.128 → 0.8.130
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-CKCx0CFA.js → hyperframes-player-Cm1Cj3TU.js} +1 -1
- package/dist/assets/{index-CwS08gWW.js → index--Z5JgizA.js} +1 -1
- package/dist/assets/{index-CF31Pq1E.js → index-DAp0QmWa.js} +1 -1
- package/dist/assets/{index-ClLzyuwb.js → index-DDsV6l9T.js} +191 -191
- package/dist/index.d.ts +61 -1
- package/dist/index.html +1 -1
- package/dist/index.js +227 -55
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/editor/LayersPanel.test.ts +2 -2
- package/src/components/editor/LayersPanel.tsx +35 -5
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +2 -2
- package/src/hooks/timelineTimingSync.ts +20 -21
- package/src/index.ts +2 -0
- package/src/layersPanelExport.test.tsx +115 -0
- package/src/utils/gsapLiveRetime.realGsap.test.ts +544 -0
- package/src/utils/gsapLiveRetime.ts +286 -0
- package/src/utils/gsapSoftReload.ts +16 -7
- package/src/utils/gsapUndoRestore.test.ts +43 -0
- package/src/utils/gsapUndoRestore.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.130",
|
|
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/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/parsers": "0.8.130",
|
|
64
|
+
"@hyperframes/player": "0.8.130",
|
|
65
|
+
"@hyperframes/core": "0.8.130",
|
|
66
|
+
"@hyperframes/sdk": "0.8.130",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.130",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.130"
|
|
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.130"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -21,12 +21,12 @@ const mocks = vi.hoisted(() => ({
|
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
23
|
vi.mock("../../contexts/StudioContext", () => ({
|
|
24
|
-
|
|
24
|
+
useStudioShellContextOptional: () => ({
|
|
25
25
|
previewIframeRef: mocks.previewIframeRef,
|
|
26
26
|
activeCompPath: "index.html",
|
|
27
27
|
showToast: mocks.showToast,
|
|
28
28
|
}),
|
|
29
|
-
|
|
29
|
+
useStudioPlaybackContextOptional: () => ({
|
|
30
30
|
refreshKey: 0,
|
|
31
31
|
compositionLoading: true,
|
|
32
32
|
timelineElements: [],
|
|
@@ -8,7 +8,12 @@ import {
|
|
|
8
8
|
resolveDomEditSelection,
|
|
9
9
|
type DomEditLayerItem,
|
|
10
10
|
} from "./domEditing";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
useStudioPlaybackContextOptional,
|
|
13
|
+
useStudioShellContextOptional,
|
|
14
|
+
type StudioPlaybackValue,
|
|
15
|
+
type StudioShellValue,
|
|
16
|
+
} from "../../contexts/StudioContext";
|
|
12
17
|
import { useDomEditContext } from "../../contexts/DomEditContext";
|
|
13
18
|
import { usePlayerStore, liveTime } from "../../player";
|
|
14
19
|
import {
|
|
@@ -94,11 +99,36 @@ interface CollapsedState {
|
|
|
94
99
|
[key: string]: boolean;
|
|
95
100
|
}
|
|
96
101
|
|
|
102
|
+
/** Studio state the panel reads; passed by hosts outside Studio's providers. Keep previewIframeRef stable,
|
|
103
|
+
* and bump refreshKey when the preview document is rebuilt without an iframe load. */
|
|
104
|
+
export type LayersPanelHost = Pick<
|
|
105
|
+
StudioShellValue,
|
|
106
|
+
"previewIframeRef" | "activeCompPath" | "showToast"
|
|
107
|
+
> &
|
|
108
|
+
Pick<StudioPlaybackValue, "timelineElements" | "isPlaying"> &
|
|
109
|
+
Partial<Pick<StudioPlaybackValue, "refreshKey" | "compositionLoading">>;
|
|
110
|
+
|
|
111
|
+
function useLayersPanelHost(host: LayersPanelHost | undefined): LayersPanelHost {
|
|
112
|
+
const shell = useStudioShellContextOptional();
|
|
113
|
+
const playback = useStudioPlaybackContextOptional();
|
|
114
|
+
if (host) return host;
|
|
115
|
+
if (!shell || !playback) {
|
|
116
|
+
throw new Error("LayersPanel needs a host prop outside Studio's shell and playback providers");
|
|
117
|
+
}
|
|
118
|
+
return { ...shell, ...playback };
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
// fallow-ignore-next-line complexity
|
|
98
|
-
export const LayersPanel = memo(function LayersPanel() {
|
|
99
|
-
const {
|
|
100
|
-
|
|
101
|
-
|
|
122
|
+
export const LayersPanel = memo(function LayersPanel({ host }: { host?: LayersPanelHost }) {
|
|
123
|
+
const {
|
|
124
|
+
previewIframeRef,
|
|
125
|
+
activeCompPath,
|
|
126
|
+
showToast,
|
|
127
|
+
refreshKey,
|
|
128
|
+
compositionLoading,
|
|
129
|
+
timelineElements,
|
|
130
|
+
isPlaying,
|
|
131
|
+
} = useLayersPanelHost(host);
|
|
102
132
|
const currentTime = usePlayerStore((s) => s.currentTime);
|
|
103
133
|
// Flashless z commits (canvas menu, timeline lane-drag z-sync) mutate iframe
|
|
104
134
|
// z-indexes with no reload and no refreshKey bump — while paused, nothing
|
|
@@ -145,8 +145,8 @@ function useMirrorLaneMoveCommit(): (
|
|
|
145
145
|
(selectionKey, coalesceKey, resolveMove) => {
|
|
146
146
|
const els = elementsRef.current;
|
|
147
147
|
const element = selectionKey ? els.find((e) => (e.key ?? e.id) === selectionKey) : undefined;
|
|
148
|
-
//
|
|
149
|
-
if (!element) return Promise.resolve(false);
|
|
148
|
+
// Canvas-only decoration, or no handler to save a lane move (no TimelineEditProvider): z-only.
|
|
149
|
+
if (!element || !onMoveElements) return Promise.resolve(false);
|
|
150
150
|
|
|
151
151
|
const move = resolveMove(element, els);
|
|
152
152
|
if (!move) return Promise.resolve(false);
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type StudioProjectFileWriter,
|
|
31
31
|
} from "../utils/studioFileMutationCoordinator";
|
|
32
32
|
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
33
|
+
import { moveLiveTweens } from "../utils/gsapLiveRetime";
|
|
33
34
|
|
|
34
35
|
export async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
|
35
36
|
if (targetPath.includes("\0") || targetPath.includes("..")) {
|
|
@@ -83,8 +84,8 @@ function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: numb
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
/**
|
|
86
|
-
* Sync the live preview after a
|
|
87
|
-
* soft reload
|
|
87
|
+
* Sync the live preview after a timing-only edit (move / resize): move the live tweens when only
|
|
88
|
+
* their timing changed, else a soft reload rather than the full reload that flashes every clip.
|
|
88
89
|
*
|
|
89
90
|
* Why this is safe WITHOUT re-deriving timeline elements: a move/resize commit has
|
|
90
91
|
* already (a) patched the live DOM timing attributes, (b) updated the store's
|
|
@@ -116,25 +117,32 @@ function rebindPreviewTiming(iframe: HTMLIFrameElement | null, currentTime: numb
|
|
|
116
117
|
* script is now stale, so a rebind against it would show wrong positions →
|
|
117
118
|
* full-reload.
|
|
118
119
|
*/
|
|
119
|
-
function syncTimingEditPreview(
|
|
120
|
+
async function syncTimingEditPreview(
|
|
120
121
|
iframe: HTMLIFrameElement | null,
|
|
121
122
|
outcome: GsapMutationStatus,
|
|
122
|
-
|
|
123
|
+
projectId: string | null,
|
|
123
124
|
reloadPreview: () => void,
|
|
124
125
|
rebindWhenUnmutated: boolean,
|
|
125
|
-
|
|
126
|
-
)
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const currentTime = () => usePlayerStore.getState().currentTime;
|
|
127
128
|
if (!outcome.mutated && rebindWhenUnmutated) {
|
|
128
|
-
if (!rebindPreviewTiming(iframe, currentTime)) reloadPreview();
|
|
129
|
+
if (!rebindPreviewTiming(iframe, currentTime())) reloadPreview();
|
|
129
130
|
return;
|
|
130
131
|
}
|
|
131
|
-
|
|
132
|
+
const { scriptText } = outcome;
|
|
133
|
+
if (!iframe || !scriptText) {
|
|
132
134
|
reloadPreview();
|
|
133
135
|
return;
|
|
134
136
|
}
|
|
135
|
-
|
|
137
|
+
if (moveLiveTweens(iframe, scriptText, currentTime(), reloadPreview)) return;
|
|
138
|
+
const nestedFiles = await settleNestedReads(
|
|
139
|
+
projectId
|
|
140
|
+
? readNestedFiles(iframe, scriptText, (path) => readFileContent(projectId, path))
|
|
141
|
+
: null,
|
|
142
|
+
);
|
|
143
|
+
const result = applySoftReload(iframe, scriptText, {
|
|
136
144
|
onAsyncFailure: reloadPreview,
|
|
137
|
-
currentTimeOverride: currentTime,
|
|
145
|
+
currentTimeOverride: currentTime(),
|
|
138
146
|
authoredHtml: outcome.after,
|
|
139
147
|
nestedFiles,
|
|
140
148
|
});
|
|
@@ -170,21 +178,12 @@ export async function finishTimelineTimingFallback(input: {
|
|
|
170
178
|
return;
|
|
171
179
|
}
|
|
172
180
|
}
|
|
173
|
-
|
|
174
|
-
const nestedFiles = await settleNestedReads(
|
|
175
|
-
projectId && outcome.scriptText
|
|
176
|
-
? readNestedFiles(input.iframe, outcome.scriptText, (path) =>
|
|
177
|
-
readFileContent(projectId, path),
|
|
178
|
-
)
|
|
179
|
-
: null,
|
|
180
|
-
);
|
|
181
|
-
syncTimingEditPreview(
|
|
181
|
+
await syncTimingEditPreview(
|
|
182
182
|
input.iframe,
|
|
183
183
|
outcome,
|
|
184
|
-
|
|
184
|
+
input.projectId,
|
|
185
185
|
input.reloadPreview,
|
|
186
186
|
input.rebindWhenUnmutated,
|
|
187
|
-
nestedFiles,
|
|
188
187
|
);
|
|
189
188
|
}
|
|
190
189
|
|
package/src/index.ts
CHANGED
|
@@ -206,6 +206,8 @@ export type { UsePreviewPersistenceParams } from "./hooks/usePreviewPersistence"
|
|
|
206
206
|
export { DomEditProvider, useDomEditSelectionContext } from "./contexts/DomEditContext";
|
|
207
207
|
export { PreviewReadOnlyProvider } from "./components/editor/previewReadOnlyContext";
|
|
208
208
|
export { ConnectedDomEditOverlay } from "./components/editor/ConnectedDomEditOverlay";
|
|
209
|
+
export { LayersPanel } from "./components/editor/LayersPanel";
|
|
210
|
+
export type { LayersPanelHost } from "./components/editor/LayersPanel";
|
|
209
211
|
export type { ConnectedDomEditOverlayProps } from "./components/editor/ConnectedDomEditOverlay";
|
|
210
212
|
export { useDomEditZOrder } from "./components/editor/useDomEditZOrder";
|
|
211
213
|
export type { DomEditZOrder } from "./components/editor/useDomEditZOrder";
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// Mounts the Layers panel by package name with no Studio shell, as a host app does.
|
|
3
|
+
import { act, type ReactNode } from "react";
|
|
4
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
DomEditProvider,
|
|
7
|
+
LayersPanel,
|
|
8
|
+
TimelineEditProvider,
|
|
9
|
+
usePlayerStore,
|
|
10
|
+
type LayersPanelHost,
|
|
11
|
+
} from "@hyperframes/studio";
|
|
12
|
+
import { installReactActEnvironment, mountReactHarness } from "./hooks/domSelectionTestHarness";
|
|
13
|
+
import { deriveTimelineStoreKeyForDomId } from "./player/lib/timelineElementHelpers";
|
|
14
|
+
|
|
15
|
+
installReactActEnvironment();
|
|
16
|
+
|
|
17
|
+
const flush = () => act(async () => {});
|
|
18
|
+
const rowLabels = () =>
|
|
19
|
+
[...document.querySelectorAll("[data-layer-index]")].map((row) => row.textContent);
|
|
20
|
+
const clip = (id: string, track: number) => {
|
|
21
|
+
const key = deriveTimelineStoreKeyForDomId(id, "index.html");
|
|
22
|
+
return { id: key, key, domId: id, tag: "div", start: 0, duration: 4, track };
|
|
23
|
+
};
|
|
24
|
+
const bgTrack = () => usePlayerStore.getState().elements.find((e) => e.domId === "bg")?.track;
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
usePlayerStore.setState({ elements: [] });
|
|
28
|
+
document.body.innerHTML = "";
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** The host's preview (title in front of bg, rows to match) and its edit session, mounted around the panel. */
|
|
32
|
+
async function mountPanel(wrap: (panel: ReactNode) => ReactNode = (panel) => panel) {
|
|
33
|
+
const iframe = document.createElement("iframe");
|
|
34
|
+
document.body.appendChild(iframe);
|
|
35
|
+
const doc = iframe.contentDocument!;
|
|
36
|
+
doc.body.innerHTML =
|
|
37
|
+
'<div data-composition-id="main"><div id="bg" style="z-index:1"></div><div id="title" style="z-index:2"></div></div>';
|
|
38
|
+
usePlayerStore.setState({ elements: [clip("title", 0), clip("bg", 1)] });
|
|
39
|
+
const session = {
|
|
40
|
+
domEditSelection: null,
|
|
41
|
+
activeGroupElement: null,
|
|
42
|
+
applyDomSelection: vi.fn(),
|
|
43
|
+
updateDomEditHoverSelection: vi.fn(),
|
|
44
|
+
handleDomZIndexReorderCommit: vi.fn(async () => ({
|
|
45
|
+
durable: true,
|
|
46
|
+
allMatched: true,
|
|
47
|
+
changed: true,
|
|
48
|
+
})),
|
|
49
|
+
setActiveGroupElement: vi.fn(),
|
|
50
|
+
} as unknown as Parameters<typeof DomEditProvider>[0]["value"];
|
|
51
|
+
const host: LayersPanelHost = {
|
|
52
|
+
previewIframeRef: { current: iframe },
|
|
53
|
+
activeCompPath: null,
|
|
54
|
+
showToast: vi.fn(),
|
|
55
|
+
timelineElements: usePlayerStore.getState().elements,
|
|
56
|
+
isPlaying: false,
|
|
57
|
+
};
|
|
58
|
+
const root = mountReactHarness(
|
|
59
|
+
<DomEditProvider value={session}>{wrap(<LayersPanel host={host} />)}</DomEditProvider>,
|
|
60
|
+
);
|
|
61
|
+
await flush();
|
|
62
|
+
return { doc, session, root };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Drags the back row (bg) above the front one. */
|
|
66
|
+
async function dragBackRowToFront() {
|
|
67
|
+
const rows = document.querySelectorAll<HTMLElement>("[data-layer-index]");
|
|
68
|
+
rows.forEach((row, i) =>
|
|
69
|
+
vi.spyOn(row, "getBoundingClientRect").mockReturnValue(new DOMRect(0, i * 24, 200, 24)),
|
|
70
|
+
);
|
|
71
|
+
const pointer = (type: string, clientY: number) =>
|
|
72
|
+
new PointerEvent(type, { bubbles: true, button: 0, pointerId: 1, clientX: 10, clientY });
|
|
73
|
+
const list = rows[0]!.parentElement!;
|
|
74
|
+
list.setPointerCapture = () => {};
|
|
75
|
+
act(() => void rows[1]!.dispatchEvent(pointer("pointerdown", 36)));
|
|
76
|
+
act(() => {
|
|
77
|
+
list.dispatchEvent(pointer("pointermove", 20));
|
|
78
|
+
list.dispatchEvent(pointer("pointermove", 2));
|
|
79
|
+
});
|
|
80
|
+
await act(async () => void list.dispatchEvent(pointer("pointerup", 2)));
|
|
81
|
+
await flush();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
it("lists, selects and reorders the host's preview layers through the host's session", async () => {
|
|
85
|
+
const { doc, session, root } = await mountPanel();
|
|
86
|
+
|
|
87
|
+
expect(rowLabels()).toEqual(["DiTitle", "DiBg"]);
|
|
88
|
+
|
|
89
|
+
const rows = document.querySelectorAll<HTMLElement>("[data-layer-index]");
|
|
90
|
+
await act(async () => rows[0]!.click());
|
|
91
|
+
expect(session.applyDomSelection).toHaveBeenCalledWith(
|
|
92
|
+
expect.objectContaining({ element: doc.getElementById("title") }),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
await dragBackRowToFront();
|
|
96
|
+
expect(session.handleDomZIndexReorderCommit).toHaveBeenCalledTimes(1);
|
|
97
|
+
const [entries] = vi.mocked(session.handleDomZIndexReorderCommit).mock.calls[0]!;
|
|
98
|
+
const z = Object.fromEntries(entries.map((e) => [e.element.id, e.zIndex]));
|
|
99
|
+
expect(z.bg).toBeGreaterThan(Number(doc.getElementById("title")!.style.zIndex));
|
|
100
|
+
// No TimelineEditProvider, so nothing can save a lane move: the row stays where the file says.
|
|
101
|
+
expect(bgTrack()).toBe(1);
|
|
102
|
+
await act(async () => root.unmount());
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("mirrors the reorder into the timeline rows through the host's move handler", async () => {
|
|
106
|
+
const onMoveElements = vi.fn(async () => {});
|
|
107
|
+
const { root } = await mountPanel((panel) => (
|
|
108
|
+
<TimelineEditProvider value={{ onMoveElements }}>{panel}</TimelineEditProvider>
|
|
109
|
+
));
|
|
110
|
+
|
|
111
|
+
await dragBackRowToFront();
|
|
112
|
+
expect(onMoveElements).toHaveBeenCalledTimes(1);
|
|
113
|
+
expect(bgTrack()).not.toBe(1);
|
|
114
|
+
await act(async () => root.unmount());
|
|
115
|
+
});
|