@hyperframes/studio 0.8.115 → 0.8.116

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 (149) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-BoUM6Xqb.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-CAvZ0AA-.js → index-DYNaXPVf.js} +201 -201
  4. package/dist/assets/{index-CVuorIMv.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +104 -22
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1131 -1044
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  16. package/src/components/editor/domGroupMoveBy.ts +95 -0
  17. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  18. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  19. package/src/components/editor/manualEditingAvailability.ts +8 -0
  20. package/src/components/editor/manualOffsetDrag.ts +5 -6
  21. package/src/components/editor/motionPathSelection.test.ts +6 -31
  22. package/src/components/editor/probeSourceElement.ts +2 -1
  23. package/src/components/editor/propertyPanelFont.tsx +3 -2
  24. package/src/components/editor/useColorGradingController.ts +2 -1
  25. package/src/components/editor/useFxCarveNodes.ts +2 -1
  26. package/src/components/editor/useFxLevelling.ts +2 -1
  27. package/src/components/feedback/projectProvenance.ts +2 -1
  28. package/src/components/nle/NLEContext.test.ts +1 -0
  29. package/src/components/nle/NLEContext.tsx +2 -1
  30. package/src/components/nle/NLEPreview.tsx +12 -10
  31. package/src/components/nle/PreviewPoster.tsx +2 -1
  32. package/src/components/renders/useFfmpegStatus.ts +2 -1
  33. package/src/components/renders/useRenderQueue.ts +5 -4
  34. package/src/components/sidebar/AssetsTab.tsx +2 -1
  35. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  36. package/src/contexts/DomEditContext.tsx +4 -0
  37. package/src/hooks/domSelectionTestHarness.ts +23 -0
  38. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  39. package/src/hooks/domStyleCommit.ts +8 -1
  40. package/src/hooks/draggedGsapPosition.ts +10 -0
  41. package/src/hooks/gsapDragCommit.test.ts +75 -164
  42. package/src/hooks/gsapDragCommit.ts +2 -105
  43. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  44. package/src/hooks/gsapEditOutcome.ts +22 -2
  45. package/src/hooks/gsapGroupPreflight.ts +34 -0
  46. package/src/hooks/gsapMutationClient.ts +4 -3
  47. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  48. package/src/hooks/gsapParsedTween.test.ts +62 -0
  49. package/src/hooks/gsapParsedTween.ts +243 -0
  50. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  51. package/src/hooks/gsapPositionDetection.ts +52 -33
  52. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  53. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  54. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  55. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  56. package/src/hooks/gsapResizeIntercept.ts +79 -169
  57. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  58. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  59. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  60. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  61. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  62. package/src/hooks/gsapScriptCommitTypes.ts +8 -0
  63. package/src/hooks/gsapShared.ts +7 -0
  64. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  65. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  66. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  67. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  68. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  69. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  70. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  71. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  72. package/src/hooks/newTweenTarget.test.ts +41 -76
  73. package/src/hooks/timelineTimingSync.test.ts +1 -0
  74. package/src/hooks/timelineTimingSync.ts +2 -1
  75. package/src/hooks/useAskAgentModal.ts +2 -1
  76. package/src/hooks/useBlockCatalog.ts +2 -1
  77. package/src/hooks/useCaptionDetection.ts +2 -1
  78. package/src/hooks/useCompositionContentLoader.ts +2 -1
  79. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  80. package/src/hooks/useDomEditSession.test.tsx +30 -1
  81. package/src/hooks/useDomEditSession.ts +10 -0
  82. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  83. package/src/hooks/useDomEditTextCommits.ts +8 -2
  84. package/src/hooks/useDomGeometryCommit.ts +1 -0
  85. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  86. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  87. package/src/hooks/useEditHistoryActions.ts +16 -5
  88. package/src/hooks/useElementLifecycleOps.ts +2 -1
  89. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  90. package/src/hooks/useEnableKeyframes.ts +2 -1
  91. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  92. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  93. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  94. package/src/hooks/useFileManager.ts +14 -10
  95. package/src/hooks/useFileTree.ts +3 -2
  96. package/src/hooks/useFrameCapture.ts +5 -1
  97. package/src/hooks/useFreezeFrame.ts +3 -2
  98. package/src/hooks/useGroupCommits.ts +2 -1
  99. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  100. package/src/hooks/useGsapAwareEditing.test.tsx +17 -29
  101. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  102. package/src/hooks/useGsapAwareEditing.ts +5 -0
  103. package/src/hooks/useGsapAwareGroupMove.ts +16 -16
  104. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  105. package/src/hooks/useGsapScriptCommits.test.tsx +3 -1
  106. package/src/hooks/useGsapScriptCommits.ts +12 -10
  107. package/src/hooks/useLintModal.ts +2 -1
  108. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  109. package/src/hooks/usePersistentEditHistory.ts +47 -8
  110. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  111. package/src/hooks/usePreviewPersistence.ts +13 -11
  112. package/src/hooks/useProjectFileWriter.ts +20 -19
  113. package/src/hooks/useRemoveBackground.ts +2 -1
  114. package/src/hooks/useSdkSession.ts +2 -1
  115. package/src/hooks/useServerConnection.ts +4 -3
  116. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  117. package/src/hooks/useThumbnailStill.test.ts +100 -0
  118. package/src/hooks/useThumbnailStill.ts +37 -0
  119. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  120. package/src/index.ts +4 -0
  121. package/src/player/components/AudioWaveform.tsx +3 -2
  122. package/src/player/components/CompositionThumbnail.tsx +2 -1
  123. package/src/player/components/clipMenuNormalize.ts +2 -1
  124. package/src/player/components/clipPeakMap.ts +2 -1
  125. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  126. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  127. package/src/player/lib/thumbnailScheduler.ts +10 -2
  128. package/src/player/sceneSwap.ts +2 -1
  129. package/src/telemetry/client.ts +2 -1
  130. package/src/utils/blockInstaller.ts +2 -1
  131. package/src/utils/elementGsap.test.ts +9 -0
  132. package/src/utils/elementGsap.ts +7 -0
  133. package/src/utils/gsapSoftReload.ts +2 -3
  134. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  135. package/src/utils/gsapUndoRestore.ts +3 -6
  136. package/src/utils/previewPrefetch.ts +2 -1
  137. package/src/utils/razorSplitTransaction.ts +12 -8
  138. package/src/utils/softReloadTargets.ts +1 -3
  139. package/src/utils/studioApiFetch.test.ts +75 -0
  140. package/src/utils/studioApiFetch.ts +13 -0
  141. package/src/utils/studioFileHistory.ts +4 -1
  142. package/src/utils/studioHelpers.ts +7 -1
  143. package/src/utils/studioPendingEdits.test.ts +23 -6
  144. package/src/utils/studioPendingEdits.ts +32 -8
  145. package/src/utils/studioTelemetry.ts +2 -1
  146. package/src/utils/timelineCompositionInsert.ts +3 -2
  147. package/src/webmcp/StudioAgentTools.tsx +2 -1
  148. package/dist/chunk-TXZMMOMA.js.map +0 -1
  149. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.115",
3
+ "version": "0.8.116",
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.115",
64
- "@hyperframes/player": "0.8.115",
65
- "@hyperframes/studio-server": "0.8.115",
66
- "@hyperframes/sdk": "0.8.115",
67
- "@hyperframes/shader-transitions": "0.8.115",
68
- "@hyperframes/parsers": "0.8.115"
63
+ "@hyperframes/parsers": "0.8.116",
64
+ "@hyperframes/player": "0.8.116",
65
+ "@hyperframes/core": "0.8.116",
66
+ "@hyperframes/sdk": "0.8.116",
67
+ "@hyperframes/studio-server": "0.8.116",
68
+ "@hyperframes/shader-transitions": "0.8.116"
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.115"
84
+ "@hyperframes/producer": "0.8.116"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -5,6 +5,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
5
5
  import { trackEvent } from "../../telemetry/client";
6
6
  import type { CaptionStyle } from "../types";
7
7
  import { studioWriteHeaders } from "../../utils/studioFileVersion";
8
+ import { studioApiFetch } from "../../utils/studioApiFetch";
8
9
 
9
10
  interface CaptionOverrideEntry {
10
11
  wordId?: string;
@@ -93,11 +94,14 @@ export function useCaptionSync(projectId: string | null) {
93
94
  const seqAtSave = editSeqRef.current;
94
95
  const overrides = buildOverrides(state.model);
95
96
 
96
- fetch(buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`), {
97
- method: "PUT",
98
- headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
99
- body: JSON.stringify(overrides, null, 2),
100
- })
97
+ studioApiFetch(
98
+ buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`),
99
+ {
100
+ method: "PUT",
101
+ headers: { "Content-Type": "text/plain", ...studioWriteHeaders() },
102
+ body: JSON.stringify(overrides, null, 2),
103
+ },
104
+ )
101
105
  .then((res) => {
102
106
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
103
107
  // A newer edit may have re-armed the debounce while this PUT was in
@@ -171,7 +175,7 @@ export function useCaptionSync(projectId: string | null) {
171
175
 
172
176
  let data: { content?: string };
173
177
  try {
174
- const res = await fetch(
178
+ const res = await studioApiFetch(
175
179
  buildProjectApiPath(pid, `/files/${encodeURIComponent("caption-overrides.json")}`),
176
180
  );
177
181
  if (!res.ok) return; // no overrides file yet — normal
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from "react";
2
+ import { studioApiFetch } from "../utils/studioApiFetch";
2
3
 
3
4
  interface ServedProject {
4
5
  id: string;
@@ -24,8 +25,7 @@ interface ProjectUnreachableBannerProps {
24
25
  *
25
26
  * The wording is deliberately narrower than "this project is gone". We can see
26
27
  * 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.
28
+ * one, and nothing here is lost.
29
29
  */
30
30
  export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBannerProps) {
31
31
  const [served, setServed] = useState<ServedProject[] | null>(null);
@@ -35,7 +35,7 @@ export function ProjectUnreachableBanner({ projectId }: ProjectUnreachableBanner
35
35
  // A list we could not read is not evidence of anything, so a failed fetch
36
36
  // leaves `served` empty and the banner falls back to the vague wording
37
37
  // rather than claiming this Studio serves nothing.
38
- fetch("/api/projects")
38
+ studioApiFetch("/api/projects")
39
39
  .then((res) => (res.ok ? res.json() : { projects: [] }))
40
40
  .then((data: { projects?: ServedProject[] }) => {
41
41
  if (!cancelled) setServed(data.projects ?? []);
@@ -0,0 +1,252 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "./domEditingTypes";
5
+ import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
6
+ import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
7
+ import { isStudioManualEditGestureLiveIn } from "./manualEditsDom";
8
+ import { readTranslatePx } from "./plainTranslate";
9
+ import { moveDomGroupBy } from "./domGroupMoveBy";
10
+ import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
11
+
12
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
13
+
14
+ const mocks = vi.hoisted(() => ({ drag: vi.fn() }));
15
+ vi.mock("../../hooks/gsapRuntimeBridge", () => ({
16
+ tryGsapDragIntercept: mocks.drag,
17
+ tryGsapRotationIntercept: vi.fn(),
18
+ }));
19
+ vi.mock("../../hooks/useAnimatedPropertyCommit", () => ({
20
+ useAnimatedPropertyCommit: () => ({
21
+ commitAnimatedProperty: vi.fn(),
22
+ commitAnimatedProperties: vi.fn(),
23
+ }),
24
+ }));
25
+ vi.mock("../../hooks/useSafeGsapCommitMutation", () => ({
26
+ useGsapSaveFailureTelemetry: () => vi.fn(),
27
+ useSafeGsapCommitMutation: (commit: unknown) => commit,
28
+ }));
29
+ vi.mock("../../utils/studioSaveDiagnostics", async (importOriginal) => ({
30
+ ...(await importOriginal<typeof import("../../utils/studioSaveDiagnostics")>()),
31
+ trackStudioEditBlocked: vi.fn(),
32
+ trackStudioSaveFailure: vi.fn(),
33
+ }));
34
+
35
+ import { mountGsapAwareEditing } from "../../hooks/useGsapAwareEditing.testHelpers";
36
+
37
+ afterEach(() => {
38
+ vi.clearAllMocks();
39
+ document.body.innerHTML = "";
40
+ });
41
+
42
+ /** A preview iframe drawn at half size: 200x100 composition px in a 100x50 box. */
43
+ function preview(): Document {
44
+ const iframe = document.createElement("iframe");
45
+ document.body.append(iframe);
46
+ const win = iframe.contentWindow!;
47
+ Object.defineProperty(win, "frameElement", { configurable: true, value: iframe });
48
+ Object.defineProperty(win, "innerWidth", { configurable: true, value: 200 });
49
+ Object.defineProperty(win, "innerHeight", { configurable: true, value: 100 });
50
+ iframe.getBoundingClientRect = () => new DOMRect(50, 40, 100, 50);
51
+ return iframe.contentDocument!;
52
+ }
53
+
54
+ const turned = (deg: number, scale: number) => {
55
+ const r = (deg * Math.PI) / 180;
56
+ return [scale * Math.cos(r), scale * Math.sin(r), -scale * Math.sin(r), scale * Math.cos(r)];
57
+ };
58
+
59
+ /** An element whose box centre is `at` plus its own move carried through its ancestors' linear map. */
60
+ function layer(doc: Document, id: string, parent = [1, 0, 0, 1], gsap?: object) {
61
+ const element = Object.assign(doc.createElement("div"), gsap ? { _gsap: gsap } : {});
62
+ doc.body.append(element);
63
+ const own = () => {
64
+ if (!gsap) return readTranslatePx(element);
65
+ const read = (prop: string) => Number.parseFloat(element.style.getPropertyValue(prop)) || 0;
66
+ return { x: read(STUDIO_OFFSET_X_PROP), y: read(STUDIO_OFFSET_Y_PROP) };
67
+ };
68
+ element.getBoundingClientRect = () => {
69
+ const { x, y } = own();
70
+ const [a, b, c, d] = parent as [number, number, number, number];
71
+ return new DOMRect(60 + a * x + c * y - 20, 40 + b * x + d * y - 10, 40, 20);
72
+ };
73
+ const selection = {
74
+ element,
75
+ id,
76
+ selector: `#${id}`,
77
+ label: id,
78
+ capabilities: { canApplyManualOffset: true },
79
+ } as unknown as DomEditSelection;
80
+ const centre = () => {
81
+ const r = element.getBoundingClientRect();
82
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
83
+ };
84
+ return { element, selection, centre };
85
+ }
86
+
87
+ function mountGroupCommit() {
88
+ const showToast = vi.fn();
89
+ const save = vi.fn().mockResolvedValue(undefined);
90
+ const stageElementPositionOffset = vi.fn(() => ({ save, rollback: vi.fn() }));
91
+ const { editing, root } = mountGsapAwareEditing({ showToast, stageElementPositionOffset });
92
+ const commit = (u: DomEditGroupPathOffsetCommit[], o?: { refusalToast?: boolean }) =>
93
+ editing().handleGsapAwareGroupPathOffsetCommit(u, o);
94
+ const moveBy = (moves: Parameters<typeof moveDomGroupBy>[0]) =>
95
+ act(() => moveDomGroupBy(moves, (u) => commit(u, { refusalToast: false })));
96
+ return {
97
+ commit: (u: DomEditGroupPathOffsetCommit[]) => commit(u),
98
+ moveBy,
99
+ showToast,
100
+ stageElementPositionOffset,
101
+ root,
102
+ };
103
+ }
104
+
105
+ const moved = (from: { x: number; y: number }, to: { x: number; y: number }) => ({
106
+ x: to.x - from.x,
107
+ y: to.y - from.y,
108
+ });
109
+
110
+ describe("handleDomGroupMoveBy", () => {
111
+ it("moves each box by its delta in composition px, saved under one undo entry", async () => {
112
+ const doc = preview();
113
+ const offset = layer(doc, "offset");
114
+ offset.element.style.setProperty("translate", "30px 0px");
115
+ const halved = layer(doc, "halved", [0.5, 0, 0, 0.5]);
116
+ const before = [offset.centre(), halved.centre()];
117
+ const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit();
118
+
119
+ await moveBy([
120
+ { selection: offset.selection, delta: { x: 10, y: 0 } },
121
+ { selection: halved.selection, delta: { x: 0, y: -5 } },
122
+ ]);
123
+
124
+ expect(moved(before[0]!, offset.centre())).toEqual({ x: 10, y: 0 });
125
+ expect(moved(before[1]!, halved.centre())).toEqual({ x: 0, y: -5 });
126
+ const calls = stageElementPositionOffset.mock.calls as unknown as unknown[][];
127
+ expect(calls.map((call) => call[1])).toEqual([
128
+ { x: 40, y: 0 },
129
+ { x: 0, y: -10 },
130
+ ]);
131
+ expect(calls[0]![3]).toMatch(/^group-drag:\d+$/);
132
+ expect(calls[1]![3]).toBe(calls[0]![3]);
133
+ expect(showToast).not.toHaveBeenCalled();
134
+ expect(isStudioManualEditGestureLiveIn(doc)).toBe(false);
135
+ act(() => root.unmount());
136
+ });
137
+
138
+ it("lands exactly under a half-scale parent with a rotated ancestor", async () => {
139
+ const doc = preview();
140
+ const tilted = layer(doc, "tilted", turned(30, 0.5));
141
+ const before = tilted.centre();
142
+ const { moveBy, root } = mountGroupCommit();
143
+
144
+ await moveBy([{ selection: tilted.selection, delta: { x: 10, y: 0 } }]);
145
+
146
+ const delta = moved(before, tilted.centre());
147
+ // Exact to the 0.001 px a translate is written with.
148
+ expect(delta.x).toBeCloseTo(10, 3);
149
+ expect(delta.y).toBeCloseTo(0, 3);
150
+ act(() => root.unmount());
151
+ });
152
+
153
+ it("moves a member GSAP positions through the GSAP write, planned and written at one delta", async () => {
154
+ mocks.drag.mockResolvedValue({ status: "persisted" });
155
+ const doc = preview();
156
+ const animated = layer(doc, "animated", [1, 0, 0, 1], { renderTransform: () => {} });
157
+ const before = animated.centre();
158
+ const { moveBy, stageElementPositionOffset, root } = mountGroupCommit();
159
+
160
+ await moveBy([{ selection: animated.selection, delta: { x: 10, y: 0 } }]);
161
+
162
+ const write = mocks.drag.mock.calls.find((call) => !call[6]?.preflightOnly);
163
+ expect(write?.[0]).toBe(animated.selection);
164
+ expect(write?.[1]).toEqual({ x: 10, y: 0 });
165
+ const plan = mocks.drag.mock.calls.find((call) => call[6]?.preflightOnly);
166
+ expect(plan?.[1]).toEqual(write?.[1]);
167
+ expect(moved(before, animated.centre())).toEqual({ x: 10, y: 0 });
168
+ expect(stageElementPositionOffset).not.toHaveBeenCalled();
169
+ act(() => root.unmount());
170
+ });
171
+
172
+ it("skips a zero delta, and writes nothing when every delta is zero", async () => {
173
+ const doc = preview();
174
+ const still = layer(doc, "still");
175
+ const { moveBy, stageElementPositionOffset, root } = mountGroupCommit();
176
+
177
+ await moveBy([{ selection: still.selection, delta: { x: 0, y: 0 } }]);
178
+
179
+ expect(stageElementPositionOffset).not.toHaveBeenCalled();
180
+ act(() => root.unmount());
181
+ });
182
+
183
+ it.each([
184
+ [
185
+ "a member that can't take a manual move",
186
+ (doc: Document) => {
187
+ const locked = layer(doc, "locked");
188
+ Object.assign(locked.selection.capabilities, {
189
+ canApplyManualOffset: false,
190
+ reasonIfDisabled: "Locked by a helper loop.",
191
+ });
192
+ return locked;
193
+ },
194
+ /helper loop/,
195
+ ],
196
+ [
197
+ "a member an animation took over",
198
+ (doc: Document) => layer(doc, "folded", [1, 0, 0, 1], { x: "94px" }),
199
+ /animation took over/,
200
+ ],
201
+ [
202
+ "a member the GSAP write refuses",
203
+ (doc: Document) => {
204
+ mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" });
205
+ return layer(doc, "blocked", [1, 0, 0, 1], { renderTransform: () => {} });
206
+ },
207
+ new GsapEditBlockedError("no-selector").message,
208
+ ],
209
+ ])("refuses %s before any write, without a Studio toast", async (_, make, reason) => {
210
+ const doc = preview();
211
+ const fine = layer(doc, "fine");
212
+ const refused = make(doc);
213
+ const before = [fine.centre(), refused.centre()];
214
+ const { moveBy, stageElementPositionOffset, showToast, root } = mountGroupCommit();
215
+
216
+ await expect(
217
+ moveBy([
218
+ { selection: fine.selection, delta: { x: 10, y: 0 } },
219
+ { selection: refused.selection, delta: { x: 10, y: 0 } },
220
+ ]),
221
+ ).rejects.toThrow(reason);
222
+
223
+ expect(stageElementPositionOffset).not.toHaveBeenCalled();
224
+ expect(mocks.drag.mock.calls.some((call) => !call[6]?.preflightOnly)).toBe(false);
225
+ expect(showToast).not.toHaveBeenCalled();
226
+ expect([fine.centre(), refused.centre()]).toEqual(before);
227
+ expect(isStudioManualEditGestureLiveIn(doc)).toBe(false);
228
+ act(() => root.unmount());
229
+ });
230
+
231
+ it.each([
232
+ ["an animation took over", () => layer(preview(), "folded", [1, 0, 0, 1], { x: "94px" }), true],
233
+ [
234
+ "the GSAP write refuses",
235
+ () => {
236
+ mocks.drag.mockResolvedValue({ status: "blocked", reason: "no-selector" });
237
+ return layer(preview(), "blocked", [1, 0, 0, 1], { renderTransform: () => {} });
238
+ },
239
+ false,
240
+ ],
241
+ ])("still toasts on a canvas group drag when %s", async (_, make, plainTranslate) => {
242
+ const { selection } = make();
243
+ const { commit, showToast, root } = mountGroupCommit();
244
+
245
+ await expect(
246
+ act(() => commit([{ selection, next: { x: 1, y: 2 }, plainTranslate }])),
247
+ ).rejects.toThrow();
248
+
249
+ expect(showToast).toHaveBeenCalledWith(expect.any(String), "error");
250
+ act(() => root.unmount());
251
+ });
252
+ });
@@ -0,0 +1,95 @@
1
+ import type { DomEditSelection } from "./domEditing";
2
+ import type { DomEditGroupPathOffsetCommit } from "./domEditOverlayGestures";
3
+ import { canCanvasNudgeTargets } from "./domEditNudge";
4
+ import { isStudioManualEditGestureCurrent, restoreStudioPathOffset } from "./manualEdits";
5
+ import { countStudioManualEditSave } from "./manualEditsDom";
6
+ import {
7
+ applyManualOffsetDragCommit,
8
+ createManualOffsetDragMember,
9
+ endManualOffsetDragMembers,
10
+ restoreManualOffsetDragMembers,
11
+ toTopScreenPoint,
12
+ type ManualOffsetDragMember,
13
+ } from "./manualOffsetDrag";
14
+
15
+ export interface DomGroupMove {
16
+ selection: DomEditSelection;
17
+ /** Composition px: the element's box moves by exactly this in the film. */
18
+ delta: { x: number; y: number };
19
+ }
20
+
21
+ /** Composition px to the top page's px; the preview frames only scale and shift. */
22
+ function toScreen(doc: Document, delta: { x: number; y: number }) {
23
+ const origin = toTopScreenPoint(doc, { x: 0, y: 0 });
24
+ const moved = toTopScreenPoint(doc, delta);
25
+ if (!origin || !moved) throw new Error("The preview has no measurable size.");
26
+ return { x: moved.x - origin.x, y: moved.y - origin.y };
27
+ }
28
+
29
+ function memberFor(
30
+ selection: DomEditSelection,
31
+ scale: { x: number; y: number },
32
+ ): ManualOffsetDragMember {
33
+ const { element } = selection;
34
+ const box = element.getBoundingClientRect();
35
+ const result = createManualOffsetDragMember({
36
+ key: selection.id ?? selection.selector ?? selection.label,
37
+ selection,
38
+ element,
39
+ rect: {
40
+ left: box.left,
41
+ top: box.top,
42
+ width: box.width,
43
+ height: box.height,
44
+ editScaleX: scale.x,
45
+ editScaleY: scale.y,
46
+ },
47
+ gesture: "drag",
48
+ });
49
+ if (!result.ok) throw new Error(result.reason);
50
+ return result.member;
51
+ }
52
+
53
+ /**
54
+ * Moves each element by its delta as one group drag would drop it: one save, one undo entry.
55
+ * Any member that can't move refuses the whole call before anything is drawn or written.
56
+ */
57
+ export async function moveDomGroupBy(
58
+ moves: DomGroupMove[],
59
+ commitGroup: (updates: DomEditGroupPathOffsetCommit[]) => Promise<unknown>,
60
+ ): Promise<void> {
61
+ const moving = moves.filter(({ delta }) => delta.x !== 0 || delta.y !== 0);
62
+ if (moving.length === 0) return;
63
+ const selections = moving.map(({ selection }) => selection);
64
+ if (!canCanvasNudgeTargets(selections)) {
65
+ const blocked = selections.find((s) => !s.capabilities.canApplyManualOffset);
66
+ throw new Error(blocked?.capabilities.reasonIfDisabled ?? `${blocked?.label} can't be moved.`);
67
+ }
68
+ const screens = moving.map(({ selection, delta }) =>
69
+ toScreen(selection.element.ownerDocument, delta),
70
+ );
71
+ const members: ManualOffsetDragMember[] = [];
72
+ try {
73
+ for (const selection of selections)
74
+ members.push(memberFor(selection, toScreen(selection.element.ownerDocument, { x: 1, y: 1 })));
75
+ } catch (error) {
76
+ restoreManualOffsetDragMembers(members);
77
+ throw error;
78
+ }
79
+ const updates = members.map((member, index) => ({
80
+ selection: member.selection,
81
+ next: applyManualOffsetDragCommit(member, screens[index]!.x, screens[index]!.y),
82
+ plainTranslate: member.plainTranslate,
83
+ }));
84
+ try {
85
+ await countStudioManualEditSave(members[0]!.element, () => commitGroup(updates));
86
+ } catch (error) {
87
+ for (const member of members) {
88
+ if (isStudioManualEditGestureCurrent(member.element, member.gestureToken))
89
+ restoreStudioPathOffset(member.element, member.initialPathOffset);
90
+ }
91
+ throw error;
92
+ } finally {
93
+ endManualOffsetDragMembers(members);
94
+ }
95
+ }
@@ -5,7 +5,7 @@ import type { GestureState } from "./domEditOverlayGestures";
5
5
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
6
6
  import {
7
7
  hasStudioPendingEdits,
8
- revertNewestStudioPendingEdit,
8
+ paintBackNewestStudioPendingEdit,
9
9
  } from "../../utils/studioPendingEdits";
10
10
 
11
11
  afterEach(() => {
@@ -59,13 +59,13 @@ it("a drag whose save is still running can be painted back at once, and shown ag
59
59
  const moved = element.style.getPropertyValue("translate");
60
60
  expect(moved).not.toBe("40px 30px");
61
61
 
62
- const showAgain = revertNewestStudioPendingEdit();
62
+ const shown = paintBackNewestStudioPendingEdit();
63
63
  expect(element.style.getPropertyValue("translate")).toBe("40px 30px");
64
- expect(revertNewestStudioPendingEdit()).toBeNull();
65
- showAgain!();
64
+ expect(paintBackNewestStudioPendingEdit()).toBeNull();
65
+ shown!.showAgain();
66
66
  expect(element.style.getPropertyValue("translate")).toBe(moved);
67
67
 
68
68
  saved();
69
69
  await vi.waitFor(() => expect(hasStudioPendingEdits()).toBe(false));
70
- expect(revertNewestStudioPendingEdit()).toBeNull();
70
+ expect(paintBackNewestStudioPendingEdit()).toBeNull();
71
71
  });
@@ -289,6 +289,39 @@ describe("a drag moves only with its own pointer pressed", () => {
289
289
  );
290
290
  });
291
291
 
292
+ describe("an outside change during a drag, on a preview that can swap scenes in place", () => {
293
+ it("leaves the dragged node in place and shows the change after the drop, loaded fresh", async () => {
294
+ const { live, overlay, box } = mountEditor(false);
295
+ const swapScenes = vi.fn((html: string) => {
296
+ live.contentDocument!.body.innerHTML = html.replace(/^[\s\S]*<body>|<\/body>[\s\S]*$/g, "");
297
+ });
298
+ Object.assign(live.contentWindow!, { __hfSwapScenes: swapScenes });
299
+ vi.stubGlobal(
300
+ "fetch",
301
+ async () => new Response(served("").contentDocument!.documentElement.outerHTML),
302
+ );
303
+ const dragged = byId(live, "title");
304
+ pointer(box, "pointerdown", 150, 150);
305
+ pointer(overlay, "pointermove", 190, 170);
306
+
307
+ act(() => api().refreshPlayer());
308
+ await settle();
309
+ expect(swapScenes, "swapped under the pointer").not.toHaveBeenCalled();
310
+ expect(byId(live, "title")).toBe(dragged);
311
+ const beforeDrop = await paintShadow(api, served("?_t=1"));
312
+ expect(api().iframeRef.current, "promoted under the pointer").toBe(live);
313
+
314
+ pointer(overlay, "pointerup", 190, 170);
315
+ await settle();
316
+ expect(file.title).toBe("translate: 40px 20px");
317
+ const fresh = served("?_t=2");
318
+ expect(await paintShadow(api, fresh)).toBeGreaterThan(beforeDrop);
319
+ expect(api().iframeRef.current).toBe(fresh);
320
+ expect(byId(fresh, "title").style.getPropertyValue("translate")).toBe("40px 20px");
321
+ vi.unstubAllGlobals();
322
+ });
323
+ });
324
+
292
325
  type Editor = ReturnType<typeof mountEditor>;
293
326
 
294
327
  describe("every way a drag ends without a drop clears its mark and promotes the held reload", () => {
@@ -78,4 +78,12 @@ export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
78
78
  true,
79
79
  );
80
80
 
81
+ // A host whose mounted API authenticates with same-origin cookies sets this; studioApiFetch then sends them, and its
82
+ // requests share the preview media's sockets again.
83
+ export const STUDIO_API_SAME_ORIGIN_CREDENTIALS = resolveStudioBooleanEnvFlag(
84
+ env,
85
+ ["VITE_STUDIO_API_SAME_ORIGIN_CREDENTIALS"],
86
+ false,
87
+ );
88
+
81
89
  import { resolveEnabledSdkFamilies } from "../../utils/sdkCutoverPolicy";
@@ -146,12 +146,12 @@ function getRectCenter(element: HTMLElement): Point | null {
146
146
  return null;
147
147
  }
148
148
 
149
- let point = {
150
- x: rect.left + rect.width / 2,
151
- y: rect.top + rect.height / 2,
152
- };
149
+ const point = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
150
+ return toTopScreenPoint(element.ownerDocument, point);
151
+ }
153
152
 
154
- let win: Window | null = element.ownerDocument.defaultView;
153
+ export function toTopScreenPoint(doc: Document, point: Point): Point | null {
154
+ let win: Window | null = doc.defaultView;
155
155
  while (win) {
156
156
  const frameElement = getFrameElement(win);
157
157
  if (!frameElement) break;
@@ -166,7 +166,6 @@ function getRectCenter(element: HTMLElement): Point | null {
166
166
  };
167
167
  win = frameElement.ownerDocument.defaultView;
168
168
  }
169
-
170
169
  return point;
171
170
  }
172
171
 
@@ -1,41 +1,16 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
- import type { DomEditSelection } from "./domEditingTypes";
4
- import { buildStableSelector, getSelectorIndex } from "./domEditingDom";
3
+ import { mountGroupSiblings, stableSelectionFor } from "../../hooks/domSelectionTestHarness";
5
4
  import { selectorFor } from "./motionPathSelection";
6
5
 
7
6
  afterEach(() => {
8
7
  document.body.innerHTML = "";
9
8
  });
10
9
 
11
- function selectionFor(el: HTMLElement): DomEditSelection {
12
- const selector = buildStableSelector(el);
13
- return {
14
- element: el,
15
- id: el.id || undefined,
16
- hfId: el.getAttribute("data-hf-id") || undefined,
17
- selector,
18
- selectorIndex: getSelectorIndex(document, el, selector, "index.html", null),
19
- sourceFile: "index.html",
20
- dataAttributes: { start: "0", duration: "2" },
21
- } as unknown as DomEditSelection;
22
- }
23
-
24
- function mountGroupSiblings(): HTMLElement[] {
25
- document.body.innerHTML = `
26
- <div id="scene" class="clip" data-start="0" data-duration="2">
27
- <div class="group"></div>
28
- <div class="group"></div>
29
- <div class="group"></div>
30
- </div>
31
- `;
32
- return Array.from(document.querySelectorAll<HTMLElement>(".group"));
33
- }
34
-
35
10
  describe("selectorFor", () => {
36
11
  it("addresses one element for a class-only sibling", () => {
37
- const groups = mountGroupSiblings();
38
- const selector = selectorFor(selectionFor(groups[2]!));
12
+ const groups = mountGroupSiblings(3);
13
+ const selector = selectorFor(stableSelectionFor(groups[2]!));
39
14
 
40
15
  // The bare ".group" both measured home off the FIRST sibling and wrote the
41
16
  // new motion path onto all three.
@@ -48,7 +23,7 @@ describe("selectorFor", () => {
48
23
  document.body.innerHTML = `<div id="hero"></div>`;
49
24
  const el = document.querySelector<HTMLElement>("#hero")!;
50
25
 
51
- expect(selectorFor(selectionFor(el))).toBe("#hero");
26
+ expect(selectorFor(stableSelectionFor(el))).toBe("#hero");
52
27
  });
53
28
 
54
29
  it("returns null with no selection", () => {
@@ -56,8 +31,8 @@ describe("selectorFor", () => {
56
31
  });
57
32
 
58
33
  it("returns null when no rung addresses one element", () => {
59
- const groups = mountGroupSiblings();
60
- const selection = selectionFor(groups[1]!);
34
+ const groups = mountGroupSiblings(3);
35
+ const selection = stableSelectionFor(groups[1]!);
61
36
  groups[1]!.remove();
62
37
 
63
38
  expect(selectorFor(selection)).toBeNull();
@@ -1,4 +1,5 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
+ import { studioApiFetch } from "../../utils/studioApiFetch";
2
3
 
3
4
  type ProbeTarget = { id?: string; hfId?: string; selector?: string; selectorIndex?: number };
4
5
 
@@ -12,7 +13,7 @@ const pending = new Map<string, PendingProbe[]>();
12
13
  async function sendBatch(projectId: string, sourceFile: string, probes: PendingProbe[]) {
13
14
  let exists: boolean[] = [];
14
15
  try {
15
- const response = await fetch(
16
+ const response = await studioApiFetch(
16
17
  buildProjectApiPath(
17
18
  projectId,
18
19
  `/file-mutations/probe-elements/${encodeURIComponent(sourceFile)}`,
@@ -20,6 +20,7 @@ import {
20
20
  } from "./propertyPanelHelpers";
21
21
  import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
22
22
  import { runWhenInputIdle } from "./overlayFrameLoop";
23
+ import { studioApiFetch } from "../../utils/studioApiFetch";
23
24
 
24
25
  /* ------------------------------------------------------------------ */
25
26
  /* Font helper functions */
@@ -135,7 +136,7 @@ let fontListsRequest: Promise<void> | null = null;
135
136
  const fontListListeners = new Set<() => void>();
136
137
 
137
138
  async function fetchFontList(url: string): Promise<string[]> {
138
- const data = (await (await fetch(url)).json()) as { fonts?: unknown };
139
+ const data = (await (await studioApiFetch(url)).json()) as { fonts?: unknown };
139
140
  if (!Array.isArray(data.fonts)) throw new Error(`${url} returned no font list`);
140
141
  return data.fonts as string[];
141
142
  }
@@ -356,7 +357,7 @@ export function FontFamilyField({
356
357
 
357
358
  const importSystemFont = async (family: string): Promise<ImportedFontAsset | null> => {
358
359
  if (!onImportFonts) return null;
359
- const response = await fetch(`/api/fonts/file?family=${encodeURIComponent(family)}`);
360
+ const response = await studioApiFetch(`/api/fonts/file?family=${encodeURIComponent(family)}`);
360
361
  if (!response.ok) return null;
361
362
  const blob = await response.blob();
362
363
  const ext = response.headers.get("Content-Disposition")?.match(/\.(\w+)"?$/)?.[1] ?? "ttf";