@hyperframes/studio 0.8.55 → 0.8.57
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-c23wnIfc.js +464 -0
- package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
- package/dist/index.d.ts +111 -17
- package/dist/index.html +2 -2
- package/dist/index.js +21534 -20683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +18 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +62 -51
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -55
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
- package/src/components/editor/PropertyPanel.tsx +4 -3
- package/src/components/editor/SnapToolbar.test.tsx +26 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
- package/src/components/editor/previewReadOnlyContext.tsx +59 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
- package/src/components/editor/useDomEditNudge.ts +9 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
- package/src/components/editor/useOffCanvasIndicators.ts +50 -0
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +19 -24
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +129 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
- package/src/components/nle/usePreviewBlockDrop.ts +3 -1
- package/src/hooks/appHotkeysDispatch.ts +16 -1
- package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
- package/src/hooks/timelineAudioGroupCreate.ts +3 -4
- package/src/hooks/timelineTrackVisibility.ts +7 -8
- package/src/hooks/useAppHotkeys.test.ts +71 -3
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -0
- package/src/hooks/useCompositionDimensions.test.tsx +38 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useDomEditCommits.test.tsx +1 -0
- package/src/hooks/useDomEditCommits.ts +3 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useDomEditWiring.ts +40 -40
- package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
- package/src/hooks/useDomGeometryCommits.ts +26 -20
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useGestureCommit.test.tsx +29 -0
- package/src/hooks/useGestureCommit.ts +23 -0
- package/src/hooks/useInlineTextEdit.test.tsx +23 -2
- package/src/hooks/useInlineTextEdit.ts +11 -2
- package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
- package/src/hooks/useTimelineAssetDropOps.ts +1 -6
- package/src/hooks/useTimelineEditing.test.tsx +10 -7
- package/src/hooks/useTimelineEditing.ts +2 -7
- package/src/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -573
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +34 -30
- package/src/player/components/timelineTrackDisplay.ts +1 -4
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
- package/src/player/store/playerStore.ts +0 -4
- package/src/player/store/timelineElement.ts +2 -4
- package/src/player/store/timelineResetState.ts +0 -1
- package/src/styles/hex-baseline.json +3 -4
- package/src/styles/studio.css +7 -7
- package/src/styles/theme.css +29 -0
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- package/src/player/hooks/useTimelineRowElements.ts +0 -17
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
7
|
+
import { CANVAS_NUDGE_COMMIT_DEBOUNCE_MS } from "./domEditNudge";
|
|
8
|
+
import { __resetForTests } from "../../utils/canvasNudgeGate";
|
|
9
|
+
import { PreviewReadOnlyProvider } from "./previewReadOnlyContext";
|
|
10
|
+
import "./domEditOverlayTestMocks";
|
|
11
|
+
import { DomEditOverlay } from "./DomEditOverlay";
|
|
12
|
+
|
|
13
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
14
|
+
|
|
15
|
+
const RECT = { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 };
|
|
16
|
+
const layout = vi.hoisted(() => ({ group: [] as unknown[] }));
|
|
17
|
+
|
|
18
|
+
vi.mock("./useDomEditOverlayRects", () => ({
|
|
19
|
+
useDomEditOverlayRects: () => ({
|
|
20
|
+
overlayRect: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
|
|
21
|
+
overlayRectRef: {
|
|
22
|
+
current: { left: 100, top: 100, width: 200, height: 100, editScaleX: 1, editScaleY: 1 },
|
|
23
|
+
},
|
|
24
|
+
setOverlayRect: () => undefined,
|
|
25
|
+
hoverRect: null,
|
|
26
|
+
groupOverlayItems: layout.group,
|
|
27
|
+
groupOverlayItemsRef: { current: layout.group },
|
|
28
|
+
setGroupOverlayItems: () => undefined,
|
|
29
|
+
childRects: [],
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
const BOX = '[data-dom-edit-selection-box="true"]';
|
|
33
|
+
let root: Root;
|
|
34
|
+
let host: HTMLElement;
|
|
35
|
+
|
|
36
|
+
function textElement(id: string): HTMLElement {
|
|
37
|
+
const element = document.createElement("h1");
|
|
38
|
+
element.id = id;
|
|
39
|
+
element.textContent = "Title";
|
|
40
|
+
document.body.append(element);
|
|
41
|
+
return element;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function fixture(
|
|
45
|
+
overrides: Partial<React.ComponentProps<typeof DomEditOverlay>> = {},
|
|
46
|
+
readOnly = false,
|
|
47
|
+
) {
|
|
48
|
+
const spies = {
|
|
49
|
+
onCanvasMouseDown: vi.fn(),
|
|
50
|
+
onSelectionChange: vi.fn(),
|
|
51
|
+
onManualDragStart: vi.fn(),
|
|
52
|
+
onBlockedMove: vi.fn(),
|
|
53
|
+
onPathOffsetCommit: vi.fn(),
|
|
54
|
+
onGroupPathOffsetCommit: vi.fn(),
|
|
55
|
+
onBoxSizeCommit: vi.fn(),
|
|
56
|
+
onRotationCommit: vi.fn(),
|
|
57
|
+
onStyleCommit: vi.fn(),
|
|
58
|
+
onDeleteSelection: vi.fn(),
|
|
59
|
+
onApplyZIndex: vi.fn(),
|
|
60
|
+
};
|
|
61
|
+
const selection = makeSelection("Title", textElement("title"));
|
|
62
|
+
selection.capabilities.canApplyManualRotation = true;
|
|
63
|
+
selection.textFields = [{ key: "text", label: "Text", value: "Title" }] as never;
|
|
64
|
+
const props = {
|
|
65
|
+
iframeRef: { current: document.createElement("iframe") },
|
|
66
|
+
activeCompositionPath: null,
|
|
67
|
+
selection,
|
|
68
|
+
hoverSelection: null,
|
|
69
|
+
onCanvasPointerMove: () => Promise.resolve(selection),
|
|
70
|
+
onCanvasPointerLeave: () => undefined,
|
|
71
|
+
...spies,
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
host = document.createElement("div");
|
|
75
|
+
document.body.append(host);
|
|
76
|
+
root = createRoot(host);
|
|
77
|
+
act(() =>
|
|
78
|
+
root.render(
|
|
79
|
+
<PreviewReadOnlyProvider readOnly={readOnly}>
|
|
80
|
+
<DomEditOverlay {...props} />
|
|
81
|
+
</PreviewReadOnlyProvider>,
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
return { spies, selection, overlay: host.firstElementChild as HTMLElement };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const fire = (target: Element, type: string, init: MouseEventInit = {}) => {
|
|
88
|
+
const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, ...init });
|
|
89
|
+
act(() => {
|
|
90
|
+
target.dispatchEvent(event);
|
|
91
|
+
});
|
|
92
|
+
return event;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
vi.useFakeTimers();
|
|
97
|
+
__resetForTests();
|
|
98
|
+
HTMLElement.prototype.setPointerCapture = () => undefined;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
act(() => root.unmount());
|
|
103
|
+
document.body.innerHTML = "";
|
|
104
|
+
layout.group = [];
|
|
105
|
+
vi.useRealTimers();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("DomEditOverlay with the preview read-only", () => {
|
|
109
|
+
it("control: with the flag off a press on the box starts a move", () => {
|
|
110
|
+
const { spies, overlay } = fixture();
|
|
111
|
+
fire(overlay.querySelector(BOX)!, "pointerdown");
|
|
112
|
+
expect(spies.onManualDragStart).toHaveBeenCalledTimes(1);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("refuses to start a move from the selection box", () => {
|
|
116
|
+
const { spies, overlay } = fixture({}, true);
|
|
117
|
+
const box = overlay.querySelector(BOX)!;
|
|
118
|
+
fire(box, "pointerdown");
|
|
119
|
+
fire(box, "pointermove", { clientX: 40 });
|
|
120
|
+
fire(box, "pointerup");
|
|
121
|
+
expect(spies.onManualDragStart).not.toHaveBeenCalled();
|
|
122
|
+
expect(spies.onBlockedMove).not.toHaveBeenCalled();
|
|
123
|
+
expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("offers no resize dots, and none can start a resize", () => {
|
|
127
|
+
const off = fixture();
|
|
128
|
+
expect(off.overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(4);
|
|
129
|
+
act(() => root.unmount());
|
|
130
|
+
document.body.innerHTML = "";
|
|
131
|
+
const { spies, overlay } = fixture({}, true);
|
|
132
|
+
expect(overlay.querySelectorAll("div.h-4.w-4")).toHaveLength(0);
|
|
133
|
+
expect(spies.onBoxSizeCommit).not.toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("offers no rotate handle", () => {
|
|
137
|
+
const off = fixture();
|
|
138
|
+
expect(off.overlay.querySelector('[aria-label="Rotate selection"]')).not.toBeNull();
|
|
139
|
+
act(() => root.unmount());
|
|
140
|
+
document.body.innerHTML = "";
|
|
141
|
+
const { spies, overlay } = fixture({}, true);
|
|
142
|
+
expect(overlay.querySelector('[aria-label="Rotate selection"]')).toBeNull();
|
|
143
|
+
expect(spies.onRotationCommit).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("offers no crop handles, so clip-path is never committed", () => {
|
|
147
|
+
const off = fixture();
|
|
148
|
+
expect(off.overlay.querySelector("[data-dom-edit-crop-frame]")).not.toBeNull();
|
|
149
|
+
act(() => root.unmount());
|
|
150
|
+
document.body.innerHTML = "";
|
|
151
|
+
const { spies, overlay } = fixture({}, true);
|
|
152
|
+
expect(overlay.querySelector("[data-dom-edit-crop-frame]")).toBeNull();
|
|
153
|
+
expect(spies.onStyleCommit).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("does not drag a multi-selection", () => {
|
|
157
|
+
const members = ["a", "b"].map((id) => {
|
|
158
|
+
const selection = makeSelection(id, textElement(id));
|
|
159
|
+
return { key: id, selection, element: selection.element, rect: RECT };
|
|
160
|
+
});
|
|
161
|
+
layout.group = members;
|
|
162
|
+
const { spies, overlay } = fixture(
|
|
163
|
+
{
|
|
164
|
+
selection: null,
|
|
165
|
+
groupSelections: members.map((m) => m.selection),
|
|
166
|
+
},
|
|
167
|
+
true,
|
|
168
|
+
);
|
|
169
|
+
fire(overlay.querySelector(BOX)!, "pointerdown");
|
|
170
|
+
expect(spies.onManualDragStart).not.toHaveBeenCalled();
|
|
171
|
+
expect(spies.onGroupPathOffsetCommit).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const nudge = () => {
|
|
175
|
+
const event = new KeyboardEvent("keydown", {
|
|
176
|
+
key: "ArrowRight",
|
|
177
|
+
bubbles: true,
|
|
178
|
+
cancelable: true,
|
|
179
|
+
});
|
|
180
|
+
act(() => {
|
|
181
|
+
window.dispatchEvent(event);
|
|
182
|
+
vi.advanceTimersByTime(CANVAS_NUDGE_COMMIT_DEBOUNCE_MS + 10);
|
|
183
|
+
});
|
|
184
|
+
return event;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
it("control: with the flag off an arrow key nudges", () => {
|
|
188
|
+
const { spies } = fixture();
|
|
189
|
+
expect(nudge().defaultPrevented).toBe(true);
|
|
190
|
+
expect(spies.onPathOffsetCommit).toHaveBeenCalledTimes(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("neither nudges on an arrow key nor swallows it", () => {
|
|
194
|
+
const { spies } = fixture({}, true);
|
|
195
|
+
expect(nudge().defaultPrevented).toBe(false);
|
|
196
|
+
expect(spies.onPathOffsetCommit).not.toHaveBeenCalled();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const pressEnter = (overlay: HTMLElement) =>
|
|
200
|
+
act(() => {
|
|
201
|
+
overlay.dispatchEvent(
|
|
202
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("control: with the flag off Enter opens the text for editing", () => {
|
|
207
|
+
const { selection, overlay } = fixture();
|
|
208
|
+
pressEnter(overlay);
|
|
209
|
+
expect(selection.element.hasAttribute("contenteditable")).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("does not open text for editing on Enter", () => {
|
|
213
|
+
const { selection, overlay } = fixture({}, true);
|
|
214
|
+
pressEnter(overlay);
|
|
215
|
+
expect(selection.element.hasAttribute("contenteditable")).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const rightClick = async (overlay: HTMLElement) => {
|
|
219
|
+
await act(async () => {
|
|
220
|
+
overlay.dispatchEvent(
|
|
221
|
+
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 5, clientY: 5 }),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
it("control: with the flag off right-click offers delete and z-order", async () => {
|
|
227
|
+
const { overlay } = fixture();
|
|
228
|
+
await rightClick(overlay);
|
|
229
|
+
expect(document.body.textContent).toContain("Delete");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("offers no delete or z-order on right-click when read-only", async () => {
|
|
233
|
+
// Same fixture as the control test above (a real, already-selected element) so the
|
|
234
|
+
// context menu actually opens; `selection: null` here would close it via
|
|
235
|
+
// useCanvasContextMenuState's own deselect effect, before read-only ever mattered.
|
|
236
|
+
const { spies, overlay } = fixture({}, true);
|
|
237
|
+
await rightClick(overlay);
|
|
238
|
+
expect(document.body.textContent).not.toContain("Delete");
|
|
239
|
+
expect(spies.onDeleteSelection).not.toHaveBeenCalled();
|
|
240
|
+
expect(spies.onApplyZIndex).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("still selects and reports to the host on right-click of an unselected element", async () => {
|
|
244
|
+
const { spies, overlay } = fixture({ selection: null }, true);
|
|
245
|
+
await rightClick(overlay);
|
|
246
|
+
expect(spies.onSelectionChange).toHaveBeenCalledTimes(1);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("still selects and reports to the host on a click", () => {
|
|
250
|
+
const { spies, overlay } = fixture({ selection: null }, true);
|
|
251
|
+
fire(overlay, "mousedown");
|
|
252
|
+
expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("still selects on a click of the selection box", () => {
|
|
256
|
+
const { spies, overlay } = fixture({}, true);
|
|
257
|
+
fire(overlay.querySelector(BOX)!, "click");
|
|
258
|
+
expect(spies.onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { useContext, useMemo, type ReactNode } from "react";
|
|
2
|
+
import { createStableContext } from "../../utils/hmrStableContext";
|
|
3
|
+
|
|
4
|
+
interface PreviewReadOnlyValue {
|
|
5
|
+
readOnly: boolean;
|
|
6
|
+
reason: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DEFAULT_REASON = "Preview is read-only.";
|
|
10
|
+
|
|
11
|
+
// Real default (not a throwing required-provider context): most existing
|
|
12
|
+
// mounts render with no wrapper at all and must stay editable, exactly as today.
|
|
13
|
+
const PreviewReadOnlyContext = createStableContext<PreviewReadOnlyValue>("PreviewReadOnlyContext", {
|
|
14
|
+
readOnly: false,
|
|
15
|
+
reason: DEFAULT_REASON,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export function PreviewReadOnlyProvider({
|
|
19
|
+
readOnly,
|
|
20
|
+
reason,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
readOnly: boolean;
|
|
24
|
+
reason?: string;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}) {
|
|
27
|
+
const value = useMemo(() => ({ readOnly, reason: reason ?? DEFAULT_REASON }), [readOnly, reason]);
|
|
28
|
+
return <PreviewReadOnlyContext value={value}>{children}</PreviewReadOnlyContext>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function usePreviewReadOnly(): boolean {
|
|
32
|
+
return useContext(PreviewReadOnlyContext).readOnly;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Short host-supplied text for why a hand-edit control is disabled. */
|
|
36
|
+
export function usePreviewReadOnlyReason(): string {
|
|
37
|
+
return useContext(PreviewReadOnlyContext).reason;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ManualEditCapabilities {
|
|
41
|
+
canApplyManualOffset: boolean;
|
|
42
|
+
canApplyManualSize: boolean;
|
|
43
|
+
canApplyManualRotation: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The manual X/Y/W/H/rotation fields' disabled state: capability, or preview read-only.
|
|
48
|
+
* Call unconditionally, even with no selection — `capabilities` may be null/undefined.
|
|
49
|
+
*/
|
|
50
|
+
export function useManualEditDisabledFlags(
|
|
51
|
+
capabilities: ManualEditCapabilities | null | undefined,
|
|
52
|
+
) {
|
|
53
|
+
const { readOnly } = useContext(PreviewReadOnlyContext);
|
|
54
|
+
return {
|
|
55
|
+
manualOffsetEditingDisabled: !capabilities?.canApplyManualOffset || readOnly,
|
|
56
|
+
manualSizeEditingDisabled: !capabilities?.canApplyManualSize || readOnly,
|
|
57
|
+
manualRotationEditingDisabled: !capabilities?.canApplyManualRotation || readOnly,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -5,6 +5,7 @@ import { formatPxMetricValue } from "./propertyPanelHelpers";
|
|
|
5
5
|
import { resolveValueTier } from "./propertyPanelValueTier";
|
|
6
6
|
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
|
|
7
7
|
import type { DomEditSelection } from "./domEditingTypes";
|
|
8
|
+
import { usePreviewReadOnly, usePreviewReadOnlyReason } from "./previewReadOnlyContext";
|
|
8
9
|
|
|
9
10
|
type KeyframeEntry = Array<{
|
|
10
11
|
percentage: number;
|
|
@@ -119,6 +120,9 @@ export function LayoutGeometryRows({
|
|
|
119
120
|
onRemoveKeyframe,
|
|
120
121
|
onConvertToKeyframes,
|
|
121
122
|
}: GeometryRowsProps) {
|
|
123
|
+
const readOnlyPreview = usePreviewReadOnly();
|
|
124
|
+
const readOnlyPreviewReason = usePreviewReadOnlyReason();
|
|
125
|
+
const readOnlyTooltip = readOnlyPreview ? readOnlyPreviewReason : undefined;
|
|
122
126
|
const gutterProps = {
|
|
123
127
|
element,
|
|
124
128
|
gsapAnimId,
|
|
@@ -137,6 +141,7 @@ export function LayoutGeometryRows({
|
|
|
137
141
|
value={formatPxMetricValue(displayX)}
|
|
138
142
|
tier={displayX === 0 ? "default" : "explicitCustom"}
|
|
139
143
|
disabled={manualOffsetEditingDisabled}
|
|
144
|
+
tooltip={readOnlyTooltip}
|
|
140
145
|
onCommit={(next) => commitManualOffset("x", next)}
|
|
141
146
|
suffix={<KeyframeGutter property="x" displayValue={displayX} {...gutterProps} />}
|
|
142
147
|
/>
|
|
@@ -145,6 +150,7 @@ export function LayoutGeometryRows({
|
|
|
145
150
|
value={formatPxMetricValue(displayY)}
|
|
146
151
|
tier={displayY === 0 ? "default" : "explicitCustom"}
|
|
147
152
|
disabled={manualOffsetEditingDisabled}
|
|
153
|
+
tooltip={readOnlyTooltip}
|
|
148
154
|
onCommit={(next) => commitManualOffset("y", next)}
|
|
149
155
|
suffix={<KeyframeGutter property="y" displayValue={displayY} {...gutterProps} />}
|
|
150
156
|
/>
|
|
@@ -153,6 +159,7 @@ export function LayoutGeometryRows({
|
|
|
153
159
|
value={formatPxMetricValue(displayW)}
|
|
154
160
|
tier="default"
|
|
155
161
|
disabled={manualSizeEditingDisabled}
|
|
162
|
+
tooltip={readOnlyTooltip}
|
|
156
163
|
onCommit={(next) => commitManualSize("width", next)}
|
|
157
164
|
suffix={<KeyframeGutter property="width" displayValue={displayW} {...gutterProps} />}
|
|
158
165
|
/>
|
|
@@ -161,6 +168,7 @@ export function LayoutGeometryRows({
|
|
|
161
168
|
value={formatPxMetricValue(displayH)}
|
|
162
169
|
tier="default"
|
|
163
170
|
disabled={manualSizeEditingDisabled}
|
|
171
|
+
tooltip={readOnlyTooltip}
|
|
164
172
|
onCommit={(next) => commitManualSize("height", next)}
|
|
165
173
|
suffix={<KeyframeGutter property="height" displayValue={displayH} {...gutterProps} />}
|
|
166
174
|
/>
|
|
@@ -169,6 +177,7 @@ export function LayoutGeometryRows({
|
|
|
169
177
|
value={`${displayR}°`}
|
|
170
178
|
tier="default"
|
|
171
179
|
disabled={manualRotationEditingDisabled}
|
|
180
|
+
tooltip={readOnlyTooltip}
|
|
172
181
|
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
|
|
173
182
|
suffix={<KeyframeGutter property="rotation" displayValue={displayR} {...gutterProps} />}
|
|
174
183
|
/>
|
|
@@ -250,8 +250,8 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
|
|
|
250
250
|
rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
|
|
251
251
|
rateTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
|
|
252
252
|
});
|
|
253
|
-
//
|
|
254
|
-
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "
|
|
253
|
+
// the speed slider is log-scaled 0.1x..10x, so the far end of the track is 10x
|
|
254
|
+
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "10");
|
|
255
255
|
act(() => root.unmount());
|
|
256
256
|
});
|
|
257
257
|
|
|
@@ -13,6 +13,10 @@ import {
|
|
|
13
13
|
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
|
|
14
14
|
import { FlatToggle } from "./propertyPanelFlatToggle";
|
|
15
15
|
import { AutomationToggle } from "./propertyPanelFxControls";
|
|
16
|
+
import { RATE_RANGE } from "@hyperframes/core/audio-automation";
|
|
17
|
+
import { type SpeedPresetId } from "@hyperframes/core/speed-ramp";
|
|
18
|
+
import { SPEED_PRESET_OPTIONS, type RateBinding } from "./useVolumeAutomation";
|
|
19
|
+
import { fromUnit, toUnit } from "../../player/components/automationLaneGeometry";
|
|
16
20
|
import {
|
|
17
21
|
AUDIO_GAIN_FADER_MAX,
|
|
18
22
|
AUDIO_GAIN_FADER_MIN,
|
|
@@ -36,6 +40,7 @@ export function FlatMediaSection({
|
|
|
36
40
|
onRemoveVolumeAutomation,
|
|
37
41
|
onCommitVolumeAt,
|
|
38
42
|
automatedVolumeValue,
|
|
43
|
+
rate,
|
|
39
44
|
}: {
|
|
40
45
|
projectDir: string | null;
|
|
41
46
|
element: DomEditSelection;
|
|
@@ -49,6 +54,8 @@ export function FlatMediaSection({
|
|
|
49
54
|
onRemoveVolumeAutomation?: () => void;
|
|
50
55
|
onCommitVolumeAt?: (v: number) => void;
|
|
51
56
|
automatedVolumeValue?: number;
|
|
57
|
+
/** Speed lane binding and presets; absent outside the Studio panel. */
|
|
58
|
+
rate?: RateBinding;
|
|
52
59
|
onRemoveBackground?: (
|
|
53
60
|
inputPath: string,
|
|
54
61
|
options: {
|
|
@@ -77,7 +84,9 @@ export function FlatMediaSection({
|
|
|
77
84
|
Number.parseFloat(
|
|
78
85
|
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
|
|
79
86
|
) || 0;
|
|
80
|
-
const
|
|
87
|
+
const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
|
|
88
|
+
const playbackRate =
|
|
89
|
+
rate?.automated && rate.automatedValue !== undefined ? rate.automatedValue : constantRate;
|
|
81
90
|
const sourceDuration =
|
|
82
91
|
Number.parseFloat(element.dataAttributes["source-duration"] ?? "") ||
|
|
83
92
|
(el as HTMLMediaElement).duration ||
|
|
@@ -259,17 +268,42 @@ export function FlatMediaSection({
|
|
|
259
268
|
}
|
|
260
269
|
/>
|
|
261
270
|
</div>
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
271
|
+
<div className="flex items-center gap-1">
|
|
272
|
+
<div className="min-w-0 flex-1">
|
|
273
|
+
<FlatSlider
|
|
274
|
+
label="Speed"
|
|
275
|
+
value={Math.round(toUnit(RATE_RANGE, playbackRate) * 1000)}
|
|
276
|
+
min={0}
|
|
277
|
+
max={1000}
|
|
278
|
+
tier={playbackRate === 1 ? "default" : "explicitCustom"}
|
|
279
|
+
displayValue={`${formatNumericValue(playbackRate)}x`}
|
|
280
|
+
onCommit={(next) => {
|
|
281
|
+
const speed = fromUnit(RATE_RANGE, next / 1000);
|
|
282
|
+
if (rate?.automated) {
|
|
283
|
+
rate.onCommitAt(speed);
|
|
284
|
+
} else {
|
|
285
|
+
void onSetAttribute("playback-rate", formatNumericValue(speed));
|
|
286
|
+
}
|
|
287
|
+
}}
|
|
288
|
+
/>
|
|
289
|
+
</div>
|
|
290
|
+
<AutomationToggle
|
|
291
|
+
paramKey="rate"
|
|
292
|
+
label="Speed"
|
|
293
|
+
automated={Boolean(rate?.automated)}
|
|
294
|
+
onAutomate={rate ? () => rate.onAutomate() : undefined}
|
|
295
|
+
onRemoveAutomation={rate ? () => rate.onRemoveAutomation() : undefined}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
{rate?.canApplyPreset && (
|
|
299
|
+
<FlatSelectRow
|
|
300
|
+
label="Speed preset"
|
|
301
|
+
value=""
|
|
302
|
+
options={[{ value: "", label: "Choose…" }, ...SPEED_PRESET_OPTIONS]}
|
|
303
|
+
tier="default"
|
|
304
|
+
onChange={(id) => id && rate.onApplyPreset(id as SpeedPresetId)}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
273
307
|
<FlatSlider
|
|
274
308
|
label="Media start"
|
|
275
309
|
value={Math.round(mediaStart * 100)}
|
|
@@ -19,6 +19,7 @@ export function FlatRow({
|
|
|
19
19
|
value,
|
|
20
20
|
tier,
|
|
21
21
|
disabled,
|
|
22
|
+
tooltip,
|
|
22
23
|
liveCommit,
|
|
23
24
|
suffix,
|
|
24
25
|
dropdown,
|
|
@@ -30,6 +31,8 @@ export function FlatRow({
|
|
|
30
31
|
value: string;
|
|
31
32
|
tier: PropertyValueTier;
|
|
32
33
|
disabled?: boolean;
|
|
34
|
+
/** Shown as a title attribute, e.g. why the row is disabled. */
|
|
35
|
+
tooltip?: string;
|
|
33
36
|
liveCommit?: boolean;
|
|
34
37
|
suffix?: ReactNode;
|
|
35
38
|
/** Renders a trailing 10px caret-down, for select-backed rows. */
|
|
@@ -40,7 +43,7 @@ export function FlatRow({
|
|
|
40
43
|
}) {
|
|
41
44
|
const track = useTrackDesignInput();
|
|
42
45
|
return (
|
|
43
|
-
<div className="group flex min-h-[30px] items-center justify-between gap-3">
|
|
46
|
+
<div className="group flex min-h-[30px] items-center justify-between gap-3" title={tooltip}>
|
|
44
47
|
<span className={`text-[11px] ${VALUE_TIER_LABEL_CLASS[tier]}`}>{label}</span>
|
|
45
48
|
<span className="flex min-w-0 shrink-0 items-center gap-1.5">
|
|
46
49
|
<span
|
|
@@ -459,12 +459,7 @@ function representativeElement() {
|
|
|
459
459
|
describe("classic PropertyPanel input coverage", () => {
|
|
460
460
|
it("emits only named, known-section events across body inputs and header/footer chrome", async () => {
|
|
461
461
|
vi.resetModules();
|
|
462
|
-
vi.doMock("./manualEditingAvailability",
|
|
463
|
-
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
|
|
464
|
-
"./manualEditingAvailability",
|
|
465
|
-
);
|
|
466
|
-
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: false };
|
|
467
|
-
});
|
|
462
|
+
vi.doMock("./manualEditingAvailability", () => ({ STUDIO_FLAT_INSPECTOR_ENABLED: false }));
|
|
468
463
|
const { PropertyPanel } = await import("./PropertyPanel");
|
|
469
464
|
const host = render(
|
|
470
465
|
<PropertyPanel
|
|
@@ -524,18 +519,13 @@ describe("classic PropertyPanel input coverage", () => {
|
|
|
524
519
|
expect(sections.has("header")).toBe(true);
|
|
525
520
|
expect(sections.has("footer")).toBe(true);
|
|
526
521
|
expect(sections.size).toBeGreaterThan(2);
|
|
527
|
-
});
|
|
522
|
+
}, 15000);
|
|
528
523
|
});
|
|
529
524
|
|
|
530
525
|
describe("flat PropertyPanel input coverage", () => {
|
|
531
526
|
it("emits only named flat events from known sections for every visible layout input", async () => {
|
|
532
527
|
vi.resetModules();
|
|
533
|
-
vi.doMock("./manualEditingAvailability",
|
|
534
|
-
const actual = await vi.importActual<typeof import("./manualEditingAvailability")>(
|
|
535
|
-
"./manualEditingAvailability",
|
|
536
|
-
);
|
|
537
|
-
return { ...actual, STUDIO_FLAT_INSPECTOR_ENABLED: true };
|
|
538
|
-
});
|
|
528
|
+
vi.doMock("./manualEditingAvailability", () => ({ STUDIO_FLAT_INSPECTOR_ENABLED: true }));
|
|
539
529
|
const { PropertyPanel } = await import("./PropertyPanel");
|
|
540
530
|
const host = render(
|
|
541
531
|
<PropertyPanel
|
|
@@ -167,6 +167,15 @@ export function useDomEditNudge(params: UseDomEditNudgeParams): { flushNudge: ()
|
|
|
167
167
|
const commitSessionRef = useRef(commitSession);
|
|
168
168
|
commitSessionRef.current = commitSession;
|
|
169
169
|
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (params.allowCanvasMovement || !sessionRef.current) return;
|
|
172
|
+
const session = sessionRef.current;
|
|
173
|
+
sessionRef.current = null;
|
|
174
|
+
if (session.timer) clearTimeout(session.timer);
|
|
175
|
+
restoreManualOffsetDragMembers(session.members);
|
|
176
|
+
endManualOffsetDragMembers(session.members);
|
|
177
|
+
}, [params.allowCanvasMovement]);
|
|
178
|
+
|
|
170
179
|
// Build drag members for the current target set — the same member snapshot a
|
|
171
180
|
// pointer drag starts from (startGesture / startGroupDrag), so the nudge
|
|
172
181
|
// commit converts offsets → GSAP x/y with identical math.
|
|
@@ -532,6 +532,10 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
|
|
|
532
532
|
restoreGestureOverlayRect(g);
|
|
533
533
|
}
|
|
534
534
|
if (g?.mode === "rotation" && sel) {
|
|
535
|
+
applyRotationDraftViaGsap(
|
|
536
|
+
sel.element,
|
|
537
|
+
g.actualRotation - (Number.parseFloat(g.initialRotation.studioRotation) || 0),
|
|
538
|
+
);
|
|
535
539
|
restoreStudioRotation(sel.element, g.initialRotation);
|
|
536
540
|
endStudioManualEditGesture(sel.element, g.manualEditDragToken);
|
|
537
541
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type RefObject } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
|
+
import type { DomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
4
|
+
import { type OffCanvasRect } from "./OffCanvasIndicators";
|
|
5
|
+
import { startOffCanvasIndicatorRefresh } from "./offCanvasIndicatorRefresh";
|
|
6
|
+
|
|
7
|
+
export function useOffCanvasIndicators({
|
|
8
|
+
iframeRef,
|
|
9
|
+
overlayRef,
|
|
10
|
+
compRectRef,
|
|
11
|
+
activeCompositionPathRef,
|
|
12
|
+
activeCompositionPath,
|
|
13
|
+
}: {
|
|
14
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
15
|
+
overlayRef: RefObject<HTMLDivElement | null>;
|
|
16
|
+
compRectRef: React.MutableRefObject<DomEditCompositionRect>;
|
|
17
|
+
activeCompositionPathRef: React.MutableRefObject<string | null>;
|
|
18
|
+
activeCompositionPath: string | null;
|
|
19
|
+
}): {
|
|
20
|
+
offCanvasRects: OffCanvasRect[];
|
|
21
|
+
offCanvasElementsRef: React.MutableRefObject<Map<string, HTMLElement>>;
|
|
22
|
+
} {
|
|
23
|
+
const offCanvasElementsRef = useRef<Map<string, HTMLElement>>(new Map());
|
|
24
|
+
const [offCanvasRects, setOffCanvasRects] = useState<OffCanvasRect[]>([]);
|
|
25
|
+
const offCanvasDirtyRef = useRef(true);
|
|
26
|
+
const offCanvasSigRef = useRef("");
|
|
27
|
+
const offCanvasObserverRef = useRef<MutationObserver | null>(null);
|
|
28
|
+
const offCanvasObservedDocRef = useRef<Document | null>(null);
|
|
29
|
+
|
|
30
|
+
useMountEffect(() =>
|
|
31
|
+
startOffCanvasIndicatorRefresh({
|
|
32
|
+
iframeRef,
|
|
33
|
+
overlayRef,
|
|
34
|
+
compRectRef,
|
|
35
|
+
activeCompositionPathRef,
|
|
36
|
+
dirtyRef: offCanvasDirtyRef,
|
|
37
|
+
sigRef: offCanvasSigRef,
|
|
38
|
+
observerRef: offCanvasObserverRef,
|
|
39
|
+
observedDocRef: offCanvasObservedDocRef,
|
|
40
|
+
elementsRef: offCanvasElementsRef,
|
|
41
|
+
setRects: setOffCanvasRects,
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
offCanvasDirtyRef.current = true;
|
|
47
|
+
}, [activeCompositionPath]);
|
|
48
|
+
|
|
49
|
+
return { offCanvasRects, offCanvasElementsRef };
|
|
50
|
+
}
|