@hyperframes/studio 0.8.103 → 0.8.105

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 (200) hide show
  1. package/dist/assets/{hyperframes-player-DG795Hwu.js → hyperframes-player-tnfCiVdR.js} +1 -1
  2. package/dist/assets/{index-BiEtDkae.js → index-Ca6LZlVb.js} +1 -1
  3. package/dist/assets/index-Cre9X4RS.css +1 -0
  4. package/dist/assets/{index-BPJRf2X9.js → index-DEDhPmPD.js} +215 -215
  5. package/dist/assets/{index-DuCZ3pQw.js → index-zpe332hb.js} +1 -1
  6. package/dist/{chunk-62DDWOSH.js → chunk-EEXSYY5F.js} +19 -18
  7. package/dist/chunk-EEXSYY5F.js.map +1 -0
  8. package/dist/{chunk-6GAO75IU.js → chunk-NCUBAVGK.js} +10 -8
  9. package/dist/chunk-NCUBAVGK.js.map +1 -0
  10. package/dist/{chunk-HIGHVWYD.js → chunk-QADHJFDF.js} +2 -2
  11. package/dist/index.css +6 -1
  12. package/dist/index.css.map +1 -1
  13. package/dist/index.d.ts +181 -168
  14. package/dist/index.html +2 -2
  15. package/dist/index.js +1018 -919
  16. package/dist/index.js.map +1 -1
  17. package/dist/player.js +2 -2
  18. package/dist/styles/tailwind-preset.js.map +1 -1
  19. package/dist/styles.css +1 -1
  20. package/dist/ui.js +2 -2
  21. package/package.json +8 -8
  22. package/src/captions/components/CaptionAnimationPanel.tsx +2 -2
  23. package/src/captions/components/CaptionPropertyPanel.tsx +2 -2
  24. package/src/captions/components/CaptionTimeline.tsx +3 -3
  25. package/src/components/AskAgentModal.tsx +1 -1
  26. package/src/components/LintModal.tsx +35 -35
  27. package/src/components/StudioErrorBoundary.tsx +1 -1
  28. package/src/components/StudioHeader.dom.test.tsx +4 -4
  29. package/src/components/StudioHeader.tsx +2 -1
  30. package/src/components/StudioToast.tsx +7 -16
  31. package/src/components/TimelineToolPicker.tsx +1 -1
  32. package/src/components/TimelineToolbar.tsx +11 -11
  33. package/src/components/dock/Dock.tsx +1 -1
  34. package/src/components/dock/dock.css +9 -1
  35. package/src/components/editor/AnimationCard.tsx +5 -5
  36. package/src/components/editor/AnimationCardParts.tsx +1 -1
  37. package/src/components/editor/BlockParamsPanel.tsx +3 -3
  38. package/src/components/editor/BorderRadiusEditor.tsx +8 -15
  39. package/src/components/editor/CanvasContextMenu.tsx +1 -1
  40. package/src/components/editor/ComputedTweenNotice.tsx +1 -1
  41. package/src/components/editor/DomEditOverlay.tsx +5 -6
  42. package/src/components/editor/DomEditRotateHandle.tsx +1 -1
  43. package/src/components/editor/EaseCurveSection.tsx +14 -19
  44. package/src/components/editor/EaseModeControls.tsx +2 -2
  45. package/src/components/editor/EaseParamFields.tsx +8 -8
  46. package/src/components/editor/FileTree.tsx +1 -1
  47. package/src/components/editor/FileTreeIcons.tsx +47 -26
  48. package/src/components/editor/FileTreeNodes.tsx +11 -8
  49. package/src/components/editor/GestureRecordControl.tsx +3 -3
  50. package/src/components/editor/GsapAddAnimationControl.tsx +1 -1
  51. package/src/components/editor/GsapAnimationSection.tsx +2 -2
  52. package/src/components/editor/InlineTextToolbar.tsx +4 -4
  53. package/src/components/editor/InspectorHeaderActions.tsx +1 -1
  54. package/src/components/editor/KeyframeDiamond.tsx +6 -4
  55. package/src/components/editor/KeyframeNavigation.tsx +4 -4
  56. package/src/components/editor/LayersPanel.tsx +3 -3
  57. package/src/components/editor/PromotableControl.tsx +2 -2
  58. package/src/components/editor/PropertyPanel.tsx +3 -3
  59. package/src/components/editor/PropertyPanelEmptyState.tsx +4 -4
  60. package/src/components/editor/PropertyPanelFlatFooter.tsx +2 -2
  61. package/src/components/editor/PropertyPanelFlatHeader.test.tsx +1 -1
  62. package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
  63. package/src/components/editor/SnapToolbar.tsx +5 -5
  64. package/src/components/editor/TimelineFxPopover.tsx +4 -4
  65. package/src/components/editor/Transform3DCube.tsx +1 -1
  66. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +46 -6
  67. package/src/components/editor/anchoredResizeReleaseShift.test.ts +0 -84
  68. package/src/components/editor/domEditOverlayGestures.ts +13 -19
  69. package/src/components/editor/domEditOverlayStartGesture.test.ts +170 -13
  70. package/src/components/editor/domEditOverlayStartGesture.ts +15 -25
  71. package/src/components/editor/easeCurveSvg.tsx +1 -1
  72. package/src/components/editor/manualEdits.test.ts +3 -9
  73. package/src/components/editor/manualEdits.ts +0 -1
  74. package/src/components/editor/manualEditsDom.ts +3 -13
  75. package/src/components/editor/manualEditsDomPatches.test.ts +3 -131
  76. package/src/components/editor/manualEditsDomPatches.ts +1 -53
  77. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  78. package/src/components/editor/manualEditsTypes.ts +2 -0
  79. package/src/components/editor/manualOffsetDrag.test.ts +41 -0
  80. package/src/components/editor/manualOffsetDrag.ts +6 -27
  81. package/src/components/editor/persistSeam.integration.test.ts +0 -18
  82. package/src/components/editor/plainTranslate.ts +42 -6
  83. package/src/components/editor/propertyPanelColorCurveGraph.tsx +2 -2
  84. package/src/components/editor/propertyPanelColorGradingControls.tsx +1 -1
  85. package/src/components/editor/propertyPanelColorGradingSection.tsx +8 -8
  86. package/src/components/editor/propertyPanelColorGradingSlider.tsx +1 -1
  87. package/src/components/editor/propertyPanelColorSecondary.tsx +4 -4
  88. package/src/components/editor/propertyPanelFill.tsx +6 -6
  89. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
  90. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +1 -1
  91. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +6 -9
  92. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
  93. package/src/components/editor/propertyPanelFlatMediaSection.tsx +2 -2
  94. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -2
  95. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +2 -2
  96. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +4 -3
  97. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  98. package/src/components/editor/propertyPanelFlatSliderReadout.tsx +1 -1
  99. package/src/components/editor/propertyPanelFont.tsx +2 -2
  100. package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
  101. package/src/components/editor/propertyPanelFxControls.tsx +2 -2
  102. package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
  103. package/src/components/editor/propertyPanelFxNodeRow.tsx +1 -1
  104. package/src/components/editor/propertyPanelFxPresetRun.tsx +1 -1
  105. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  106. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  107. package/src/components/editor/propertyPanelSections.tsx +2 -2
  108. package/src/components/editor/propertyPanelTransformCommit.test.ts +29 -0
  109. package/src/components/editor/propertyPanelTransformCommit.ts +8 -5
  110. package/src/components/editor/propertyPanelTypes.ts +1 -0
  111. package/src/components/editor/propertyPanelValueTier.test.ts +1 -1
  112. package/src/components/editor/propertyPanelValueTier.ts +1 -1
  113. package/src/components/editor/rotateInlineSave.test.ts +111 -0
  114. package/src/components/editor/rotationDraft.test.ts +111 -0
  115. package/src/components/editor/rotationDraft.ts +145 -0
  116. package/src/components/editor/useDomEditNudge.test.tsx +33 -0
  117. package/src/components/editor/useDomEditNudge.ts +6 -2
  118. package/src/components/editor/useDomEditOverlayGestures.ts +22 -24
  119. package/src/components/feedback/StudioFeedbackCard.tsx +11 -23
  120. package/src/components/nle/AssetPreviewOverlay.tsx +1 -1
  121. package/src/components/nle/AudioMeterStrip.tsx +2 -2
  122. package/src/components/nle/NLEPreview.test.ts +8 -4
  123. package/src/components/nle/PreviewOverlays.tsx +1 -1
  124. package/src/components/nle/PreviewPane.tsx +3 -3
  125. package/src/components/nle/PreviewPoster.tsx +29 -1
  126. package/src/components/nle/PreviewZoomOverlay.tsx +1 -1
  127. package/src/components/panels/SlideshowPanel.tsx +4 -4
  128. package/src/components/panels/SlideshowSubPanels.tsx +19 -21
  129. package/src/components/panels/VariablesBindElement.tsx +1 -1
  130. package/src/components/panels/VariablesDeclarationForm.tsx +1 -1
  131. package/src/components/panels/VariablesPanel.tsx +4 -6
  132. package/src/components/panels/VariablesRowAction.tsx +1 -1
  133. package/src/components/renders/FfmpegRequiredNotice.tsx +4 -4
  134. package/src/components/renders/RenderQueue.tsx +2 -2
  135. package/src/components/renders/RenderQueueItem.tsx +3 -3
  136. package/src/components/sidebar/AssetCard.tsx +3 -3
  137. package/src/components/sidebar/AssetContextMenu.tsx +7 -7
  138. package/src/components/sidebar/AssetsTab.tsx +6 -6
  139. package/src/components/sidebar/AudioRow.tsx +5 -3
  140. package/src/components/sidebar/BlocksTab.tsx +6 -6
  141. package/src/components/sidebar/CompositionsTab.drag.test.tsx +3 -69
  142. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +4 -50
  143. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +256 -0
  144. package/src/components/sidebar/CompositionsTab.tsx +65 -12
  145. package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
  146. package/src/components/sidebar/SidebarLintButton.tsx +3 -3
  147. package/src/components/sidebar/compositionsTabTestUtils.tsx +39 -0
  148. package/src/components/timelineToolbarStyles.ts +3 -3
  149. package/src/components/ui/Button.tsx +4 -4
  150. package/src/components/ui/HyperframesLogo.tsx +19 -18
  151. package/src/components/ui/Input.tsx +1 -1
  152. package/src/components/ui/Menu.tsx +4 -2
  153. package/src/hooks/draggedGsapPosition.ts +28 -0
  154. package/src/hooks/elementOffsetStager.ts +34 -13
  155. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  156. package/src/hooks/gsapRuntimeKeyframes.ts +36 -2
  157. package/src/hooks/plainRotation.test.ts +122 -0
  158. package/src/hooks/plainRotation.ts +42 -0
  159. package/src/hooks/useClipboard.source.test.ts +42 -4
  160. package/src/hooks/useClipboard.ts +13 -3
  161. package/src/hooks/useDomEditCommits.ts +0 -3
  162. package/src/hooks/useDomEditSession.ts +5 -5
  163. package/src/hooks/useDomGeometryCommit.test.tsx +79 -1
  164. package/src/hooks/useDomGeometryCommit.ts +16 -17
  165. package/src/hooks/useDomGeometryCommits.test.tsx +233 -33
  166. package/src/hooks/useDomGeometryCommits.ts +27 -80
  167. package/src/hooks/useGsapAwareEditing.test.tsx +194 -11
  168. package/src/hooks/useGsapAwareEditing.ts +43 -42
  169. package/src/hooks/useInlineTextEdit.test.tsx +33 -3
  170. package/src/hooks/useInlineTextEdit.ts +2 -2
  171. package/src/player/components/ClipContextMenu.tsx +1 -1
  172. package/src/player/components/CompositionThumbnail.tsx +23 -11
  173. package/src/player/components/EditModal.tsx +6 -6
  174. package/src/player/components/KeyframeDiamondContextMenu.tsx +2 -2
  175. package/src/player/components/PlayerControls.tsx +3 -3
  176. package/src/player/components/ShortcutsPanel.tsx +8 -20
  177. package/src/player/components/TimelineCanvas.tsx +1 -1
  178. package/src/player/components/TimelineClip.tsx +2 -2
  179. package/src/player/components/TimelineEmptyState.tsx +2 -2
  180. package/src/player/components/TimelineLanes.tsx +0 -1
  181. package/src/player/components/TrackClipCount.tsx +1 -1
  182. package/src/player/components/VolumeControl.tsx +1 -1
  183. package/src/player/components/timelineClipChildren.tsx +1 -1
  184. package/src/player/components/timelineMotionStyles.test.ts +6 -22
  185. package/src/player/components/trackHeaderLabelRows.tsx +16 -16
  186. package/src/player/lib/thumbnailScheduler.ts +4 -5
  187. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  188. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  189. package/src/styles/hex-baseline.json +7 -19
  190. package/src/styles/studio.css +0 -9
  191. package/src/styles/tailwind-preset.shared.js +2 -1
  192. package/src/styles/theme.css +204 -147
  193. package/src/styles/theme.test.ts +13 -9
  194. package/src/utils/blockCategories.ts +41 -9
  195. package/src/utils/clipboardPayload.ts +2 -0
  196. package/dist/assets/index-CQN3M56N.css +0 -1
  197. package/dist/chunk-62DDWOSH.js.map +0 -1
  198. package/dist/chunk-6GAO75IU.js.map +0 -1
  199. package/src/hooks/gsapTargetCache.ts +0 -62
  200. /package/dist/{chunk-HIGHVWYD.js.map → chunk-QADHJFDF.js.map} +0 -0
@@ -0,0 +1,122 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { DomEditSelection } from "../components/editor/domEditing";
5
+ import {
6
+ applyStudioRotation,
7
+ reapplyPositionEditsAfterSeek,
8
+ } from "../components/editor/manualEdits";
9
+ import { STUDIO_ROTATION_ATTR } from "../components/editor/manualEditsTypes";
10
+ import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
11
+ import { savePlainRotation } from "./plainRotation";
12
+ import { readShownRotation } from "../components/editor/plainTranslate";
13
+
14
+ function rotate(
15
+ element: HTMLElement,
16
+ angle: number,
17
+ options: { save?: () => Promise<void>; readOnlyPreview?: boolean } = {},
18
+ ) {
19
+ element.id = "box";
20
+ document.body.append(element);
21
+ const commitPositionPatchToHtml = vi.fn<ElementOffsetStagerDeps["commitPositionPatchToHtml"]>(
22
+ options.save ?? (() => Promise.resolve()),
23
+ );
24
+ const selection = { element, id: "box", selector: "#box" } as unknown as DomEditSelection;
25
+ const saved = savePlainRotation(
26
+ { commitPositionPatchToHtml, readOnlyPreview: options.readOnlyPreview },
27
+ selection,
28
+ { angle },
29
+ );
30
+ return { saved, commitPositionPatchToHtml, selection };
31
+ }
32
+
33
+ describe("savePlainRotation", () => {
34
+ it("saves the element's own rotate, less what its transform turns, with no Studio marks", async () => {
35
+ const element = document.createElement("div");
36
+ element.style.transform = "rotate(10deg)";
37
+ const { saved, commitPositionPatchToHtml, selection } = rotate(element, 55);
38
+ await saved;
39
+ expect(element.style.getPropertyValue("rotate")).toBe("45deg");
40
+ expect(commitPositionPatchToHtml).toHaveBeenCalledWith(
41
+ selection,
42
+ [{ type: "inline-style", property: "rotate", value: "45deg" }],
43
+ expect.objectContaining({ label: "Rotate layer" }),
44
+ );
45
+ expect(element.hasAttribute(STUDIO_ROTATION_ATTR)).toBe(false);
46
+ });
47
+
48
+ it("drops a legacy Studio rotation in the same write, so a seek does not put its angle back", async () => {
49
+ const element = document.createElement("div");
50
+ applyStudioRotation(element, { angle: 15 });
51
+ const { saved, commitPositionPatchToHtml } = rotate(element, 40);
52
+ await saved;
53
+ const patches = commitPositionPatchToHtml.mock.calls[0]![1];
54
+ expect(patches).toContainEqual({
55
+ type: "attribute",
56
+ property: STUDIO_ROTATION_ATTR,
57
+ value: null,
58
+ });
59
+ expect(patches.at(-1)).toEqual({ type: "inline-style", property: "rotate", value: "40deg" });
60
+ reapplyPositionEditsAfterSeek(document);
61
+ expect(element.style.getPropertyValue("rotate")).toBe("40deg");
62
+ });
63
+
64
+ it("puts the live rotate back when the save fails", async () => {
65
+ const element = document.createElement("div");
66
+ element.style.setProperty("rotate", "12deg");
67
+ const { saved } = rotate(element, 30, { save: () => Promise.reject(new Error("offline")) });
68
+ await expect(saved).rejects.toThrow("offline");
69
+ expect(element.style.getPropertyValue("rotate")).toBe("12deg");
70
+ });
71
+
72
+ it("writes nothing in a read-only preview", async () => {
73
+ const element = document.createElement("div");
74
+ const { saved, commitPositionPatchToHtml } = rotate(element, 30, { readOnlyPreview: true });
75
+ await saved;
76
+ expect(element.style.getPropertyValue("rotate")).toBe("");
77
+ expect(commitPositionPatchToHtml).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("saves a transform-centred element's turn in its transform and puts it back on a failed save", async () => {
81
+ const element = document.createElement("div");
82
+ element.style.transform = "translate(-120px, -80px)";
83
+ const { saved, commitPositionPatchToHtml } = rotate(element, 25, {
84
+ save: () => Promise.reject(new Error("offline")),
85
+ });
86
+ expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
87
+ {
88
+ type: "inline-style",
89
+ property: "transform",
90
+ value: "translate(-120px, -80px) rotate(25deg)",
91
+ },
92
+ ]);
93
+ await expect(saved).rejects.toThrow("offline");
94
+ expect(element.style.getPropertyValue("transform")).toBe("translate(-120px, -80px)");
95
+ });
96
+
97
+ it("makes an inline element inline-block, as a turn needs, and puts it back on a failed save", async () => {
98
+ const element = document.createElement("span");
99
+ element.style.setProperty("display", "inline");
100
+ const { saved, commitPositionPatchToHtml } = rotate(element, 20, {
101
+ save: () => Promise.reject(new Error("offline")),
102
+ });
103
+ expect(commitPositionPatchToHtml.mock.calls[0]![1]).toEqual([
104
+ { type: "inline-style", property: "display", value: "inline-block" },
105
+ { type: "inline-style", property: "rotate", value: "20deg" },
106
+ ]);
107
+ await expect(saved).rejects.toThrow("offline");
108
+ expect(element.style.getPropertyValue("display")).toBe("inline");
109
+ });
110
+ });
111
+
112
+ describe("readShownRotation", () => {
113
+ it("shows an element's own CSS turn, and GSAP's legacy reading once GSAP turns it", () => {
114
+ const element = document.createElement("div");
115
+ document.body.append(element);
116
+ element.style.setProperty("rotate", "25deg");
117
+ expect(readShownRotation(element)).toEqual({ angle: 25 });
118
+ Object.assign(element, { _gsap: { renderTransform: () => {} } });
119
+ applyStudioRotation(element, { angle: 15 });
120
+ expect(readShownRotation(element)).toEqual({ angle: 15 });
121
+ });
122
+ });
@@ -0,0 +1,42 @@
1
+ import type { DomEditSelection } from "../components/editor/domEditing";
2
+ import { captureStudioRotation, clearStudioRotation } from "../components/editor/manualEdits";
3
+ import { buildClearRotationPatches } from "../components/editor/manualEditsDomPatches";
4
+ import { STUDIO_ROTATION_ATTR } from "../components/editor/manualEditsTypes";
5
+ import {
6
+ applyCssRotation,
7
+ restorePlainRotation,
8
+ type RotationCommit,
9
+ } from "../components/editor/rotationDraft";
10
+ import type { PatchOperation } from "../utils/sourcePatcher";
11
+ import type { ElementOffsetStagerDeps } from "./elementOffsetStager";
12
+
13
+ let plainRotateCounter = 0;
14
+
15
+ /** GSAP does not turn the element: `next` is its whole angle, drawn and saved as its CSS turn. */
16
+ export function savePlainRotation(
17
+ { commitPositionPatchToHtml, readOnlyPreview }: Omit<ElementOffsetStagerDeps, "showToast">,
18
+ selection: DomEditSelection,
19
+ next: RotationCommit,
20
+ ): Promise<void> {
21
+ if (readOnlyPreview) return Promise.resolve();
22
+ const { element } = selection;
23
+ const before = captureStudioRotation(element);
24
+ // Legacy marks go in the same write, or a seek puts their angle back; clearing them voids the press read.
25
+ const patches: PatchOperation[] = element.hasAttribute(STUDIO_ROTATION_ATTR)
26
+ ? buildClearRotationPatches(element)
27
+ : [];
28
+ if (patches.length) clearStudioRotation(element);
29
+ const drawn = applyCssRotation(element, next.angle, patches.length ? undefined : next.plain);
30
+ patches.push(...drawn);
31
+ const turn = drawn.at(-1)!;
32
+ return commitPositionPatchToHtml(selection, patches, {
33
+ label: "Rotate layer",
34
+ coalesceKey: `rotate:${++plainRotateCounter}`,
35
+ coalesceMs: Number.POSITIVE_INFINITY,
36
+ }).catch((error) => {
37
+ if (element.style.getPropertyValue(turn.property) === turn.value) {
38
+ restorePlainRotation(element, before);
39
+ }
40
+ throw error;
41
+ });
42
+ }
@@ -52,12 +52,16 @@ afterEach(() => {
52
52
 
53
53
  // The preview page: the saved markup with the preview's in-memory ids, a sub-composition
54
54
  // mounted inline, and the runtime's hide on both clips.
55
- function mountPreview(sub: string, bundle: (host: Element) => void): HTMLIFrameElement {
55
+ function mountPreview(
56
+ sub: string,
57
+ bundle: (host: Element) => void,
58
+ saved = SAVED,
59
+ ): HTMLIFrameElement {
56
60
  const iframe = document.createElement("iframe");
57
61
  document.body.appendChild(iframe);
58
62
  const doc = iframe.contentDocument as Document;
59
63
  doc.open();
60
- doc.write(ensureHfIds(SAVED));
64
+ doc.write(ensureHfIds(saved));
61
65
  doc.close();
62
66
  const parsed = new DOMParser().parseFromString(ensureHfIds(sub), "text/html");
63
67
  const subRoot = (parsed.querySelector("template") as HTMLTemplateElement).content
@@ -93,14 +97,15 @@ function mountClipboard(
93
97
  domSelection: DomEditSelection | null = null,
94
98
  sub = SUB,
95
99
  bundle: (host: Element) => void = () => {},
100
+ saved = SAVED,
96
101
  ) {
97
- const files: Record<string, string> = { "index.html": SAVED, "compositions/sub.html": sub };
102
+ const files: Record<string, string> = { "index.html": saved, "compositions/sub.html": sub };
98
103
  const fail = { on: false };
99
104
  const delayMs: Record<string, number> = {};
100
105
  const domEditSave = { pending: Promise.resolve() };
101
106
  const domSelectionRef = { current: domSelection };
102
107
  stubFiles(files, fail, delayMs);
103
- const iframe = mountPreview(sub, bundle);
108
+ const iframe = mountPreview(sub, bundle, saved);
104
109
  const writes: string[] = [];
105
110
  const deleted: string[] = [];
106
111
  const writeProjectFile = async (_path: string, content: string) => {
@@ -248,6 +253,39 @@ const SUB_SELECTION = {
248
253
  sourceFile: "compositions/sub.html",
249
254
  } as DomEditSelection;
250
255
 
256
+ describe("paste of an element styled by its id", () => {
257
+ it("gives the copy the look its original has", async () => {
258
+ clearSelection();
259
+ const saved = SAVED.replace(
260
+ "<body>",
261
+ "<body><style>#badge { color: rgb(200, 30, 40); letter-spacing: 3px; }</style>",
262
+ ).replace("</h1>", '</h1><p id="badge">New</p>');
263
+ const selection = {
264
+ hfId: stampedHfId(saved, "#badge"),
265
+ selector: "#badge",
266
+ selectorIndex: 0,
267
+ sourceFile: "index.html",
268
+ } as DomEditSelection;
269
+ const { clipboard, writes } = mountClipboard(selection, SUB, () => {}, saved);
270
+ clipboard().handleCopy();
271
+ await clipboard().handlePaste();
272
+ const page = document.createElement("iframe");
273
+ document.body.appendChild(page);
274
+ const doc = page.contentDocument as Document;
275
+ doc.open();
276
+ doc.write(writes[0] ?? "");
277
+ doc.close();
278
+ const look = (id: string) => {
279
+ const style = (page.contentWindow as Window).getComputedStyle(
280
+ doc.getElementById(id) as Element,
281
+ );
282
+ return { color: style.color, letterSpacing: style.letterSpacing };
283
+ };
284
+ expect(look("badge")).toEqual({ color: "rgb(200, 30, 40)", letterSpacing: "3px" });
285
+ expect(look("badge-2")).toEqual(look("badge"));
286
+ });
287
+ });
288
+
251
289
  describe("copy of a sub-composition clip", () => {
252
290
  it("rebases its relative asset paths to the project root, as the preview does", async () => {
253
291
  clearSelection();
@@ -160,10 +160,17 @@ const hfIdsInFile = (content: string) =>
160
160
  export function pasteElementHtml(
161
161
  content: string,
162
162
  payload: { html: string; originSelector?: string; originSelectorIndex?: number },
163
+ fromThisFile = false,
163
164
  ): string {
164
165
  const reminted = remintHfIds(payload.html, new DOMParser(), hfIdsInFile(content));
165
166
  const deduped = deduplicateIds(reminted, collectHtmlIds(content));
166
- return insertAsSibling(content, deduped, payload.originSelector, payload.originSelectorIndex);
167
+ const result = insertAsSibling(
168
+ content,
169
+ deduped,
170
+ payload.originSelector,
171
+ payload.originSelectorIndex,
172
+ );
173
+ return fromThisFile ? carryLook(result, renamedIds(reminted, deduped), 0) : result;
167
174
  }
168
175
 
169
176
  /** Shared insertion path for paste and duplicate, anchored at the playhead or the selection's end. Returns the
@@ -324,6 +331,7 @@ export function useClipboard({
324
331
  kind: "dom-element",
325
332
  html: savedMarkupElseLive(parseSavedSource(content), live, sourceFile),
326
333
  sourceFile,
334
+ projectId: projectIdRef.current ?? undefined,
327
335
  originSelector: domSelection.selector,
328
336
  originSelectorIndex: domSelection.selectorIndex,
329
337
  };
@@ -373,15 +381,17 @@ export function useClipboard({
373
381
  const targetPath = activeCompPath || "index.html";
374
382
  try {
375
383
  let pastedIds: string[] = [];
384
+ const fromThisFile = payload.sourceFile === targetPath && payload.projectId === pid;
376
385
  const paste = (originalContent: string) => {
377
- if (payload.kind !== "timeline-clip") return pasteElementHtml(originalContent, payload);
386
+ if (payload.kind !== "timeline-clip")
387
+ return pasteElementHtml(originalContent, payload, fromThisFile);
378
388
  const { currentTime, elements } = usePlayerStore.getState();
379
389
  const pasted = pasteTimelineClips(
380
390
  originalContent,
381
391
  payload.clips,
382
392
  currentTime,
383
393
  elements,
384
- payload.sourceFile === targetPath && payload.projectId === pid,
394
+ fromThisFile,
385
395
  );
386
396
  pastedIds = pasted.ids;
387
397
  // A clip pasted past the current composition end would exist in the
@@ -271,12 +271,10 @@ export function useDomEditCommits({
271
271
 
272
272
  const {
273
273
  stageElementPositionOffset,
274
- handleDomPathOffsetCommit,
275
274
  handleDomBoxSizeCommit,
276
275
  handleDomRotationCommit,
277
276
  handleDomManualEditsReset,
278
277
  } = useDomGeometryCommits({
279
- previewIframeRef,
280
278
  showToast,
281
279
  commitPositionPatchToHtml,
282
280
  readOnlyPreview,
@@ -316,7 +314,6 @@ export function useDomEditCommits({
316
314
  handleDomRemoveTextField,
317
315
  stageElementPositionOffset,
318
316
  commitPositionPatchToHtml,
319
- handleDomPathOffsetCommit,
320
317
  handleDomBoxSizeCommit,
321
318
  handleDomRotationCommit,
322
319
  handleDomManualEditsReset,
@@ -24,8 +24,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
24
24
  import { membersForDelete, timelineElementsForDelete } from "./domEditDeleteMembers";
25
25
  import type { RecordEditInput } from "./domEditDeleteMembers";
26
26
  import type { DomEditTimelineParams } from "./useDomSelectionTypes";
27
- // Re-exported: the delete rule lives in its own module now, and callers (and its
28
- // own test) have always imported it from here.
27
+ // Re-exported: the delete rule lives in its own module; callers and its test import it from here.
29
28
  export { membersForDelete };
30
29
 
31
30
  export interface UseDomEditSessionParams extends DomEditTimelineParams {
@@ -232,6 +231,7 @@ export function useDomEditSession({
232
231
  stageElementPositionOffset,
233
232
  commitPositionPatchToHtml,
234
233
  handleDomBoxSizeCommit,
234
+ handleDomRotationCommit: handleDomCssRotationCommit,
235
235
  handleDomManualEditsReset,
236
236
  handleDomEditElementsDelete,
237
237
  handleDomZIndexReorderCommit,
@@ -256,9 +256,8 @@ export function useDomEditSession({
256
256
  readOnlyPreview,
257
257
  onTrySdkPersist: sdkSession
258
258
  ? (selection, operations, originalContent, targetPath, options) => {
259
- // Decoupled tripwire, runs regardless of the cutover flag. originalContent lets
260
- // the runtime-node filter suppress hf-ids absent from source (script-created
261
- // nodes); the paths let a cross-file edit skip instead of a false not-found.
259
+ // Decoupled tripwire, regardless of the cutover flag. originalContent lets the runtime-node
260
+ // filter drop hf-ids absent from source; the paths skip a cross-file edit, not a not-found.
262
261
  runResolverShadow(sdkSession, selection.hfId, operations, originalContent, {
263
262
  targetPath,
264
263
  compositionPath: activeCompPath,
@@ -491,6 +490,7 @@ export function useDomEditSession({
491
490
  trackGsapInteractionFailure,
492
491
  stageElementPositionOffset,
493
492
  handleDomBoxSizeCommit,
493
+ handleDomRotationCommit: handleDomCssRotationCommit,
494
494
  commitPositionPatchToHtml,
495
495
  addGsapAnimation,
496
496
  convertToKeyframes,
@@ -168,7 +168,32 @@ describe("useDomGeometryCommit, from the package entry", () => {
168
168
  expect(element.style.getPropertyValue("translate")).toBe("130.5px 90px");
169
169
  expect(calls.patches).toEqual([
170
170
  expect.objectContaining({
171
- operations: [{ type: "inline-style", property: "translate", value: "130.5px 90px" }],
171
+ operations: [
172
+ { type: "inline-style", property: "translate", value: "130.5px 90px" },
173
+ { type: "attribute", property: "data-hf-studio-original-inline-translate", value: "" },
174
+ ],
175
+ }),
176
+ ]);
177
+ expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
178
+ expect(recordEdit).toHaveBeenCalledTimes(1);
179
+ unmount();
180
+ });
181
+
182
+ it("saves a GSAP-free resize as its inline size, with no GSAP script and no animation read", async () => {
183
+ const calls = stubPatchServer();
184
+ const { element, recordEdit, hook, unmount } = renderHost();
185
+
186
+ await expect(
187
+ hook().commitBoxSize(makeSelection("card", element), { width: 300, height: 90 }),
188
+ ).resolves.toEqual({ ok: true });
189
+
190
+ expect(element.style.getPropertyValue("width")).toBe("300px");
191
+ expect(calls.patches).toEqual([
192
+ expect.objectContaining({
193
+ operations: expect.arrayContaining([
194
+ { type: "inline-style", property: "width", value: "300px" },
195
+ { type: "inline-style", property: "height", value: "90px" },
196
+ ]),
172
197
  }),
173
198
  ]);
174
199
  expect(calls.urls.filter((url) => url.includes("gsap"))).toEqual([]);
@@ -188,6 +213,57 @@ describe("useDomGeometryCommit, from the package entry", () => {
188
213
  unmount();
189
214
  });
190
215
 
216
+ it("refuses a CSS move GSAP has folded into its x/y, writes nothing, and hands the translate back", async () => {
217
+ const calls = stubPatchServer();
218
+ const { element, hook, unmount } = renderHost();
219
+ element.style.setProperty("translate", "none");
220
+ Object.assign(element, { _gsap: { renderTransform: () => {}, x: "94px", y: "66px" } });
221
+ const set = vi.fn();
222
+ Object.assign(element.ownerDocument.defaultView!, { gsap: { set } });
223
+
224
+ await expect(
225
+ hook().commitPathOffset(
226
+ makeSelection("card", element),
227
+ { x: 1, y: 2 },
228
+ { plainTranslate: true },
229
+ ),
230
+ ).rejects.toThrow(/animation took over/);
231
+ delete (element.ownerDocument.defaultView as { gsap?: unknown }).gsap;
232
+ expect(calls.patches).toEqual([]);
233
+ expect(set).toHaveBeenCalledWith(element, { x: 0, y: 0, xPercent: 0, yPercent: 0 });
234
+ unmount();
235
+ });
236
+
237
+ it("saves a CSS move GSAP has only parsed, with nothing folded into its x/y", async () => {
238
+ const calls = stubPatchServer();
239
+ const { element, hook, unmount } = renderHost();
240
+ Object.assign(element, {
241
+ _gsap: { renderTransform: () => {}, x: "0px", y: "0px", xPercent: 0 },
242
+ });
243
+
244
+ await expect(
245
+ hook().commitPathOffset(
246
+ makeSelection("card", element),
247
+ { x: 1, y: 2 },
248
+ { plainTranslate: true },
249
+ ),
250
+ ).resolves.toEqual({ ok: true });
251
+ expect(calls.patches).toHaveLength(1);
252
+ unmount();
253
+ });
254
+
255
+ it("leaves a later move's translate alone when an earlier move's save fails", async () => {
256
+ stubPatchServer(500);
257
+ const { element, hook, unmount } = renderHost();
258
+ element.style.setProperty("translate", "40px 30px");
259
+
260
+ const saving = hook().commitPathOffset(makeSelection("card", element), { x: 1, y: 2 });
261
+ element.style.setProperty("translate", "7px 8px");
262
+ await expect(saving).rejects.toThrow();
263
+ expect(element.style.getPropertyValue("translate")).toBe("7px 8px");
264
+ unmount();
265
+ });
266
+
191
267
  it("keeps the same commits across renders, so the overlay's handlers stay put", () => {
192
268
  stubServer();
193
269
  const { hook, rerender, unmount } = renderHost();
@@ -216,6 +292,8 @@ describe("useDomGeometryCommit, from the package entry", () => {
216
292
  const showToast = vi.fn();
217
293
  const restore = vi.fn();
218
294
  const { element, recordEdit, hook, unmount } = renderHost({ showToast });
295
+ // GSAP renders the card's transform, so its resize reads animations first.
296
+ Object.assign(element, { _gsap: { renderTransform: () => undefined } });
219
297
 
220
298
  await expect(
221
299
  hook().commitBoxSize(
@@ -1,7 +1,11 @@
1
+ import type { RotationCommit } from "../components/editor/rotationDraft";
1
2
  import { useCallback, useMemo, useRef, useState } from "react";
2
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
4
  import type { DomEditSelection } from "../components/editor/domEditing";
4
- import type { DomEditGroupPathOffsetCommit } from "../components/editor/DomEditOverlay";
5
+ import type {
6
+ DomEditGroupPathOffsetCommit,
7
+ MoveCommitOptions,
8
+ } from "../components/editor/DomEditOverlay";
5
9
  import { isPreviewBooted } from "../player/store/playerStore";
6
10
  import type { DomEditCommitOutcome } from "./domEditCommitRunner";
7
11
  import type { UseDomStyleCommitOptions } from "./useDomStyleCommit";
@@ -13,8 +17,8 @@ import { useGsapCacheVersion } from "./useGsapTweenCache";
13
17
  import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
14
18
  import { useDomEditPersist } from "./useDomEditPersist";
15
19
  import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
20
+ import { useDomGeometryCommits } from "./useDomGeometryCommits";
16
21
  import { useMountEffect } from "./useMountEffect";
17
- import { stageElementOffset } from "./elementOffsetStager";
18
22
 
19
23
  /**
20
24
  * Studio's `Player` must show the project, with `beginTimelineSession(projectId)` run before it
@@ -29,7 +33,7 @@ export interface DomGeometryCommits {
29
33
  commitPathOffset: (
30
34
  selection: DomEditSelection,
31
35
  next: { x: number; y: number },
32
- modifiers?: { altKey?: boolean },
36
+ modifiers?: MoveCommitOptions,
33
37
  ) => Promise<DomEditCommitOutcome>;
34
38
  commitGroupPathOffset: (updates: DomEditGroupPathOffsetCommit[]) => Promise<DomEditCommitOutcome>;
35
39
  commitBoxSize: (
@@ -40,15 +44,13 @@ export interface DomGeometryCommits {
40
44
  ) => Promise<DomEditCommitOutcome>;
41
45
  commitRotation: (
42
46
  selection: DomEditSelection,
43
- next: { angle: number },
47
+ next: RotationCommit,
44
48
  ) => Promise<DomEditCommitOutcome>;
45
49
  waitForPendingSaves: () => Promise<void>;
46
50
  }
47
51
 
48
52
  const noop = () => {};
49
53
  const NO_SELECTED_ANIMATIONS: GsapAnimation[] = [];
50
- // ponytail: unreachable, the GSAP writer always exists so a resize never takes the DOM route.
51
- const noDomBoxSizeRoute = () => Promise.reject(new Error("Resize has no DOM route here"));
52
54
 
53
55
  /**
54
56
  * Saves canvas moves, resizes and rotations through Studio's own GSAP-aware commits, for a host
@@ -107,16 +109,12 @@ export function useDomGeometryCommit({
107
109
  },
108
110
  [commitPositionPatchToHtml, queue],
109
111
  );
110
- const stageElementPositionOffset = useCallback(
111
- (selection: DomEditSelection, next: { x: number; y: number }, coalesceKey?: string) =>
112
- stageElementOffset(
113
- { commitPositionPatchToHtml: commitWithFreshQueue, showToast },
114
- selection,
115
- next,
116
- coalesceKey,
117
- ),
118
- [commitWithFreshQueue, showToast],
119
- );
112
+ const { stageElementPositionOffset, handleDomBoxSizeCommit, handleDomRotationCommit } =
113
+ useDomGeometryCommits({
114
+ showToast,
115
+ commitPositionPatchToHtml: commitWithFreshQueue,
116
+ readOnlyPreview: false,
117
+ });
120
118
  const makeFetchFallback = useGsapAnimationFetchFallback(projectId);
121
119
  const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
122
120
  const {
@@ -135,7 +133,8 @@ export function useDomGeometryCommit({
135
133
  makeFetchFallback,
136
134
  trackGsapInteractionFailure,
137
135
  stageElementPositionOffset,
138
- handleDomBoxSizeCommit: noDomBoxSizeRoute,
136
+ handleDomBoxSizeCommit,
137
+ handleDomRotationCommit,
139
138
  commitPositionPatchToHtml: commitWithFreshQueue,
140
139
  addGsapAnimation: gsap.addGsapAnimation,
141
140
  convertToKeyframes: gsap.convertToKeyframes,