@hyperframes/studio 0.8.118 → 0.8.120
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-CnpIuNRl.js → hyperframes-player-i4gqkhjz.js} +1 -1
- package/dist/assets/{index-BZ23-h1_.js → index-C7LHDM18.js} +1 -1
- package/dist/assets/{index-B4SbAADe.js → index-Ce3Lr-Gt.js} +174 -174
- package/dist/assets/{index-FeRU6G3G.js → index-uWOhRqxe.js} +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.html +1 -1
- package/dist/index.js +72 -87
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/components/EditorShell.tsx +6 -0
- package/src/components/nle/TimelinePane.tsx +3 -0
- package/src/hooks/fetchStubTestUtils.ts +3 -5
- package/src/hooks/gsapMutationClient.ts +17 -8
- package/src/hooks/gsapScriptCommitHelpers.ts +2 -64
- package/src/hooks/newTweenTarget.test.ts +3 -0
- package/src/hooks/useDomEditCommitsHelpers.test.ts +116 -0
- package/src/hooks/useDomEditCommitsHelpers.ts +44 -0
- package/src/hooks/useGsapAnimationOps.test.tsx +181 -1
- package/src/hooks/useGsapAnimationOps.ts +13 -12
- package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
- package/src/hooks/useGsapScriptCommits.ts +6 -32
- package/src/player/components/ClipContextMenu.test.tsx +31 -0
- package/src/player/components/ClipContextMenu.tsx +4 -1
- package/src/player/components/TimelineOverlays.test.ts +18 -0
- package/src/player/components/TimelineOverlays.tsx +1 -0
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTypes.ts +1 -0
- package/src/player/components/studioShortcuts.ts +3 -1
- package/src/player/components/useTimelineProviderState.tsx +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.120",
|
|
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/core": "0.8.
|
|
64
|
-
"@hyperframes/
|
|
65
|
-
"@hyperframes/
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/
|
|
63
|
+
"@hyperframes/core": "0.8.120",
|
|
64
|
+
"@hyperframes/parsers": "0.8.120",
|
|
65
|
+
"@hyperframes/sdk": "0.8.120",
|
|
66
|
+
"@hyperframes/shader-transitions": "0.8.120",
|
|
67
|
+
"@hyperframes/studio-server": "0.8.120",
|
|
68
|
+
"@hyperframes/player": "0.8.120"
|
|
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.120"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -59,6 +59,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
|
|
|
59
59
|
onDuplicateClip: () => Promise<boolean>;
|
|
60
60
|
canPasteClip: () => boolean;
|
|
61
61
|
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
62
|
+
splitShortcut?: TimelineProps["splitShortcut"];
|
|
62
63
|
setCompIdToSrc: (map: Map<string, string>) => void;
|
|
63
64
|
setCompositionLoading: (loading: boolean) => void;
|
|
64
65
|
shouldShowMotionPath: boolean;
|
|
@@ -111,6 +112,7 @@ export const EditorShell = memo(function EditorShell({
|
|
|
111
112
|
onDuplicateClip,
|
|
112
113
|
canPasteClip,
|
|
113
114
|
clipMenuItems,
|
|
115
|
+
splitShortcut,
|
|
114
116
|
setCompIdToSrc,
|
|
115
117
|
setCompositionLoading,
|
|
116
118
|
shouldShowMotionPath,
|
|
@@ -211,6 +213,7 @@ export const EditorShell = memo(function EditorShell({
|
|
|
211
213
|
onDuplicateClip={onDuplicateClip}
|
|
212
214
|
canPasteClip={canPasteClip}
|
|
213
215
|
clipMenuItems={clipMenuItems}
|
|
216
|
+
splitShortcut={splitShortcut}
|
|
214
217
|
previewOverlay={
|
|
215
218
|
<PreviewOverlays
|
|
216
219
|
shouldShowMotionPath={shouldShowMotionPath}
|
|
@@ -255,6 +258,7 @@ interface EditorShellBodyProps {
|
|
|
255
258
|
onDuplicateClip: () => Promise<boolean>;
|
|
256
259
|
canPasteClip: () => boolean;
|
|
257
260
|
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
261
|
+
splitShortcut?: TimelineProps["splitShortcut"];
|
|
258
262
|
}
|
|
259
263
|
|
|
260
264
|
function EditorShellBody({
|
|
@@ -276,6 +280,7 @@ function EditorShellBody({
|
|
|
276
280
|
onDuplicateClip,
|
|
277
281
|
canPasteClip,
|
|
278
282
|
clipMenuItems,
|
|
283
|
+
splitShortcut,
|
|
279
284
|
}: EditorShellBodyProps) {
|
|
280
285
|
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
|
|
281
286
|
|
|
@@ -329,6 +334,7 @@ function EditorShellBody({
|
|
|
329
334
|
onDuplicateClip={onDuplicateClip}
|
|
330
335
|
canPasteClip={canPasteClip}
|
|
331
336
|
clipMenuItems={clipMenuItems}
|
|
337
|
+
splitShortcut={splitShortcut}
|
|
332
338
|
onSelectTimelineElement={onSelectTimelineElement}
|
|
333
339
|
timelineFooter={
|
|
334
340
|
captionEditMode ? (
|
|
@@ -47,6 +47,7 @@ export interface TimelinePaneProps {
|
|
|
47
47
|
onDuplicateClip?: () => Promise<boolean>;
|
|
48
48
|
canPasteClip?: () => boolean;
|
|
49
49
|
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
50
|
+
splitShortcut?: TimelineProps["splitShortcut"];
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
export function TimelinePane({
|
|
@@ -67,6 +68,7 @@ export function TimelinePane({
|
|
|
67
68
|
onDuplicateClip,
|
|
68
69
|
canPasteClip,
|
|
69
70
|
clipMenuItems,
|
|
71
|
+
splitShortcut,
|
|
70
72
|
}: TimelinePaneProps) {
|
|
71
73
|
const {
|
|
72
74
|
seek,
|
|
@@ -123,6 +125,7 @@ export function TimelinePane({
|
|
|
123
125
|
onDuplicateClip={onDuplicateClip}
|
|
124
126
|
canPasteClip={canPasteClip}
|
|
125
127
|
clipMenuItems={clipMenuItems}
|
|
128
|
+
splitShortcut={splitShortcut}
|
|
126
129
|
/>
|
|
127
130
|
</div>
|
|
128
131
|
<AudioMeterStrip />
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
// useTimelineEditing.test.tsx): a JSON Response factory and a Request → URL
|
|
3
|
-
// normalizer. Test-only module — imported exclusively from *.test.* files.
|
|
1
|
+
// Test-only helpers for `fetch` stubs: a JSON Response factory and a Request → URL normalizer.
|
|
4
2
|
|
|
5
|
-
export function jsonResponse(body: unknown): Response {
|
|
3
|
+
export function jsonResponse(body: unknown, status = 200): Response {
|
|
6
4
|
return new Response(JSON.stringify(body), {
|
|
7
|
-
status
|
|
5
|
+
status,
|
|
8
6
|
headers: { "content-type": "application/json" },
|
|
9
7
|
});
|
|
10
8
|
}
|
|
@@ -88,6 +88,22 @@ function readMutationError(value: unknown, fallback: string): string {
|
|
|
88
88
|
return fallback;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
export function requestGsapMutation(
|
|
92
|
+
projectId: string,
|
|
93
|
+
route: "gsap-mutations" | "gsap-mutations-batch",
|
|
94
|
+
filePath: string,
|
|
95
|
+
body: unknown,
|
|
96
|
+
): Promise<Response> {
|
|
97
|
+
return studioApiFetch(
|
|
98
|
+
`/api/projects/${encodeURIComponent(projectId)}/${route}/${encodeURIComponent(filePath)}`,
|
|
99
|
+
{
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
102
|
+
body: JSON.stringify(body),
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
export async function postGsapMutation(
|
|
92
108
|
projectId: string,
|
|
93
109
|
filePath: string,
|
|
@@ -96,14 +112,7 @@ export async function postGsapMutation(
|
|
|
96
112
|
): Promise<GsapMutationStatus> {
|
|
97
113
|
let response: Response;
|
|
98
114
|
try {
|
|
99
|
-
response = await
|
|
100
|
-
`/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
|
|
101
|
-
{
|
|
102
|
-
method: "POST",
|
|
103
|
-
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
104
|
-
body: JSON.stringify(mutation),
|
|
105
|
-
},
|
|
106
|
-
);
|
|
115
|
+
response = await requestGsapMutation(projectId, "gsap-mutations", filePath, mutation);
|
|
107
116
|
} catch (error) {
|
|
108
117
|
throw new GsapPreviewConvergenceError(`${fallback}: mutation outcome unknown`, {
|
|
109
118
|
cause: error,
|
|
@@ -1,22 +1,11 @@
|
|
|
1
|
-
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
2
1
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
2
|
|
|
4
3
|
export { PROPERTY_DEFAULTS } from "./gsapShared";
|
|
5
4
|
import { idSelector, matchesExactlyOne } from "./gsapShared";
|
|
6
|
-
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
7
|
-
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
8
5
|
|
|
9
6
|
/**
|
|
10
|
-
* The selector
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `selection.selector` is only usable when it addresses ONE element:
|
|
14
|
-
* `buildStableSelector` hands back a bare class for an id-less element, so
|
|
15
|
-
* returning it unconditionally aimed "add animation" at every sibling sharing
|
|
16
|
-
* the class (the attribution blow-up that collapsed the timeline to one row,
|
|
17
|
-
* see writeTargetSelector). A non-unique selector falls through to the id mint
|
|
18
|
-
* below, which is the stronger fix here than a structural path: the id it writes
|
|
19
|
-
* back to the source also makes every later lookup for this element exact.
|
|
7
|
+
* The selector for a NEW tween; a shared-class selector would hit every sibling, so an
|
|
8
|
+
* element without a unique one gets an `autoId` proposal the server makes unique (`ensure-id`).
|
|
20
9
|
*/
|
|
21
10
|
export function ensureElementAddressable(selection: DomEditSelection): {
|
|
22
11
|
selector: string;
|
|
@@ -37,7 +26,6 @@ export function ensureElementAddressable(selection: DomEditSelection): {
|
|
|
37
26
|
n += 1;
|
|
38
27
|
id = `${tag}-${n}`;
|
|
39
28
|
}
|
|
40
|
-
el.setAttribute("id", id);
|
|
41
29
|
return { selector: idSelector(id), autoId: id };
|
|
42
30
|
}
|
|
43
31
|
|
|
@@ -87,53 +75,3 @@ export function formatGsapMutationRejectionToast(error: GsapMutationHttpError):
|
|
|
87
75
|
}
|
|
88
76
|
return `Couldn't save animation: ${error.message}`;
|
|
89
77
|
}
|
|
90
|
-
|
|
91
|
-
interface AssignAutoIdParams {
|
|
92
|
-
projectId: string;
|
|
93
|
-
targetPath: string;
|
|
94
|
-
selection: DomEditSelection;
|
|
95
|
-
autoId: string;
|
|
96
|
-
showToast?: (message: string, tone?: "error" | "info") => void;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export async function assignGsapTargetAutoIdIfNeeded({
|
|
100
|
-
projectId,
|
|
101
|
-
targetPath,
|
|
102
|
-
selection,
|
|
103
|
-
autoId,
|
|
104
|
-
showToast,
|
|
105
|
-
}: AssignAutoIdParams): Promise<boolean> {
|
|
106
|
-
const patchBody = {
|
|
107
|
-
target: {
|
|
108
|
-
id: selection.id,
|
|
109
|
-
hfId: selection.hfId,
|
|
110
|
-
selector: selection.selector,
|
|
111
|
-
selectorIndex: selection.selectorIndex,
|
|
112
|
-
},
|
|
113
|
-
operations: [{ type: "html-attribute", property: "id", value: autoId }],
|
|
114
|
-
};
|
|
115
|
-
const unsafePatchFields = findUnsafeDomPatchValues(patchBody);
|
|
116
|
-
if (unsafePatchFields.length > 0) {
|
|
117
|
-
showToast?.("Couldn't assign element id because the patch contains invalid values", "error");
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
const res = await studioApiFetch(
|
|
121
|
-
`/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
|
|
122
|
-
{
|
|
123
|
-
method: "POST",
|
|
124
|
-
headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
|
|
125
|
-
body: JSON.stringify(patchBody),
|
|
126
|
-
},
|
|
127
|
-
);
|
|
128
|
-
if (!res.ok) {
|
|
129
|
-
showToast?.(
|
|
130
|
-
formatGsapMutationRejectionToast(
|
|
131
|
-
new GsapMutationHttpError(res.status, await readJsonResponseBody(res)),
|
|
132
|
-
),
|
|
133
|
-
"error",
|
|
134
|
-
);
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
137
|
-
const data = (await res.json()) as { changed?: boolean };
|
|
138
|
-
return data.changed === true;
|
|
139
|
-
}
|
|
@@ -73,6 +73,9 @@ describe("ensureElementAddressable — add-animation button", () => {
|
|
|
73
73
|
const { selector, autoId } = ensureElementAddressable(selection);
|
|
74
74
|
|
|
75
75
|
expect(autoId).toBeTruthy();
|
|
76
|
+
expect(el.hasAttribute("id")).toBe(false);
|
|
77
|
+
// addGsapAnimation sets the id the server confirms; the proposal is free in the preview.
|
|
78
|
+
el.setAttribute("id", autoId!);
|
|
76
79
|
expect(document.querySelectorAll(selector)).toHaveLength(1);
|
|
77
80
|
expect(document.querySelector(selector)).toBe(el);
|
|
78
81
|
expect(attributedTo(selector)).toEqual([autoId]);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
4
|
+
import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
|
|
5
|
+
import { assignGsapTargetAutoIdIfNeeded } from "./useDomEditCommitsHelpers";
|
|
6
|
+
|
|
7
|
+
const selection = {
|
|
8
|
+
hfId: "hf-card",
|
|
9
|
+
selector: '[data-hf-id="hf-card"]',
|
|
10
|
+
selectorIndex: 0,
|
|
11
|
+
} as DomEditSelection;
|
|
12
|
+
|
|
13
|
+
function stubPatch(response: Response) {
|
|
14
|
+
const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
|
15
|
+
const url = requestUrl(input);
|
|
16
|
+
if (url.includes("/api/projects/p1/file-mutations/patch-element/index.html")) return response;
|
|
17
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
18
|
+
});
|
|
19
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
20
|
+
return fetchMock;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function assign(over: Partial<Parameters<typeof assignGsapTargetAutoIdIfNeeded>[0]> = {}) {
|
|
24
|
+
const showToast = vi.fn();
|
|
25
|
+
const result = assignGsapTargetAutoIdIfNeeded({
|
|
26
|
+
projectId: "p1",
|
|
27
|
+
targetPath: "index.html",
|
|
28
|
+
selection,
|
|
29
|
+
autoId: "hf-auto-1",
|
|
30
|
+
showToast,
|
|
31
|
+
...over,
|
|
32
|
+
});
|
|
33
|
+
return { result, showToast };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("assignGsapTargetAutoIdIfNeeded", () => {
|
|
37
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
38
|
+
|
|
39
|
+
it("asks the patch-element client to ensure the id and returns the one the file holds", async () => {
|
|
40
|
+
const fetchMock = stubPatch(jsonResponse({ changed: true, elementId: "hf-auto-1-2" }));
|
|
41
|
+
const { result } = assign();
|
|
42
|
+
|
|
43
|
+
await expect(result).resolves.toBe("hf-auto-1-2");
|
|
44
|
+
const [, init] = fetchMock.mock.calls[0]!;
|
|
45
|
+
expect(init?.method).toBe("POST");
|
|
46
|
+
expect(new Headers(init?.headers).get("X-Hyperframes-Write-Token")).toBeTruthy();
|
|
47
|
+
expect(JSON.parse(String(init?.body))).toEqual({
|
|
48
|
+
target: { hfId: "hf-card", selector: '[data-hf-id="hf-card"]', selectorIndex: 0 },
|
|
49
|
+
operations: [{ type: "ensure-id", property: "id", value: "hf-auto-1" }],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns the id the element already holds in the file", async () => {
|
|
54
|
+
stubPatch(jsonResponse({ changed: false, matched: true, elementId: "div" }));
|
|
55
|
+
await expect(assign().result).resolves.toBe("div");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it.each([
|
|
59
|
+
["the target is not in the file", { changed: false, matched: false }],
|
|
60
|
+
["the reply names no id", {}],
|
|
61
|
+
])("is null with a toast when %s", async (_name, body) => {
|
|
62
|
+
stubPatch(jsonResponse(body));
|
|
63
|
+
const { result, showToast } = assign();
|
|
64
|
+
|
|
65
|
+
await expect(result).resolves.toBeNull();
|
|
66
|
+
expect(showToast).toHaveBeenCalledWith(
|
|
67
|
+
"Couldn't assign element id: element not found in index.html",
|
|
68
|
+
"error",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("is null with the server's reason in a toast when the patch is refused", async () => {
|
|
73
|
+
stubPatch(
|
|
74
|
+
new Response(JSON.stringify({ error: "target not found" }), {
|
|
75
|
+
status: 409,
|
|
76
|
+
headers: { "content-type": "application/json" },
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
const { result, showToast } = assign();
|
|
80
|
+
|
|
81
|
+
await expect(result).resolves.toBeNull();
|
|
82
|
+
expect(showToast).toHaveBeenCalledWith("Couldn't save edit: target not found", "error");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("is null with a plain toast when the refusal has no JSON reason", async () => {
|
|
86
|
+
stubPatch(new Response("boom", { status: 500, headers: { "content-type": "text/plain" } }));
|
|
87
|
+
const { result, showToast } = assign();
|
|
88
|
+
|
|
89
|
+
await expect(result).resolves.toBeNull();
|
|
90
|
+
expect(showToast).toHaveBeenCalledWith("Couldn't save edit", "error");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("refuses a non-finite target before any request", async () => {
|
|
94
|
+
const fetchMock = stubPatch(jsonResponse({ changed: true }));
|
|
95
|
+
const { result, showToast } = assign({
|
|
96
|
+
selection: { ...selection, selectorIndex: Number.NaN },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await expect(result).resolves.toBeNull();
|
|
100
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
101
|
+
expect(showToast).toHaveBeenCalledWith(
|
|
102
|
+
"Couldn't assign element id because the patch contains invalid values",
|
|
103
|
+
"error",
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("lets a network failure through", async () => {
|
|
108
|
+
vi.stubGlobal(
|
|
109
|
+
"fetch",
|
|
110
|
+
vi.fn(async () => {
|
|
111
|
+
throw new TypeError("offline");
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
await expect(assign().result).rejects.toThrow("offline");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -8,6 +8,8 @@ import type { DomEditPatchBatch } from "./domEditCommitTypes";
|
|
|
8
8
|
import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
|
|
9
9
|
import { studioWriteHeaders } from "../utils/studioFileVersion";
|
|
10
10
|
import { studioApiFetch } from "../utils/studioApiFetch";
|
|
11
|
+
import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mutation";
|
|
12
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
11
13
|
|
|
12
14
|
export function formatUnsafeFieldList(fields: Array<{ path: string }>): string {
|
|
13
15
|
return fields.map((field) => field.path).join(", ");
|
|
@@ -169,6 +171,8 @@ export interface PatchElementResponse {
|
|
|
169
171
|
content?: string;
|
|
170
172
|
path?: string;
|
|
171
173
|
version?: string;
|
|
174
|
+
/** The patched element's id after the patch; absent when the target was not found. */
|
|
175
|
+
elementId?: string | null;
|
|
172
176
|
}
|
|
173
177
|
|
|
174
178
|
type ShowToast = (message: string, tone?: "error" | "info") => void;
|
|
@@ -199,6 +203,46 @@ export async function postPatchElement(
|
|
|
199
203
|
return (await response.json()) as PatchElementResponse;
|
|
200
204
|
}
|
|
201
205
|
|
|
206
|
+
interface AssignAutoIdParams {
|
|
207
|
+
projectId: string;
|
|
208
|
+
targetPath: string;
|
|
209
|
+
selection: DomEditSelection;
|
|
210
|
+
autoId: string;
|
|
211
|
+
showToast: ShowToast;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The id the file holds for the element after proposing `autoId` (the server keeps or dedupes it); null if none. */
|
|
215
|
+
export async function assignGsapTargetAutoIdIfNeeded({
|
|
216
|
+
projectId,
|
|
217
|
+
targetPath,
|
|
218
|
+
selection,
|
|
219
|
+
autoId,
|
|
220
|
+
showToast,
|
|
221
|
+
}: AssignAutoIdParams): Promise<string | null> {
|
|
222
|
+
const patchBody = {
|
|
223
|
+
target: {
|
|
224
|
+
id: selection.id,
|
|
225
|
+
hfId: selection.hfId,
|
|
226
|
+
selector: selection.selector,
|
|
227
|
+
selectorIndex: selection.selectorIndex,
|
|
228
|
+
},
|
|
229
|
+
operations: [{ type: "ensure-id", property: "id", value: autoId }],
|
|
230
|
+
};
|
|
231
|
+
if (findUnsafeDomPatchValues(patchBody).length > 0) {
|
|
232
|
+
showToast("Couldn't assign element id because the patch contains invalid values", "error");
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const { elementId } = await postPatchElement(projectId, targetPath, patchBody, showToast);
|
|
237
|
+
if (elementId) return elementId;
|
|
238
|
+
showToast(`Couldn't assign element id: element not found in ${targetPath}`, "error");
|
|
239
|
+
return null;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (error instanceof StudioSaveHttpError && error.alreadyToasted) return null;
|
|
242
|
+
throw error;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
202
246
|
export async function writePreparedContent(
|
|
203
247
|
targetPath: string,
|
|
204
248
|
patchedContent: string,
|
|
@@ -3,6 +3,8 @@ import { act } from "react";
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
6
|
+
import { patchElementInHtml } from "../../../studio-server/src/helpers/sourceMutation.js";
|
|
7
|
+
import { jsonResponse } from "./fetchStubTestUtils";
|
|
6
8
|
import { useGsapAnimationOps } from "./useGsapAnimationOps";
|
|
7
9
|
|
|
8
10
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -13,6 +15,8 @@ let cleanup: (() => void) | null = null;
|
|
|
13
15
|
afterEach(() => {
|
|
14
16
|
cleanup?.();
|
|
15
17
|
cleanup = null;
|
|
18
|
+
vi.unstubAllGlobals();
|
|
19
|
+
document.body.replaceChildren();
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
const selection = { id: "box", selector: "#box" } as DomEditSelection;
|
|
@@ -20,11 +24,12 @@ const selection = { id: "box", selector: "#box" } as DomEditSelection;
|
|
|
20
24
|
function renderOps(
|
|
21
25
|
commitMutationSafely: (...args: unknown[]) => Promise<void>,
|
|
22
26
|
commitMutation: (...args: unknown[]) => Promise<void> = vi.fn(async () => undefined),
|
|
27
|
+
projectId: string | null = "project",
|
|
23
28
|
): HookApi {
|
|
24
29
|
const captured: { api: HookApi | null } = { api: null };
|
|
25
30
|
function Probe() {
|
|
26
31
|
captured.api = useGsapAnimationOps({
|
|
27
|
-
projectIdRef: { current:
|
|
32
|
+
projectIdRef: { current: projectId },
|
|
28
33
|
activeCompPath: "index.html",
|
|
29
34
|
commitMutation,
|
|
30
35
|
commitMutationSafely,
|
|
@@ -42,6 +47,52 @@ function renderOps(
|
|
|
42
47
|
return captured.api;
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
const CARDS = `<div data-hf-id="hf-a" class="card"></div><div data-hf-id="hf-b" class="card"></div>`;
|
|
51
|
+
|
|
52
|
+
function mountCards(): HTMLElement[] {
|
|
53
|
+
document.body.innerHTML = CARDS;
|
|
54
|
+
return [...document.body.querySelectorAll<HTMLElement>(".card")];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addTo(api: HookApi, element: HTMLElement, hfId: string): Promise<void> {
|
|
58
|
+
return api.addGsapAnimation({ element, hfId } as unknown as DomEditSelection, "from");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The patch-element route over an in-memory copy of CARDS, patched by the server's own code. */
|
|
62
|
+
function serveCards() {
|
|
63
|
+
let html = CARDS;
|
|
64
|
+
return {
|
|
65
|
+
handle(init?: RequestInit): Response {
|
|
66
|
+
const { target, operations } = JSON.parse(String(init?.body));
|
|
67
|
+
const result = patchElementInHtml(html, target, operations);
|
|
68
|
+
const changed = result.html !== html;
|
|
69
|
+
html = result.html;
|
|
70
|
+
return jsonResponse({
|
|
71
|
+
ok: true,
|
|
72
|
+
changed,
|
|
73
|
+
matched: result.matched,
|
|
74
|
+
elementId: result.elementId,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
stub(route: (init: RequestInit | undefined, call: number) => Promise<Response>) {
|
|
78
|
+
let calls = 0;
|
|
79
|
+
vi.stubGlobal(
|
|
80
|
+
"fetch",
|
|
81
|
+
vi.fn(async (_input: unknown, init?: RequestInit) => route(init, ++calls)),
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
ids(): Record<string, string | null> {
|
|
85
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
86
|
+
return Object.fromEntries(
|
|
87
|
+
[...doc.querySelectorAll("[data-hf-id]")].map((el) => [
|
|
88
|
+
el.getAttribute("data-hf-id"),
|
|
89
|
+
el.getAttribute("id"),
|
|
90
|
+
]),
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
45
96
|
function deferredCommit() {
|
|
46
97
|
let release!: () => void;
|
|
47
98
|
const promise = new Promise<void>((resolve) => {
|
|
@@ -86,6 +137,135 @@ describe("useGsapAnimationOps settlement", () => {
|
|
|
86
137
|
);
|
|
87
138
|
});
|
|
88
139
|
|
|
140
|
+
it.each([
|
|
141
|
+
["saved", true, "div"],
|
|
142
|
+
["refused", false, null],
|
|
143
|
+
])(
|
|
144
|
+
"an id-less element takes the file's id only when the id write is %s",
|
|
145
|
+
async (_name, saved, id) => {
|
|
146
|
+
const server = serveCards();
|
|
147
|
+
const [element] = mountCards();
|
|
148
|
+
server.stub(async (init) =>
|
|
149
|
+
saved ? server.handle(init) : jsonResponse({ error: "file changed" }, 409),
|
|
150
|
+
);
|
|
151
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
152
|
+
const api = renderOps(
|
|
153
|
+
vi.fn(async () => undefined),
|
|
154
|
+
commitMutation,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
await addTo(api, element!, "hf-a");
|
|
158
|
+
|
|
159
|
+
expect(element!.getAttribute("id")).toBe(id);
|
|
160
|
+
expect(server.ids()).toEqual({ "hf-a": id, "hf-b": null });
|
|
161
|
+
expect(commitMutation).toHaveBeenCalledTimes(id ? 1 : 0);
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
it("a reply lost after the id is saved never lets a sibling reuse that id", async () => {
|
|
166
|
+
const server = serveCards();
|
|
167
|
+
const [first, second] = mountCards();
|
|
168
|
+
server.stub(async (init, call) => {
|
|
169
|
+
const reply = server.handle(init);
|
|
170
|
+
if (call === 1) throw new TypeError("connection reset");
|
|
171
|
+
return reply;
|
|
172
|
+
});
|
|
173
|
+
const commitMutation = vi.fn(async () => undefined);
|
|
174
|
+
const api = renderOps(
|
|
175
|
+
vi.fn(async () => undefined),
|
|
176
|
+
commitMutation,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await expect(addTo(api, first!, "hf-a")).rejects.toThrow("connection reset");
|
|
180
|
+
await addTo(api, second!, "hf-b");
|
|
181
|
+
|
|
182
|
+
expect(server.ids()).toEqual({ "hf-a": "div", "hf-b": "div-2" });
|
|
183
|
+
expect(second!.id).toBe("div-2");
|
|
184
|
+
expect(commitMutation).toHaveBeenCalledWith(
|
|
185
|
+
expect.anything(),
|
|
186
|
+
expect.objectContaining({ targetSelector: "#div-2" }),
|
|
187
|
+
expect.anything(),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
await addTo(api, first!, "hf-a");
|
|
191
|
+
|
|
192
|
+
expect(server.ids()).toEqual({ "hf-a": "div", "hf-b": "div-2" });
|
|
193
|
+
expect(first!.id).toBe("div");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("a refused add never strips the id an overlapping saved add gave the same element", async () => {
|
|
197
|
+
const server = serveCards();
|
|
198
|
+
const [element] = mountCards();
|
|
199
|
+
let release!: () => void;
|
|
200
|
+
const held = new Promise<void>((resolve) => {
|
|
201
|
+
release = resolve;
|
|
202
|
+
});
|
|
203
|
+
server.stub(async (init, call) => {
|
|
204
|
+
if (call !== 1) return server.handle(init);
|
|
205
|
+
await held;
|
|
206
|
+
return jsonResponse({ error: "file changed" }, 409);
|
|
207
|
+
});
|
|
208
|
+
const api = renderOps(vi.fn(async () => undefined));
|
|
209
|
+
|
|
210
|
+
const refused = addTo(api, element!, "hf-a");
|
|
211
|
+
await addTo(api, element!, "hf-a");
|
|
212
|
+
release();
|
|
213
|
+
await refused;
|
|
214
|
+
|
|
215
|
+
expect(server.ids()["hf-a"]).toBeTruthy();
|
|
216
|
+
expect(element!.id).toBe(server.ids()["hf-a"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("overlapping adds on siblings take the ids the file holds, never one id twice", async () => {
|
|
220
|
+
const server = serveCards();
|
|
221
|
+
const [first, second] = mountCards();
|
|
222
|
+
let release!: () => void;
|
|
223
|
+
const held = new Promise<void>((resolve) => {
|
|
224
|
+
release = resolve;
|
|
225
|
+
});
|
|
226
|
+
server.stub(async (init, call) => {
|
|
227
|
+
if (call === 1) await held;
|
|
228
|
+
return server.handle(init);
|
|
229
|
+
});
|
|
230
|
+
const api = renderOps(vi.fn(async () => undefined));
|
|
231
|
+
|
|
232
|
+
const adding = addTo(api, first!, "hf-a");
|
|
233
|
+
await addTo(api, second!, "hf-b");
|
|
234
|
+
release();
|
|
235
|
+
await adding;
|
|
236
|
+
|
|
237
|
+
const ids = server.ids();
|
|
238
|
+
expect(new Set([ids["hf-a"], ids["hf-b"]])).toEqual(new Set(["div", "div-2"]));
|
|
239
|
+
expect([first!.id, second!.id]).toEqual([ids["hf-a"], ids["hf-b"]]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("leaves no id when the id write never reaches the file", async () => {
|
|
243
|
+
const [element] = mountCards();
|
|
244
|
+
vi.stubGlobal(
|
|
245
|
+
"fetch",
|
|
246
|
+
vi.fn(async () => {
|
|
247
|
+
throw new TypeError("offline");
|
|
248
|
+
}),
|
|
249
|
+
);
|
|
250
|
+
const api = renderOps(vi.fn(async () => undefined));
|
|
251
|
+
|
|
252
|
+
await expect(addTo(api, element!, "hf-a")).rejects.toThrow("offline");
|
|
253
|
+
expect(element!.hasAttribute("id")).toBe(false);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("leaves no id when there is no project to save it to", async () => {
|
|
257
|
+
const [element] = mountCards();
|
|
258
|
+
const api = renderOps(
|
|
259
|
+
vi.fn(async () => undefined),
|
|
260
|
+
undefined,
|
|
261
|
+
null,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
await addTo(api, element!, "hf-a");
|
|
265
|
+
|
|
266
|
+
expect(element!.hasAttribute("id")).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
89
269
|
it("soft-reloads the preview when deleting an animation", async () => {
|
|
90
270
|
const commitMutationSafely = vi.fn(async () => undefined);
|
|
91
271
|
const api = renderOps(commitMutationSafely);
|