@hyperframes/studio 0.8.119 → 0.8.121

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/assets/{hyperframes-player-7Icl2uVN.js → hyperframes-player-GkN9yzzq.js} +1 -1
  2. package/dist/assets/{index-7YcUqkoh.js → index-BpKhiuzT.js} +1 -1
  3. package/dist/assets/{index-Dc8FEIEU.js → index-ByV72rWx.js} +1 -1
  4. package/dist/assets/{index-DOO2cYhl.js → index-CyFqMHGS.js} +174 -174
  5. package/dist/index.d.ts +5 -1
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +72 -87
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/components/EditorShell.tsx +6 -0
  11. package/src/components/nle/TimelinePane.tsx +3 -0
  12. package/src/hooks/fetchStubTestUtils.ts +3 -5
  13. package/src/hooks/gsapMutationClient.ts +17 -8
  14. package/src/hooks/gsapScriptCommitHelpers.ts +2 -64
  15. package/src/hooks/newTweenTarget.test.ts +3 -0
  16. package/src/hooks/useDomEditCommitsHelpers.test.ts +116 -0
  17. package/src/hooks/useDomEditCommitsHelpers.ts +44 -0
  18. package/src/hooks/useGsapAnimationOps.test.tsx +181 -1
  19. package/src/hooks/useGsapAnimationOps.ts +13 -12
  20. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  21. package/src/hooks/useGsapScriptCommits.ts +6 -32
  22. package/src/player/components/ClipContextMenu.test.tsx +31 -0
  23. package/src/player/components/ClipContextMenu.tsx +4 -1
  24. package/src/player/components/TimelineOverlays.test.ts +18 -0
  25. package/src/player/components/TimelineOverlays.tsx +1 -0
  26. package/src/player/components/TimelineProvider.tsx +1 -0
  27. package/src/player/components/TimelineTypes.ts +1 -0
  28. package/src/player/components/studioShortcuts.ts +3 -1
  29. 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.119",
3
+ "version": "0.8.121",
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/parsers": "0.8.119",
64
- "@hyperframes/shader-transitions": "0.8.119",
65
- "@hyperframes/studio-server": "0.8.119",
66
- "@hyperframes/sdk": "0.8.119",
67
- "@hyperframes/player": "0.8.119",
68
- "@hyperframes/core": "0.8.119"
63
+ "@hyperframes/core": "0.8.121",
64
+ "@hyperframes/sdk": "0.8.121",
65
+ "@hyperframes/shader-transitions": "0.8.121",
66
+ "@hyperframes/player": "0.8.121",
67
+ "@hyperframes/studio-server": "0.8.121",
68
+ "@hyperframes/parsers": "0.8.121"
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.119"
84
+ "@hyperframes/producer": "0.8.121"
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
- // Shared helpers for test-side `fetch` stubs (timelineTimingSync.test.ts,
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: 200,
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 studioApiFetch(
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 to author a NEW tween against, minting an id on the element when
11
- * it has no address of its own.
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: "project" },
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);