@hyperframes/studio 0.8.115 → 0.8.117

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 (163) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
  3. package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.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 +132 -41
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1184 -1084
  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/DomEditOverlay.tsx +1 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  17. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  18. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  19. package/src/components/editor/domGroupMoveBy.ts +95 -0
  20. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  21. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  22. package/src/components/editor/manualEditingAvailability.ts +8 -0
  23. package/src/components/editor/manualOffsetDrag.ts +8 -9
  24. package/src/components/editor/motionPathSelection.test.ts +6 -31
  25. package/src/components/editor/plainTranslate.ts +5 -3
  26. package/src/components/editor/probeSourceElement.ts +2 -1
  27. package/src/components/editor/propertyPanelFont.tsx +3 -2
  28. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  29. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  30. package/src/components/editor/rotationDraft.ts +2 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -1
  32. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  33. package/src/components/editor/useFxCarveNodes.ts +2 -1
  34. package/src/components/editor/useFxLevelling.ts +2 -1
  35. package/src/components/feedback/projectProvenance.ts +2 -1
  36. package/src/components/nle/NLEContext.test.ts +1 -0
  37. package/src/components/nle/NLEContext.tsx +2 -1
  38. package/src/components/nle/NLEPreview.tsx +12 -10
  39. package/src/components/nle/PreviewPoster.tsx +2 -1
  40. package/src/components/renders/useFfmpegStatus.ts +2 -1
  41. package/src/components/renders/useRenderQueue.ts +5 -4
  42. package/src/components/sidebar/AssetsTab.tsx +2 -1
  43. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  44. package/src/contexts/DomEditContext.tsx +4 -0
  45. package/src/hooks/domSelectionTestHarness.ts +23 -0
  46. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  47. package/src/hooks/domStyleCommit.ts +8 -1
  48. package/src/hooks/draggedGsapPosition.ts +10 -0
  49. package/src/hooks/gsapDragCommit.test.ts +75 -164
  50. package/src/hooks/gsapDragCommit.ts +2 -105
  51. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  52. package/src/hooks/gsapEditOutcome.ts +22 -2
  53. package/src/hooks/gsapGroupPreflight.ts +34 -0
  54. package/src/hooks/gsapMutationClient.ts +4 -3
  55. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  56. package/src/hooks/gsapParsedTween.test.ts +62 -0
  57. package/src/hooks/gsapParsedTween.ts +243 -0
  58. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  59. package/src/hooks/gsapPositionDetection.ts +52 -33
  60. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  61. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  62. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  63. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  64. package/src/hooks/gsapResizeIntercept.ts +79 -169
  65. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  66. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  67. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  68. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  69. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  70. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  71. package/src/hooks/gsapScriptCommitTypes.ts +9 -1
  72. package/src/hooks/gsapShared.ts +7 -0
  73. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  74. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  75. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  76. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  77. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  78. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  79. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  80. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  81. package/src/hooks/newTweenTarget.test.ts +41 -76
  82. package/src/hooks/plainRotation.ts +5 -1
  83. package/src/hooks/timelineTimingSync.test.ts +1 -0
  84. package/src/hooks/timelineTimingSync.ts +2 -1
  85. package/src/hooks/useAskAgentModal.ts +2 -1
  86. package/src/hooks/useBlockCatalog.ts +2 -1
  87. package/src/hooks/useCaptionDetection.ts +2 -1
  88. package/src/hooks/useCompositionContentLoader.ts +2 -1
  89. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  90. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  91. package/src/hooks/useDomEditSession.test.tsx +30 -1
  92. package/src/hooks/useDomEditSession.ts +10 -0
  93. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  94. package/src/hooks/useDomEditTextCommits.ts +8 -2
  95. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  96. package/src/hooks/useDomGeometryCommit.ts +7 -9
  97. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  98. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  99. package/src/hooks/useEditHistoryActions.ts +16 -5
  100. package/src/hooks/useElementLifecycleOps.ts +2 -1
  101. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  102. package/src/hooks/useEnableKeyframes.ts +2 -1
  103. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  104. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  105. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  106. package/src/hooks/useFileManager.ts +14 -10
  107. package/src/hooks/useFileTree.ts +3 -2
  108. package/src/hooks/useFrameCapture.ts +5 -1
  109. package/src/hooks/useFreezeFrame.test.ts +39 -3
  110. package/src/hooks/useFreezeFrame.ts +7 -3
  111. package/src/hooks/useGroupCommits.ts +2 -1
  112. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  113. package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
  114. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  115. package/src/hooks/useGsapAwareEditing.ts +17 -8
  116. package/src/hooks/useGsapAwareGroupMove.ts +18 -18
  117. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  118. package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
  119. package/src/hooks/useGsapScriptCommits.ts +21 -17
  120. package/src/hooks/useLintModal.ts +2 -1
  121. package/src/hooks/usePersistentEditHistory.test.ts +302 -3
  122. package/src/hooks/usePersistentEditHistory.ts +50 -17
  123. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  124. package/src/hooks/usePreviewPersistence.ts +13 -11
  125. package/src/hooks/useProjectFileWriter.ts +20 -19
  126. package/src/hooks/useRemoveBackground.ts +2 -1
  127. package/src/hooks/useSdkSession.ts +2 -1
  128. package/src/hooks/useServerConnection.ts +4 -3
  129. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  130. package/src/hooks/useThumbnailStill.test.ts +100 -0
  131. package/src/hooks/useThumbnailStill.ts +37 -0
  132. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  133. package/src/index.ts +4 -0
  134. package/src/player/components/AudioWaveform.tsx +3 -2
  135. package/src/player/components/CompositionThumbnail.tsx +2 -1
  136. package/src/player/components/clipMenuNormalize.ts +2 -1
  137. package/src/player/components/clipPeakMap.ts +2 -1
  138. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  139. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  140. package/src/player/lib/thumbnailScheduler.ts +10 -2
  141. package/src/player/sceneSwap.ts +2 -1
  142. package/src/telemetry/client.ts +2 -1
  143. package/src/utils/blockInstaller.ts +2 -1
  144. package/src/utils/elementGsap.test.ts +9 -0
  145. package/src/utils/elementGsap.ts +7 -0
  146. package/src/utils/gsapSoftReload.test.ts +39 -0
  147. package/src/utils/gsapSoftReload.ts +9 -14
  148. package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
  149. package/src/utils/gsapUndoRestore.ts +3 -6
  150. package/src/utils/previewPrefetch.ts +2 -1
  151. package/src/utils/razorSplitTransaction.ts +12 -8
  152. package/src/utils/softReloadTargets.ts +5 -6
  153. package/src/utils/studioApiFetch.test.ts +75 -0
  154. package/src/utils/studioApiFetch.ts +13 -0
  155. package/src/utils/studioFileHistory.ts +5 -1
  156. package/src/utils/studioHelpers.ts +7 -1
  157. package/src/utils/studioPendingEdits.test.ts +23 -6
  158. package/src/utils/studioPendingEdits.ts +32 -8
  159. package/src/utils/studioTelemetry.ts +2 -1
  160. package/src/utils/timelineCompositionInsert.ts +3 -2
  161. package/src/webmcp/StudioAgentTools.tsx +2 -1
  162. package/dist/chunk-TXZMMOMA.js.map +0 -1
  163. /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.117",
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/core": "0.8.117",
64
+ "@hyperframes/parsers": "0.8.117",
65
+ "@hyperframes/sdk": "0.8.117",
66
+ "@hyperframes/shader-transitions": "0.8.117",
67
+ "@hyperframes/studio-server": "0.8.117",
68
+ "@hyperframes/player": "0.8.117"
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.117"
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 ?? []);
@@ -94,6 +94,7 @@ export interface DomEditOverlayProps {
94
94
  next: { width: number; height: number },
95
95
  offset?: { x: number; y: number },
96
96
  restore?: () => void,
97
+ route?: { plainTranslate: boolean },
97
98
  ) => Promise<unknown> | void;
98
99
  onRotationCommit: (selection: DomEditSelection, next: RotationCommit) => Promise<unknown> | void;
99
100
  onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
@@ -275,6 +275,7 @@ export type UseDomEditOverlayGesturesOptions = {
275
275
  n: { width: number; height: number },
276
276
  offset?: { x: number; y: number },
277
277
  restore?: () => void,
278
+ route?: { plainTranslate: boolean },
278
279
  ) => Promise<unknown> | void
279
280
  >;
280
281
  onRotationCommitRef: RefObject<
@@ -32,7 +32,7 @@ import {
32
32
  type UseDomEditOverlayGesturesOptions,
33
33
  } from "./domEditOverlayGestures";
34
34
  import { collectSnapContext, buildExcludeElements } from "./snapTargetCollection";
35
- import { gsapWritesRotation } from "../../hooks/gsapRuntimeKeyframes";
35
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
36
36
  import { logResize, resetResizeMoveLog } from "../../utils/resizeDebug";
37
37
  import { logDrag, readDragPositions, resetDragMoveLog } from "../../utils/dragDebug";
38
38
 
@@ -234,7 +234,7 @@ export function startGesture(
234
234
  try {
235
235
  // Rotation base: the angle the element shows. An element GSAP does not turn, or a plain-translate
236
236
  // move, never asks GSAP: reading a property makes it bake the CSS into its transform.
237
- const plain = !!pathOffsetMember?.plainTranslate || !gsapWritesRotation(sel.element);
237
+ const plain = !!pathOffsetMember?.plainTranslate || editsPlainCss(sel.element, "rotate");
238
238
  const plainRotation = plain && kind === "rotate" ? readCssRotationTarget(sel.element) : null;
239
239
  const rotation = { angle: readRotationBase(sel.element, plain) };
240
240
  const overlayBounds = overlayEl?.getBoundingClientRect();
@@ -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";
@@ -11,7 +11,7 @@ import {
11
11
  type StudioPathOffsetSnapshot,
12
12
  } from "./manualEdits";
13
13
  import { computeDraggedGsapPosition } from "../../hooks/draggedGsapPosition";
14
- import { gsapWritesBox, gsapWritesPosition } from "../../hooks/gsapRuntimeKeyframes";
14
+ import { editsPlainCss } from "../../hooks/gsapRuntimeKeyframes";
15
15
  import { readTranslatePx, UNREADABLE_TRANSLATE, writeTranslatePx } from "./plainTranslate";
16
16
  import type { StudioEditRevert } from "../../utils/studioPendingEdits";
17
17
 
@@ -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
 
@@ -319,8 +318,8 @@ export function createManualOffsetDragMember(input: {
319
318
  rect: ManualOffsetDragRect;
320
319
  gesture?: "drag" | "nudge" | "resize"; // resize: the anchor that keeps its centre planted
321
320
  }): ManualOffsetDragMemberResult {
322
- const gsapOwns = input.gesture === "resize" ? gsapWritesBox : gsapWritesPosition;
323
- const plainTranslate = !!input.gesture && !gsapOwns(input.element);
321
+ const plainTranslate =
322
+ !!input.gesture && editsPlainCss(input.element, input.gesture === "resize" ? "resize" : "move");
324
323
  // The APPLIED offset, never a dormant var, so a stale one can't fling the element off-screen.
325
324
  const initialOffset = plainTranslate
326
325
  ? readTranslatePx(input.element)