@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.
- package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
- package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
- package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
- package/dist/chunk-TFLU2MGI.js.map +1 -0
- package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
- package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.html +1 -1
- package/dist/index.js +7327 -6265
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/App.tsx +9 -4
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +11 -7
- package/src/components/editor/PropertyPanel.test.tsx +600 -748
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
- package/src/components/editor/domEditOverlayGeometry.ts +7 -7
- package/src/components/editor/domEditOverlayGestures.ts +18 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
- package/src/components/editor/dragUndoPaint.test.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
- package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsDom.ts +7 -7
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/manualEditsTypes.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +23 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -0
- package/src/components/editor/previewGestureStarts.ts +284 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/editor/rotateInlineSave.test.ts +1 -0
- package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
- package/src/components/nle/NLEContext.test.ts +26 -0
- package/src/components/nle/NLEContext.tsx +8 -3
- package/src/components/nle/NLEPreview.test.ts +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- package/src/hooks/draggedGsapPosition.ts +8 -0
- package/src/hooks/editMoment.ts +13 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapDragCommit.ts +2 -1
- package/src/hooks/gsapDragPositionCommit.ts +19 -7
- package/src/hooks/gsapPositionDetection.ts +5 -3
- package/src/hooks/gsapResizeIntercept.ts +11 -4
- package/src/hooks/gsapRuntimeBridge.ts +31 -11
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
- package/src/hooks/gsapValueAtPlayhead.ts +12 -5
- package/src/hooks/timelineEditingHelpers.test.ts +22 -0
- package/src/hooks/timelineEditingHelpers.ts +18 -2
- package/src/hooks/useDomSelection.test.ts +21 -2
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
- package/src/hooks/useGsapAwareEditing.ts +148 -136
- package/src/hooks/useGsapAwareGroupMove.ts +126 -113
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
- package/src/hooks/usePreviewPersistence.ts +1 -4
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
- package/src/hooks/useTimelineDeleteOps.ts +9 -10
- package/src/index.ts +2 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/hooks/useShadowPreviewReload.ts +6 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
- package/src/player/hooks/useTimelinePlayer.ts +8 -5
- package/src/player/lib/thumbnailScheduler.test.ts +20 -1
- package/src/player/lib/thumbnailScheduler.ts +1 -1
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineElementHelpers.ts +0 -11
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/previewReloading.test.ts +48 -0
- package/src/player/previewReloading.ts +48 -0
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- package/src/utils/rootDuration.ts +5 -4
- package/src/utils/studioPendingEdits.test.ts +27 -0
- package/src/utils/studioPendingEdits.ts +9 -0
- package/src/utils/timelineAssetDrop.test.ts +13 -4
- package/src/utils/timelineAssetDrop.ts +6 -2
- package/dist/chunk-TDWGXBUT.js.map +0 -1
- /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
- /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
- /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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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 = (
|
|
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 =
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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" },
|