@hyperframes/studio 0.8.138 → 0.8.140

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 (135) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
  5. package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +20 -3
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +7327 -6265
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/TimelineToolbar.test.tsx +79 -1
  18. package/src/components/TimelineToolbar.tsx +14 -29
  19. package/src/components/editor/DomEditOverlay.tsx +11 -7
  20. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  21. package/src/components/editor/PropertyPanel.tsx +1 -0
  22. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEdits.ts +1 -1
  33. package/src/components/editor/manualEditsDom.ts +7 -7
  34. package/src/components/editor/manualEditsParsing.ts +14 -2
  35. package/src/components/editor/manualEditsTypes.ts +5 -0
  36. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  37. package/src/components/editor/manualOffsetDrag.ts +5 -0
  38. package/src/components/editor/previewGestureStarts.ts +284 -0
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  40. package/src/components/editor/propertyPanelFill.tsx +36 -13
  41. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  42. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  43. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  44. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  45. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  46. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  47. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  48. package/src/components/nle/NLEContext.test.ts +26 -0
  49. package/src/components/nle/NLEContext.tsx +8 -3
  50. package/src/components/nle/NLEPreview.test.ts +44 -1
  51. package/src/components/nle/NLEPreview.tsx +7 -2
  52. package/src/hooks/draggedGsapPosition.ts +8 -0
  53. package/src/hooks/editMoment.ts +13 -0
  54. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  55. package/src/hooks/gsapDragCommit.ts +2 -1
  56. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  57. package/src/hooks/gsapPositionDetection.ts +5 -3
  58. package/src/hooks/gsapResizeIntercept.ts +11 -4
  59. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  60. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  61. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  62. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  63. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  64. package/src/hooks/timelineEditingHelpers.ts +18 -2
  65. package/src/hooks/useDomSelection.test.ts +21 -2
  66. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  67. package/src/hooks/useEditHistoryActions.ts +2 -0
  68. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  69. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  70. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  71. package/src/hooks/useGsapAwareEditing.ts +148 -136
  72. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  73. package/src/hooks/useGsapScriptCommits.ts +5 -4
  74. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  75. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  76. package/src/hooks/usePreviewPersistence.ts +1 -4
  77. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  78. package/src/hooks/useSdkSession.ts +4 -1
  79. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  80. package/src/hooks/useThumbnailStripSize.ts +23 -1
  81. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  82. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  83. package/src/index.ts +2 -0
  84. package/src/player/components/AudioWaveform.test.tsx +153 -1
  85. package/src/player/components/AudioWaveform.tsx +93 -37
  86. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  87. package/src/player/components/Timeline.test.ts +3 -1
  88. package/src/player/components/TimelineCanvas.tsx +4 -0
  89. package/src/player/components/TimelineClip.tsx +20 -6
  90. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  91. package/src/player/components/TimelineClipFades.tsx +4 -4
  92. package/src/player/components/TimelineClipJoins.tsx +23 -18
  93. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  94. package/src/player/components/TimelineGroupRow.tsx +1 -0
  95. package/src/player/components/TimelineLaneClip.tsx +127 -0
  96. package/src/player/components/TimelineLanes.test.tsx +51 -6
  97. package/src/player/components/TimelineLanes.tsx +166 -178
  98. package/src/player/components/TimelineRuler.tsx +2 -0
  99. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  100. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  101. package/src/player/components/VideoThumbnail.tsx +37 -9
  102. package/src/player/components/thumbnailUtils.test.ts +17 -3
  103. package/src/player/components/thumbnailUtils.ts +17 -1
  104. package/src/player/components/timelineLayout.test.ts +3 -3
  105. package/src/player/components/timelineMotion.test.ts +27 -0
  106. package/src/player/components/timelineMotion.ts +62 -0
  107. package/src/player/components/timelineZoomInput.test.ts +438 -0
  108. package/src/player/components/timelineZoomInput.ts +456 -0
  109. package/src/player/components/useTimelineGeometry.ts +0 -3
  110. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  111. package/src/player/components/useTimelinePlayhead.ts +53 -59
  112. package/src/player/components/useTimelineProviderState.tsx +16 -21
  113. package/src/player/components/useTimelineZoom.ts +7 -12
  114. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  115. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  116. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  117. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  118. package/src/player/lib/thumbnailScheduler.ts +1 -1
  119. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  120. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  121. package/src/player/lib/timelineElementHelpers.ts +0 -11
  122. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  123. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  124. package/src/player/previewReloading.test.ts +48 -0
  125. package/src/player/previewReloading.ts +48 -0
  126. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  127. package/src/utils/rootDuration.ts +5 -4
  128. package/src/utils/studioPendingEdits.test.ts +27 -0
  129. package/src/utils/studioPendingEdits.ts +9 -0
  130. package/src/utils/timelineAssetDrop.test.ts +13 -4
  131. package/src/utils/timelineAssetDrop.ts +6 -2
  132. package/dist/chunk-TDWGXBUT.js.map +0 -1
  133. /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
  134. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  135. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -0,0 +1,164 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { StyleSections } from "./propertyPanelStyleSections";
6
+ import { FlatStyleSection } from "./propertyPanelFlatStyleSections";
7
+ import type { DomEditSelection } from "./domEditing";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ });
13
+
14
+ function mount(
15
+ Panel: typeof StyleSections | typeof FlatStyleSection,
16
+ tag = "img",
17
+ sourceFile = "index.html",
18
+ src = "assets/old.png",
19
+ ) {
20
+ const selected = document.createElement(tag);
21
+ selected.id = "picture";
22
+ selected.setAttribute("src", src);
23
+ document.body.append(selected);
24
+ const element: DomEditSelection = {
25
+ element: selected,
26
+ id: "picture",
27
+ selector: "#picture",
28
+ label: "Picture",
29
+ tagName: tag,
30
+ sourceFile,
31
+ compositionPath: sourceFile,
32
+ isCompositionHost: false,
33
+ isInsideLockedComposition: false,
34
+ boundingBox: { x: 0, y: 0, width: 320, height: 180 },
35
+ textContent: "",
36
+ dataAttributes: {},
37
+ inlineStyles: {},
38
+ computedStyles: {},
39
+ textFields: [],
40
+ capabilities: {
41
+ canSelect: true,
42
+ canEditStyles: true,
43
+ canCrop: true,
44
+ canMove: true,
45
+ canResize: true,
46
+ canApplyManualOffset: true,
47
+ canApplyManualSize: true,
48
+ canApplyManualRotation: true,
49
+ },
50
+ };
51
+ const host = document.createElement("div");
52
+ document.body.append(host);
53
+ const root = createRoot(host);
54
+ const onSetStyle = vi.fn((prop: string, value: string) =>
55
+ selected.style.setProperty(prop, value),
56
+ );
57
+ const onSetHtmlAttribute = vi.fn((attr: string, value: string | null) => {
58
+ if (value !== null) selected.setAttribute(attr, value);
59
+ });
60
+ act(() =>
61
+ root.render(
62
+ <Panel
63
+ projectId="fixture"
64
+ element={element}
65
+ styles={{}}
66
+ assets={["assets/old.png", "assets/new.png"]}
67
+ onSetStyle={onSetStyle}
68
+ onSetHtmlAttribute={onSetHtmlAttribute}
69
+ />,
70
+ ),
71
+ );
72
+ const image = Array.from(host.querySelectorAll("button")).find(
73
+ (button) => button.textContent === "Image",
74
+ );
75
+ if (!image) throw new Error("Image control must exist");
76
+ act(() => image.click());
77
+ // The Image asset select is the one whose options include the fixture asset.
78
+ const assetSelect = Array.from(host.querySelectorAll("select")).find((node) =>
79
+ Array.from(node.options).some((option) => option.value === "assets/new.png"),
80
+ );
81
+ if (!assetSelect) throw new Error("Project asset control must exist");
82
+ function choose(value: string) {
83
+ const control = assetSelect;
84
+ if (!control) throw new Error("Asset control must exist");
85
+ act(() => {
86
+ control.value = value;
87
+ control.dispatchEvent(new Event("change", { bubbles: true }));
88
+ });
89
+ }
90
+ return { host, selected, root, choose, assetSelect, onSetStyle, onSetHtmlAttribute };
91
+ }
92
+
93
+ describe.each([
94
+ ["classic", StyleSections],
95
+ ["flat", FlatStyleSection],
96
+ ] as const)("%s Image fill", (_name, Panel) => {
97
+ it("shows the img source as the current project asset", () => {
98
+ const view = mount(Panel);
99
+ expect(view.assetSelect.value).toBe("assets/old.png");
100
+ act(() => view.root.unmount());
101
+ });
102
+ it.each([
103
+ "assets/100%.png",
104
+ 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100%"></svg>',
105
+ ])("shows an authored source containing a literal percent: %s", (src) => {
106
+ const view = mount(Panel, "img", "index.html", src);
107
+ const input = Array.from(view.host.querySelectorAll("label"))
108
+ .find((node) => node.querySelector("span")?.textContent === "External URL")
109
+ ?.querySelector("input");
110
+ expect(input?.value).toBe(src);
111
+ act(() => view.root.unmount());
112
+ });
113
+ it("replaces the img source rather than writing a hidden background", () => {
114
+ const view = mount(Panel);
115
+ view.choose("assets/new.png");
116
+ expect(view.selected.getAttribute("src")).toBe("assets/new.png");
117
+ expect(view.onSetHtmlAttribute).toHaveBeenCalledExactlyOnceWith("src", "assets/new.png");
118
+ expect(view.onSetStyle).not.toHaveBeenCalled();
119
+ act(() => view.root.unmount());
120
+ });
121
+ it("replaces an img with an external URL without CSS wrapping", () => {
122
+ const view = mount(Panel);
123
+ const row = Array.from(view.host.querySelectorAll("label")).find(
124
+ (node) =>
125
+ node.querySelector("span")?.textContent === "External URL" && node.querySelector("input"),
126
+ );
127
+ const input = row?.querySelector("input");
128
+ if (!input) throw new Error("External URL input must exist");
129
+ act(() => {
130
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(
131
+ input,
132
+ "https://example.com/new.png",
133
+ );
134
+ input.dispatchEvent(new Event("input", { bubbles: true }));
135
+ });
136
+ act(() => input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })));
137
+ expect(view.selected.getAttribute("src")).toBe("https://example.com/new.png");
138
+ expect(view.onSetStyle).not.toHaveBeenCalled();
139
+ act(() => view.root.unmount());
140
+ });
141
+ it("keeps the img source when cleared", () => {
142
+ const view = mount(Panel);
143
+ view.choose("");
144
+ expect(view.selected.getAttribute("src")).toBe("assets/old.png");
145
+ expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
146
+ expect(view.onSetStyle).not.toHaveBeenCalled();
147
+ act(() => view.root.unmount());
148
+ });
149
+ it("uses source-relative image paths in nested compositions", () => {
150
+ const view = mount(Panel, "img", "compositions/scene.html");
151
+ view.choose("assets/new.png");
152
+ expect(view.selected.getAttribute("src")).toBe("../assets/new.png");
153
+ act(() => view.root.unmount());
154
+ });
155
+ it("keeps background-image replacement and clearing for ordinary elements", () => {
156
+ const view = mount(Panel, "div");
157
+ view.choose("assets/new.png");
158
+ expect(view.selected.style.backgroundImage).toBe('url("assets/new.png")');
159
+ view.choose("");
160
+ expect(view.selected.style.backgroundImage).toBe("none");
161
+ expect(view.onSetHtmlAttribute).not.toHaveBeenCalled();
162
+ act(() => view.root.unmount());
163
+ });
164
+ });
@@ -45,6 +45,7 @@ export function StyleSections({
45
45
  styles,
46
46
  assets,
47
47
  onSetStyle,
48
+ onSetHtmlAttribute,
48
49
  onImportAssets,
49
50
  gsapBorderRadius,
50
51
  hideFlex = false,
@@ -54,6 +55,7 @@ export function StyleSections({
54
55
  styles: Record<string, string>;
55
56
  assets: string[];
56
57
  onSetStyle: (prop: string, value: string) => void | Promise<unknown>;
58
+ onSetHtmlAttribute: (attr: string, value: string | null) => void | Promise<void>;
57
59
  onImportAssets?: (files: FileList) => Promise<string[]>;
58
60
  gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
59
61
  // When true, the Flex `Section` is suppressed. The flat inspector renders
@@ -108,12 +110,12 @@ export function StyleSections({
108
110
  const backgroundImage = styles["background-image"] ?? "none";
109
111
  const hasTextControls = isTextEditableSelection(element);
110
112
 
111
- const fillMode =
112
- backgroundImage && backgroundImage !== "none"
113
- ? backgroundImage.includes("gradient")
114
- ? "Gradient"
115
- : "Image"
116
- : "Solid";
113
+ let fillMode = "Solid";
114
+ if (element.tagName === "img") {
115
+ fillMode = "Image";
116
+ } else if (backgroundImage && backgroundImage !== "none") {
117
+ fillMode = backgroundImage.includes("gradient") ? "Gradient" : "Image";
118
+ }
117
119
  const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
118
120
  const imageUrl = extractBackgroundImageUrl(backgroundImage);
119
121
  // Remember the last authored gradient so an exploratory Solid↔Gradient
@@ -488,7 +490,8 @@ export function StyleSections({
488
490
  ) : (
489
491
  <ImageFillField
490
492
  projectId={projectId}
491
- sourceFile={element.sourceFile}
493
+ element={element}
494
+ onSetHtmlAttribute={onSetHtmlAttribute}
492
495
  value={imageUrl}
493
496
  assets={assets}
494
497
  disabled={styleEditingDisabled}
@@ -62,6 +62,7 @@ async function rotateWord(before: (word: HTMLElement) => void = () => {}) {
62
62
  gestureRef: ref<GestureState | null>(null),
63
63
  groupGestureRef: ref(null),
64
64
  blockedMoveRef: ref(null),
65
+ waitingPressRef: ref(null),
65
66
  rafPausedRef: ref(false),
66
67
  suppressNextBoxClickRef: ref(false),
67
68
  setOverlayRect: () => {},
@@ -19,6 +19,7 @@ function gestureHarness(gesture: Partial<GestureState> | null, group: GroupGestu
19
19
  hoverSelectionRef: { current: null },
20
20
  boxRef: { current: null },
21
21
  blockedMoveRef: { current: null },
22
+ waitingPressRef: { current: null },
22
23
  rafPausedRef: { current: false },
23
24
  suppressNextBoxClickRef: { current: false },
24
25
  snapGuidesRef: { current: null },
@@ -1,3 +1,4 @@
1
+ import { createPreviewGestureStarts } from "./previewGestureStarts";
1
2
  import { trackPreviewEditResult } from "../../utils/previewFeatureUsage";
2
3
  // fallow-ignore-file code-duplication
3
4
  /**
@@ -28,13 +29,12 @@ import {
28
29
  type GroupOverlayItem,
29
30
  type OverlayRect,
30
31
  orientedOverlayRect,
32
+ shiftedOverlayRect,
31
33
  } from "./domEditOverlayGeometry";
32
34
  import {
33
35
  BLOCKED_MOVE_THRESHOLD_PX,
34
- type GestureKind,
35
36
  type GestureState,
36
37
  type GroupGestureState,
37
- type ResizeHandle,
38
38
  type UseDomEditOverlayGesturesOptions,
39
39
  ROTATED_SNAP_BYPASS_DEGREES,
40
40
  hasDomEditRotationChanged,
@@ -44,11 +44,7 @@ import {
44
44
  } from "./domEditOverlayGestures";
45
45
  import { resolveCenterResizeSize } from "./domEditResizeLocal";
46
46
  import { resolveResizeDraftRect } from "./resizeDraft";
47
- import {
48
- notifyBlockedPress,
49
- startGesture as _startGesture,
50
- startGroupDrag as _startGroupDrag,
51
- } from "./domEditOverlayStartGesture";
47
+ import { notifyBlockedPress } from "./domEditOverlayStartGesture";
52
48
  import { hugRectForElement } from "./domEditOverlayCrop";
53
49
  import {
54
50
  resolveSnapAdjustment,
@@ -76,7 +72,7 @@ function logGestureCommitFailure(message: string, error: unknown): void {
76
72
 
77
73
  export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
78
74
  const setDraftOverlayRect = (next: OverlayRect) => {
79
- opts.setOverlayRect(next);
75
+ if (!opts.waitingPressRef.current) opts.setOverlayRect(next);
80
76
  };
81
77
  const restoreGestureOverlayRect = (g: GestureState) => {
82
78
  setDraftOverlayRect({
@@ -86,14 +82,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
86
82
  height: g.originHeight,
87
83
  editScaleX: g.editScaleX,
88
84
  editScaleY: g.editScaleY,
89
- // Every draft rect must carry the element's rotation: the rotation wrapper
90
- // renders rotate(overlayRect.angle), so an omitted angle straightens the
91
- // chrome for the duration of the draft (the "straightens while moving" bug).
85
+ // Rotation keeps the chrome aligned with the element during its draft.
92
86
  angle: g.actualRotation,
93
87
  });
94
88
  };
95
89
  const setDraftGroupOverlayItems = (next: GroupOverlayItem[]) => {
96
- opts.setGroupOverlayItems(next);
90
+ if (!opts.waitingPressRef.current) opts.setGroupOverlayItems(next);
97
91
  };
98
92
 
99
93
  const restoreGroupPathOffsets = (g: GroupGestureState) => {
@@ -101,16 +95,12 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
101
95
  setDraftGroupOverlayItems(g.originItems);
102
96
  };
103
97
 
104
- const startGroupDrag = (e: React.PointerEvent<HTMLElement>) => _startGroupDrag(e, opts);
105
- const startGesture = (
106
- kind: GestureKind,
107
- e: React.PointerEvent<HTMLElement>,
108
- options?: {
109
- selection?: DomEditSelection;
110
- rect?: OverlayRect | null;
111
- resizeHandle?: ResizeHandle;
112
- },
113
- ) => _startGesture(kind, e, opts, options);
98
+ const { startGesture, startGroupDrag, endWaitingPress, endHeldPress, releaseWaitingPress } =
99
+ createPreviewGestureStarts(
100
+ opts,
101
+ (event) => moveActiveGesture(event),
102
+ (event) => releaseActiveGesture(event),
103
+ );
114
104
 
115
105
  // A press on a box that cannot move says why at once.
116
106
  const startBlockedMove = (e: React.PointerEvent<HTMLElement>, selection: DomEditSelection) => {
@@ -123,12 +113,23 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
123
113
 
124
114
  const moveGroupDrag = createGroupDragMover(opts, setDraftGroupOverlayItems);
125
115
 
126
- // fallow-ignore-next-line complexity
127
116
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
117
+ const waiting = opts.waitingPressRef.current;
118
+ if (waiting && waiting.pointerId === e.pointerId) {
119
+ if (waiting.released || !movesGesture(waiting, e)) return;
120
+ waiting.moved = e;
121
+ waiting.draw?.(e.clientX - waiting.startX, e.clientY - waiting.startY);
122
+ return;
123
+ }
124
+ moveActiveGesture(e);
125
+ };
126
+
127
+ // fallow-ignore-next-line complexity
128
+ const moveActiveGesture = (e: React.PointerEvent<HTMLDivElement>) => {
128
129
  const g = opts.gestureRef.current;
129
130
  const groupG = opts.groupGestureRef.current;
130
131
  const sel = g?.selection ?? opts.selectionRef.current;
131
- const box = opts.boxRef.current;
132
+ const box = opts.waitingPressRef.current ? null : opts.boxRef.current;
132
133
  const blockedMove = opts.blockedMoveRef.current;
133
134
  if (!blockedMove && !g && !groupG) {
134
135
  opts.onCanvasPointerMoveRef.current(e, { preferClipAncestor: false });
@@ -248,18 +249,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
248
249
  }
249
250
  if (g.pathOffsetMember) applyManualOffsetDragDraft(g.pathOffsetMember, dx, dy);
250
251
  } else {
251
- if (!box) return;
252
-
253
- // CENTER-ANCHORED size (CapCut model): the element scales proportionally
254
- // about its CENTER — the scale is the pointer's RADIAL distance from the
255
- // element center now over its distance at gesture start. Rotation-invariant
256
- // (a distance ignores the angle) and continuous, so all four corners behave
257
- // identically and there is no per-axis projection or edge-snapping. Base size
258
- // is the element-local px size at gesture start (actualWidth/Height,
259
- // GSAP-scale-aware). Corner drag is ALWAYS proportional; there is no
260
- // free-form stretch gesture. Edge-snapping is intentionally NOT applied:
261
- // with center anchoring both edges move symmetrically, so the corner-anchored
262
- // snap math no longer holds — CapCut does not edge-snap during scale either.
252
+ // Corner resize scales proportionally about the center, without edge snapping.
263
253
  const nextSize = resolveCenterResizeSize({
264
254
  baseWidth: g.actualWidth,
265
255
  baseHeight: g.actualHeight,
@@ -288,33 +278,41 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
288
278
  draftRect,
289
279
  liveInlineStyle: sel.element.getAttribute("style"),
290
280
  });
291
- box.style.left = `${draftRect.left}px`;
292
- box.style.top = `${draftRect.top}px`;
293
- box.style.width = `${draftRect.width}px`;
294
- box.style.height = `${draftRect.height}px`;
281
+ if (box) {
282
+ box.style.left = `${draftRect.left}px`;
283
+ box.style.top = `${draftRect.top}px`;
284
+ box.style.width = `${draftRect.width}px`;
285
+ box.style.height = `${draftRect.height}px`;
286
+ }
295
287
  setDraftOverlayRect(draftRect);
296
288
  }
297
289
  };
298
290
 
299
- // fallow-ignore-next-line complexity
300
291
  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
292
+ const waiting = opts.waitingPressRef.current;
293
+ if (waiting && waiting.pointerId === e.pointerId) {
294
+ releaseWaitingPress(waiting, e);
295
+ opts.suppressNextBoxClickRef.current = true;
296
+ return;
297
+ }
298
+ releaseActiveGesture(e);
299
+ };
300
+
301
+ // fallow-ignore-next-line complexity
302
+ const releaseActiveGesture = (e: React.PointerEvent<HTMLDivElement>) => {
301
303
  opts.snapGuidesRef.current = null;
302
304
  const g = opts.gestureRef.current;
303
305
  const groupG = opts.groupGestureRef.current;
304
306
  const sel = g?.selection ?? opts.selectionRef.current;
305
- const box = opts.boxRef.current;
307
+ const box = opts.waitingPressRef.current ? null : opts.boxRef.current;
306
308
  opts.blockedMoveRef.current = null;
309
+ opts.rafPausedRef.current = opts.waitingPressRef.current !== null;
307
310
 
308
311
  if (groupG) {
309
312
  opts.groupGestureRef.current = null;
310
- opts.rafPausedRef.current = false;
311
313
  const rawDx = e.clientX - groupG.startX;
312
314
  const rawDy = e.clientY - groupG.startY;
313
- // The click that trails every pointerup has to be eaten either way. The
314
- // gesture ref is already cleared above, so by the time it arrives the box
315
- // no longer looks busy, and handleBoxClick hands it to the canvas as an
316
- // ordinary click — which lands between the members, resolves to nothing,
317
- // and deselects the group the drag just moved.
315
+ // Consume the release click so it cannot deselect the moved group.
318
316
  opts.suppressNextBoxClickRef.current = true;
319
317
  if (isTap(groupG, e)) {
320
318
  restoreGroupPathOffsets(groupG);
@@ -331,7 +329,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
331
329
  setDraftGroupOverlayItems(
332
330
  groupG.originItems.map((item) => ({
333
331
  ...item,
334
- rect: { ...item.rect, left: item.rect.left + dx, top: item.rect.top + dy },
332
+ rect: shiftedOverlayRect(item.rect, dx, dy),
335
333
  })),
336
334
  );
337
335
  const updates = groupG.members.map((member) => ({
@@ -367,23 +365,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
367
365
  .finally(() => {
368
366
  logDrag("committed", { at: readDragPositions(groupG.members) });
369
367
  endManualOffsetDragMembers(groupG.members);
370
- // The gesture teardown resumes the paused timelines and re-seeks the
371
- // player, which re-renders from whatever the preview currently holds.
372
- // If the reloaded source has not landed yet that is the OLD position,
373
- // so this is where a snap-back would show.
368
+ // Teardown re-seeks the player, exposing any stale preview position.
374
369
  logDragSettle("settle", groupG.members);
375
370
  });
376
371
  groupEdit.settle(groupSaved);
377
372
  return;
378
373
  }
379
374
 
380
- if (!g || !sel) {
381
- opts.gestureRef.current = null;
382
- opts.rafPausedRef.current = false;
383
- return;
384
- }
385
375
  opts.gestureRef.current = null;
386
- opts.rafPausedRef.current = false;
376
+ if (!g || !sel) return;
387
377
  const movedDistance = Math.hypot(e.clientX - g.startX, e.clientY - g.startY);
388
378
 
389
379
  if (g.kind === "drag" && isTap(g, e)) {
@@ -457,10 +447,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
457
447
  .finally(() => endStudioManualEditGesture(sel.element, g.manualEditDragToken));
458
448
  edit.settle(saved);
459
449
  } else if (g.kind === "drag") {
460
- // A moved drag (taps returned earlier) must not let the release click
461
- // re-select whatever now sits under the pointer — dropping over a
462
- // higher-z element should keep the dragged element selected, not select
463
- // the drop target. Mirrors the resize branch below.
450
+ // A release over another layer must keep this dragged layer selected.
464
451
  opts.suppressNextBoxClickRef.current = true;
465
452
  const dx = g.lastSnappedDx ?? e.clientX - g.startX;
466
453
  const dy = g.lastSnappedDy ?? e.clientY - g.startY;
@@ -509,12 +496,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
509
496
  opts.suppressNextBoxClickRef.current = true;
510
497
  const finalSize = readStudioBoxSize(sel.element);
511
498
  applyStudioBoxSize(sel.element, finalSize);
512
- // Anchored corner resize (NW/NE/SW) also moved the element to keep the
513
- // center planted. Land the size AND the anchor offset in a SINGLE
514
- // box-size commit (one persist, one undo entry). The prior two-commit
515
- // sequence re-stamped the element from source after the size-only persist
516
- // but before the offset persist landed — that one frame (new size, old
517
- // offset) was the release "jump". SE has no anchor member → size only.
499
+ // Save size and center-preserving offset together under one undo entry.
518
500
  const member = g.pathOffsetMember;
519
501
  const anchor = g.lastResizeAnchor;
520
502
  const finalOffset =
@@ -554,8 +536,14 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
554
536
  }
555
537
  };
556
538
 
539
+ /** `dropQueued`: released presses still waiting go too, as when the overlay leaves or turns read-only. */
557
540
  // fallow-ignore-next-line complexity
558
- const clearPointerState = (selectionRef: RefObject<DomEditSelection | null>) => {
541
+ const clearPointerState = (
542
+ selectionRef: RefObject<DomEditSelection | null>,
543
+ dropQueued = false,
544
+ ) => {
545
+ if (dropQueued) endWaitingPress();
546
+ else endHeldPress();
559
547
  opts.snapGuidesRef.current = null;
560
548
  const groupG = opts.groupGestureRef.current;
561
549
  if (groupG) restoreGroupPathOffsets(groupG);
@@ -586,7 +574,14 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
586
574
  opts.blockedMoveRef.current = null;
587
575
  opts.groupGestureRef.current = null;
588
576
  opts.gestureRef.current = null;
589
- opts.rafPausedRef.current = false;
577
+ opts.rafPausedRef.current = opts.waitingPressRef.current !== null;
578
+ };
579
+
580
+ const onLostPointerCapture = (e: React.PointerEvent<HTMLDivElement>) => {
581
+ const waiting = opts.waitingPressRef.current;
582
+ // Pointerup releases capture normally; the queued edit still has to land.
583
+ if (waiting?.pointerId === e.pointerId && waiting.released) return;
584
+ clearPointerState(opts.selectionRef);
590
585
  };
591
586
 
592
587
  return {
@@ -595,6 +590,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
595
590
  startBlockedMove,
596
591
  onPointerMove,
597
592
  onPointerUp,
593
+ onLostPointerCapture,
598
594
  clearPointerState,
599
595
  };
600
596
  }
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { shouldDisableTimelineWhileCompositionLoading, NLEProvider } from "./NLEContext";
6
6
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
7
7
  import { installReactActEnvironment } from "../../hooks/domSelectionTestHarness";
8
+ import { isPreviewChanging, requestPreviewReload } from "../../player/previewReloading";
8
9
 
9
10
  installReactActEnvironment();
10
11
 
@@ -143,3 +144,28 @@ describe("NLEProvider — composition source-map scoping", () => {
143
144
  host.remove();
144
145
  });
145
146
  });
147
+
148
+ describe("NLEProvider — a requested reload", () => {
149
+ beforeEach(() => {
150
+ vi.stubGlobal(
151
+ "fetch",
152
+ vi.fn(() => Promise.reject(new Error("no network in tests"))),
153
+ );
154
+ });
155
+
156
+ afterEach(() => {
157
+ vi.unstubAllGlobals();
158
+ });
159
+
160
+ it("stops counting as a preview change once the refresh begins, so later presses do not wait", async () => {
161
+ const { host, root } = await mountNleProvider({ projectId: "p", refreshKey: 0 });
162
+ requestPreviewReload();
163
+ expect(isPreviewChanging()).toBe(true);
164
+
165
+ await renderNleProvider(root, { projectId: "p", refreshKey: 1 });
166
+ expect(isPreviewChanging()).toBe(false);
167
+
168
+ act(() => root.unmount());
169
+ host.remove();
170
+ });
171
+ });
@@ -10,6 +10,7 @@ import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
10
10
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
11
11
  import { createStableContext } from "../../utils/hmrStableContext";
12
12
  import { studioApiFetch } from "../../utils/studioApiFetch";
13
+ import { previewReloadBegun } from "../../player/previewReloading";
13
14
 
14
15
  export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
15
16
  return compositionLoading;
@@ -130,9 +131,13 @@ export function NLEProvider({
130
131
  // Lightweight reload: change iframe src instead of destroying the Player.
131
132
  const prevRefreshKeyRef = useRef(refreshKey);
132
133
  useEffect(() => {
133
- if (refreshKey === prevRefreshKeyRef.current) return;
134
- prevRefreshKeyRef.current = refreshKey;
135
- refreshPlayer();
134
+ try {
135
+ if (refreshKey === prevRefreshKeyRef.current) return;
136
+ prevRefreshKeyRef.current = refreshKey;
137
+ refreshPlayer();
138
+ } finally {
139
+ previewReloadBegun();
140
+ }
136
141
  }, [refreshKey, refreshPlayer]);
137
142
 
138
143
  // Steps that follow every load of the live iframe, including a reload promoted in place.
@@ -12,6 +12,12 @@ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
12
12
  const playerMounts: string[] = [];
13
13
  let livePlayerProps: { onReadyToShowChange?: (ready: boolean) => void } = {};
14
14
 
15
+ const measured = vi.hoisted(() => ({ size: null as { width: number; height: number } | null }));
16
+ vi.mock("../../utils/previewCompositionSize", async (original) => ({
17
+ ...(await original<typeof import("../../utils/previewCompositionSize")>()),
18
+ readPreviewCompositionSize: () => measured.size,
19
+ }));
20
+
15
21
  vi.mock("../../player", async () => {
16
22
  const React = await import("react");
17
23
 
@@ -81,7 +87,12 @@ function renderPreview(
81
87
  {
82
88
  box = { width: 800, height: 600 },
83
89
  fillBox,
84
- }: { box?: { width: number; height: number }; fillBox?: boolean } = {},
90
+ compositionSizeHint,
91
+ }: {
92
+ box?: { width: number; height: number };
93
+ fillBox?: boolean;
94
+ compositionSizeHint?: { width: number; height: number };
95
+ } = {},
85
96
  ) {
86
97
  resizeCallbacks = [];
87
98
  const host = document.createElement("div");
@@ -103,6 +114,7 @@ function renderPreview(
103
114
  setShadowIframeNode: () => {},
104
115
  resetPreviewSlots: () => {},
105
116
  fillBox,
117
+ compositionSizeHint,
106
118
  }),
107
119
  );
108
120
  });
@@ -378,6 +390,37 @@ describe("NLEPreview", () => {
378
390
  filled.cleanup();
379
391
  });
380
392
 
393
+ it("takes the host's composition size as the stage's shape until the preview measures its own", () => {
394
+ const box = { width: 800, height: 600 };
395
+ const plain = renderPreview(undefined, { box });
396
+ expect([plain.stage.style.width, plain.stage.style.height]).toEqual(["784px", "441px"]);
397
+ plain.cleanup();
398
+
399
+ const hinted = renderPreview(undefined, {
400
+ box,
401
+ compositionSizeHint: { width: 1276, height: 1078 },
402
+ });
403
+ const shape = parseFloat(hinted.stage.style.width) / parseFloat(hinted.stage.style.height);
404
+ expect(shape).toBeCloseTo(1276 / 1078, 3);
405
+ expect(hinted.stage.style.height).toBe("584px");
406
+ hinted.cleanup();
407
+ });
408
+
409
+ it("lets the size the preview measures win over the host's hint", () => {
410
+ measured.size = { width: 1920, height: 1080 };
411
+ try {
412
+ const box = { width: 800, height: 600 };
413
+ const hinted = renderPreview(undefined, {
414
+ box,
415
+ compositionSizeHint: { width: 1276, height: 1078 },
416
+ });
417
+ expect([hinted.stage.style.width, hinted.stage.style.height]).toEqual(["784px", "441px"]);
418
+ hinted.cleanup();
419
+ } finally {
420
+ measured.size = null;
421
+ }
422
+ });
423
+
381
424
  it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
382
425
  const view = renderPreview([
383
426
  { gen: 0, role: "live" },