@hyperframes/studio 0.8.97 → 0.8.98
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-Biu5_W0l.js → hyperframes-player-C6muFtfk.js} +1 -1
- package/dist/assets/{index-B5Z-1RW9.js → index-B6vU39NM.js} +1 -1
- package/dist/assets/{index-CWuozokW.js → index-BxnoLgxG.js} +1 -1
- package/dist/assets/{index-BdV5T-QI.js → index-itc1XziP.js} +174 -174
- package/dist/index.d.ts +16 -4
- package/dist/index.html +1 -1
- package/dist/index.js +216 -234
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/components/EditorShell.tsx +7 -0
- package/src/components/editor/DomEditOverlay.tsx +0 -27
- package/src/components/editor/DomEditSelectionChrome.test.tsx +0 -5
- package/src/components/editor/DomEditSelectionChrome.tsx +0 -6
- package/src/components/editor/clickAfterPreventedPress.test.tsx +122 -0
- package/src/components/editor/propertyPanelFont.test.tsx +48 -5
- package/src/components/editor/propertyPanelFont.tsx +2 -1
- package/src/components/editor/shiftDragAxisLock.test.tsx +0 -2
- package/src/components/nle/TimelinePane.tsx +4 -0
- package/src/hooks/useCommitPreflightCapabilities.ts +3 -11
- package/src/hooks/useGsapAnimationFetchFallback.test.ts +44 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +11 -35
- package/src/hooks/useGsapTweenCache.element.test.tsx +23 -1
- package/src/hooks/useGsapTweenCache.ts +0 -24
- package/src/index.ts +4 -1
- package/src/player/components/ClipContextMenu.test.tsx +182 -0
- package/src/player/components/ClipContextMenu.tsx +52 -10
- package/src/player/components/TimelineOverlays.test.ts +30 -7
- package/src/player/components/TimelineOverlays.tsx +7 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTypes.ts +10 -0
- package/src/player/components/menuKeyboardNav.ts +12 -5
- package/src/player/components/useTimelineProviderState.tsx +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.98",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -60,12 +60,12 @@
|
|
|
60
60
|
"dockview-react": "8.3.1",
|
|
61
61
|
"gsap": "^3.13.0",
|
|
62
62
|
"mediabunny": "^1.45.3",
|
|
63
|
-
"@hyperframes/core": "0.8.
|
|
64
|
-
"@hyperframes/parsers": "0.8.
|
|
65
|
-
"@hyperframes/player": "0.8.
|
|
66
|
-
"@hyperframes/
|
|
67
|
-
"@hyperframes/
|
|
68
|
-
"@hyperframes/studio-server": "0.8.
|
|
63
|
+
"@hyperframes/core": "0.8.98",
|
|
64
|
+
"@hyperframes/parsers": "0.8.98",
|
|
65
|
+
"@hyperframes/player": "0.8.98",
|
|
66
|
+
"@hyperframes/sdk": "0.8.98",
|
|
67
|
+
"@hyperframes/shader-transitions": "0.8.98",
|
|
68
|
+
"@hyperframes/studio-server": "0.8.98"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@tailwindcss/vite": "^4.3.3",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"vite": "^6.4.2",
|
|
82
82
|
"vitest": "^4.1.11",
|
|
83
83
|
"zustand": "^5.0.0",
|
|
84
|
-
"@hyperframes/producer": "0.8.
|
|
84
|
+
"@hyperframes/producer": "0.8.98"
|
|
85
85
|
},
|
|
86
86
|
"peerDependencies": {
|
|
87
87
|
"react": "19",
|
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
"build": "vite build && tsup",
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
|
95
95
|
"test": "vitest run",
|
|
96
|
+
"test:edit-accuracy": "bun tests/e2e/edit-accuracy/run.mjs",
|
|
96
97
|
"test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs",
|
|
97
98
|
"test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs",
|
|
98
99
|
"test:watch": "vitest",
|
|
@@ -18,6 +18,7 @@ import type { GestureRecordingState } from "./editor/GestureRecordControl";
|
|
|
18
18
|
import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync";
|
|
19
19
|
import { StudioAgentTools } from "../webmcp/StudioAgentTools";
|
|
20
20
|
import type { TimelineDropPlacement } from "../player/components/timelineCallbacks";
|
|
21
|
+
import type { TimelineProps } from "../player/components/TimelineTypes";
|
|
21
22
|
import { Dock } from "./dock/Dock";
|
|
22
23
|
|
|
23
24
|
type RenderClipContent = (
|
|
@@ -57,6 +58,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
|
|
|
57
58
|
onPasteClip: () => Promise<void>;
|
|
58
59
|
onDuplicateClip: () => Promise<boolean>;
|
|
59
60
|
canPasteClip: () => boolean;
|
|
61
|
+
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
60
62
|
setCompIdToSrc: (map: Map<string, string>) => void;
|
|
61
63
|
setCompositionLoading: (loading: boolean) => void;
|
|
62
64
|
shouldShowMotionPath: boolean;
|
|
@@ -105,6 +107,7 @@ export function EditorShell({
|
|
|
105
107
|
onPasteClip,
|
|
106
108
|
onDuplicateClip,
|
|
107
109
|
canPasteClip,
|
|
110
|
+
clipMenuItems,
|
|
108
111
|
setCompIdToSrc,
|
|
109
112
|
setCompositionLoading,
|
|
110
113
|
shouldShowMotionPath,
|
|
@@ -201,6 +204,7 @@ export function EditorShell({
|
|
|
201
204
|
onPasteClip={onPasteClip}
|
|
202
205
|
onDuplicateClip={onDuplicateClip}
|
|
203
206
|
canPasteClip={canPasteClip}
|
|
207
|
+
clipMenuItems={clipMenuItems}
|
|
204
208
|
previewOverlay={
|
|
205
209
|
<PreviewOverlays
|
|
206
210
|
shouldShowMotionPath={shouldShowMotionPath}
|
|
@@ -244,6 +248,7 @@ interface EditorShellBodyProps {
|
|
|
244
248
|
onPasteClip: () => Promise<void>;
|
|
245
249
|
onDuplicateClip: () => Promise<boolean>;
|
|
246
250
|
canPasteClip: () => boolean;
|
|
251
|
+
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
function EditorShellBody({
|
|
@@ -264,6 +269,7 @@ function EditorShellBody({
|
|
|
264
269
|
onPasteClip,
|
|
265
270
|
onDuplicateClip,
|
|
266
271
|
canPasteClip,
|
|
272
|
+
clipMenuItems,
|
|
267
273
|
}: EditorShellBodyProps) {
|
|
268
274
|
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
|
|
269
275
|
|
|
@@ -316,6 +322,7 @@ function EditorShellBody({
|
|
|
316
322
|
onPasteClip={onPasteClip}
|
|
317
323
|
onDuplicateClip={onDuplicateClip}
|
|
318
324
|
canPasteClip={canPasteClip}
|
|
325
|
+
clipMenuItems={clipMenuItems}
|
|
319
326
|
onSelectTimelineElement={onSelectTimelineElement}
|
|
320
327
|
timelineFooter={
|
|
321
328
|
captionEditMode ? (
|
|
@@ -161,8 +161,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
161
161
|
const groupGestureRef = useRef<GroupGestureState | null>(null);
|
|
162
162
|
const blockedMoveRef = useRef<BlockedMoveState | null>(null);
|
|
163
163
|
const suppressNextBoxClickRef = useRef(false);
|
|
164
|
-
const suppressNextBoxMouseDownRef = useRef(false);
|
|
165
|
-
const suppressNextOverlayMouseDownRef = useRef(false);
|
|
166
164
|
const snapGuidesRef = useRef<SnapGuidesState | null>(null);
|
|
167
165
|
const rafPausedRef = useRef(false);
|
|
168
166
|
|
|
@@ -329,15 +327,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
329
327
|
|
|
330
328
|
const handleOverlayMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
331
329
|
if (!allowCanvasMovement) return;
|
|
332
|
-
if (suppressNextOverlayMouseDownRef.current) {
|
|
333
|
-
logSelect("mousedown-suppressed", { shift: event.shiftKey });
|
|
334
|
-
suppressNextOverlayMouseDownRef.current = false;
|
|
335
|
-
suppressNextBoxMouseDownRef.current = false;
|
|
336
|
-
suppressNextBoxClickRef.current = false;
|
|
337
|
-
event.preventDefault();
|
|
338
|
-
event.stopPropagation();
|
|
339
|
-
return;
|
|
340
|
-
}
|
|
341
330
|
const target = event.target as HTMLElement | null;
|
|
342
331
|
const onBox = Boolean(target?.closest('[data-dom-edit-selection-box="true"]'));
|
|
343
332
|
logSelect("mousedown", { shift: event.shiftKey, onBox });
|
|
@@ -346,10 +335,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
346
335
|
// extend beyond the composition rect into the gray zone, and users need
|
|
347
336
|
// to select/deselect them by clicking there.
|
|
348
337
|
onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
|
|
349
|
-
if (event.shiftKey) {
|
|
350
|
-
suppressNextBoxMouseDownRef.current = true;
|
|
351
|
-
suppressNextBoxClickRef.current = true;
|
|
352
|
-
}
|
|
353
338
|
};
|
|
354
339
|
|
|
355
340
|
// fallow-ignore-next-line complexity
|
|
@@ -373,8 +358,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
373
358
|
if (!candidate) return;
|
|
374
359
|
event.preventDefault();
|
|
375
360
|
event.stopPropagation();
|
|
376
|
-
suppressNextOverlayMouseDownRef.current = true;
|
|
377
|
-
suppressNextBoxMouseDownRef.current = true;
|
|
378
361
|
suppressNextBoxClickRef.current = true;
|
|
379
362
|
onSelectionChangeRef.current(candidate, { additive: true });
|
|
380
363
|
return;
|
|
@@ -419,7 +402,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
419
402
|
// so those elements were always selectable once the band could begin.
|
|
420
403
|
event.preventDefault();
|
|
421
404
|
event.stopPropagation();
|
|
422
|
-
suppressNextOverlayMouseDownRef.current = true;
|
|
423
405
|
marquee.begin(event);
|
|
424
406
|
return;
|
|
425
407
|
}
|
|
@@ -437,13 +419,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
437
419
|
onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current });
|
|
438
420
|
};
|
|
439
421
|
|
|
440
|
-
const suppressBoxMouseDown = (e: React.MouseEvent) => {
|
|
441
|
-
if (!suppressNextBoxMouseDownRef.current) return;
|
|
442
|
-
suppressNextBoxMouseDownRef.current = false;
|
|
443
|
-
e.preventDefault();
|
|
444
|
-
e.stopPropagation();
|
|
445
|
-
};
|
|
446
|
-
|
|
447
422
|
// Right-click state + handler: select the element under the pointer (if
|
|
448
423
|
// needed), then open the menu; closes when the selection moves off-target.
|
|
449
424
|
const { contextMenu, closeContextMenu, handleContextMenu } = useCanvasContextMenuState({
|
|
@@ -509,7 +484,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
509
484
|
allowBodyDrag={bodyDrag}
|
|
510
485
|
groupCanMove={groupCanMove}
|
|
511
486
|
gestures={gestures}
|
|
512
|
-
onBoxMouseDown={suppressBoxMouseDown}
|
|
513
487
|
onBoxClick={handleBoxClick}
|
|
514
488
|
/>
|
|
515
489
|
)}
|
|
@@ -528,7 +502,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
|
|
|
528
502
|
groupSelectionCount={groupSelections.length}
|
|
529
503
|
gestures={gestures}
|
|
530
504
|
onStyleCommit={onStyleCommitRef.current}
|
|
531
|
-
onBoxMouseDown={suppressBoxMouseDown}
|
|
532
505
|
onBoxClick={handleBoxClick}
|
|
533
506
|
/>
|
|
534
507
|
)}
|
|
@@ -56,7 +56,6 @@ describe("DomEditSelectionChrome crop composition", () => {
|
|
|
56
56
|
groupSelectionCount={0}
|
|
57
57
|
gestures={{ startGesture: vi.fn() } as never}
|
|
58
58
|
onStyleCommit={vi.fn()}
|
|
59
|
-
onBoxMouseDown={vi.fn()}
|
|
60
59
|
onBoxClick={vi.fn()}
|
|
61
60
|
/>,
|
|
62
61
|
);
|
|
@@ -112,7 +111,6 @@ describe("DomEditSelectionChrome crop composition", () => {
|
|
|
112
111
|
groupSelectionCount={0}
|
|
113
112
|
gestures={{ startGesture: vi.fn() } as never}
|
|
114
113
|
onStyleCommit={vi.fn()}
|
|
115
|
-
onBoxMouseDown={vi.fn()}
|
|
116
114
|
onBoxClick={vi.fn()}
|
|
117
115
|
/>,
|
|
118
116
|
);
|
|
@@ -173,7 +171,6 @@ describe("DomEditSelectionChrome while editing text", () => {
|
|
|
173
171
|
groupSelectionCount={0}
|
|
174
172
|
gestures={{ startGesture: vi.fn() } as never}
|
|
175
173
|
onStyleCommit={vi.fn()}
|
|
176
|
-
onBoxMouseDown={vi.fn()}
|
|
177
174
|
onBoxClick={vi.fn()}
|
|
178
175
|
inlineText={{ editing, startFromPress: vi.fn() }}
|
|
179
176
|
/>,
|
|
@@ -258,7 +255,6 @@ describe("DomEditSelectionChrome with body drag off", () => {
|
|
|
258
255
|
selectionKey="box"
|
|
259
256
|
groupSelectionCount={0}
|
|
260
257
|
gestures={gestures as never}
|
|
261
|
-
onBoxMouseDown={vi.fn()}
|
|
262
258
|
onBoxClick={vi.fn()}
|
|
263
259
|
/>,
|
|
264
260
|
);
|
|
@@ -313,7 +309,6 @@ describe("DomEditSelectionChrome with body drag off", () => {
|
|
|
313
309
|
allowBodyDrag={false}
|
|
314
310
|
groupCanMove
|
|
315
311
|
gestures={gestures as never}
|
|
316
|
-
onBoxMouseDown={vi.fn()}
|
|
317
312
|
onBoxClick={vi.fn()}
|
|
318
313
|
/>,
|
|
319
314
|
);
|
|
@@ -62,7 +62,6 @@ interface DomEditGroupChromeProps {
|
|
|
62
62
|
allowBodyDrag: boolean;
|
|
63
63
|
groupCanMove: boolean;
|
|
64
64
|
gestures: GestureHandlers;
|
|
65
|
-
onBoxMouseDown: (e: React.MouseEvent) => void;
|
|
66
65
|
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
67
66
|
}
|
|
68
67
|
|
|
@@ -75,7 +74,6 @@ export function DomEditGroupChrome({
|
|
|
75
74
|
allowBodyDrag,
|
|
76
75
|
groupCanMove,
|
|
77
76
|
gestures,
|
|
78
|
-
onBoxMouseDown,
|
|
79
77
|
onBoxClick,
|
|
80
78
|
}: DomEditGroupChromeProps) {
|
|
81
79
|
const canManipulate = allowCanvasMovement && !usePreviewReadOnly();
|
|
@@ -108,7 +106,6 @@ export function DomEditGroupChrome({
|
|
|
108
106
|
if (!canManipulate || !allowBodyDrag || (e.shiftKey && !groupCanMove)) return;
|
|
109
107
|
gestures.startGroupDrag(e);
|
|
110
108
|
}}
|
|
111
|
-
onMouseDown={onBoxMouseDown}
|
|
112
109
|
onClick={onBoxClick}
|
|
113
110
|
/>
|
|
114
111
|
</>
|
|
@@ -128,7 +125,6 @@ interface DomEditSelectionChromeProps {
|
|
|
128
125
|
groupSelectionCount: number;
|
|
129
126
|
gestures: GestureHandlers;
|
|
130
127
|
onStyleCommit?: (property: string, value: string) => Promise<unknown> | void;
|
|
131
|
-
onBoxMouseDown: (e: React.MouseEvent) => void;
|
|
132
128
|
onBoxClick: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
133
129
|
/** The canvas' text-editing session: what opens one, and whether one is open. */
|
|
134
130
|
inlineText?: {
|
|
@@ -157,7 +153,6 @@ export function DomEditSelectionChrome({
|
|
|
157
153
|
groupSelectionCount,
|
|
158
154
|
gestures,
|
|
159
155
|
onStyleCommit,
|
|
160
|
-
onBoxMouseDown,
|
|
161
156
|
onBoxClick,
|
|
162
157
|
inlineText,
|
|
163
158
|
}: DomEditSelectionChromeProps) {
|
|
@@ -225,7 +220,6 @@ export function DomEditSelectionChrome({
|
|
|
225
220
|
}
|
|
226
221
|
if (!e.shiftKey) gestures.startBlockedMove(e, selection);
|
|
227
222
|
}}
|
|
228
|
-
onMouseDown={onBoxMouseDown}
|
|
229
223
|
onClick={onBoxClick}
|
|
230
224
|
>
|
|
231
225
|
{cropOutlineInsetPx && (
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
6
|
+
import { makeSelection } from "../../hooks/domSelectionTestHarness";
|
|
7
|
+
import type { DomEditSelection } from "./domEditing";
|
|
8
|
+
import "./domEditOverlayTestMocks";
|
|
9
|
+
import { DomEditOverlay } from "./DomEditOverlay";
|
|
10
|
+
|
|
11
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
12
|
+
HTMLElement.prototype.setPointerCapture ??= () => {};
|
|
13
|
+
HTMLElement.prototype.releasePointerCapture ??= () => {};
|
|
14
|
+
|
|
15
|
+
const pointTarget = vi.hoisted(() => ({ current: null as HTMLElement | null }));
|
|
16
|
+
const rect = vi.hoisted(() => ({ current: null as Record<string, number> | null }));
|
|
17
|
+
vi.mock("../../utils/studioPreviewHelpers", async (importOriginal) => ({
|
|
18
|
+
...(await importOriginal<typeof import("../../utils/studioPreviewHelpers")>()),
|
|
19
|
+
getPreviewTargetFromPointer: () => pointTarget.current,
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("./useDomEditOverlayRects", () => ({
|
|
22
|
+
useDomEditOverlayRects: () => ({
|
|
23
|
+
overlayRect: rect.current,
|
|
24
|
+
overlayRectRef: rect,
|
|
25
|
+
setOverlayRect: () => undefined,
|
|
26
|
+
hoverRect: null,
|
|
27
|
+
groupOverlayItems: [],
|
|
28
|
+
groupOverlayItemsRef: { current: [] },
|
|
29
|
+
setGroupOverlayItems: () => undefined,
|
|
30
|
+
childRects: [],
|
|
31
|
+
}),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
let root: Root;
|
|
35
|
+
const onCanvasMouseDown = vi.fn();
|
|
36
|
+
const onSelectionChange = vi.fn();
|
|
37
|
+
|
|
38
|
+
function mount() {
|
|
39
|
+
const host = document.createElement("div");
|
|
40
|
+
document.body.append(host);
|
|
41
|
+
root = createRoot(host);
|
|
42
|
+
const element = document.createElement("h1");
|
|
43
|
+
document.body.append(element);
|
|
44
|
+
return { host, element, hover: makeSelection("Title", element) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function render(
|
|
48
|
+
hoverSelection: DomEditSelection | null,
|
|
49
|
+
selection: DomEditSelection | null = null,
|
|
50
|
+
) {
|
|
51
|
+
act(() =>
|
|
52
|
+
root.render(
|
|
53
|
+
<DomEditOverlay
|
|
54
|
+
iframeRef={{ current: document.createElement("iframe") }}
|
|
55
|
+
activeCompositionPath={null}
|
|
56
|
+
selection={selection}
|
|
57
|
+
hoverSelection={hoverSelection}
|
|
58
|
+
onCanvasMouseDown={onCanvasMouseDown}
|
|
59
|
+
onCanvasPointerMove={() => Promise.resolve(null)}
|
|
60
|
+
onCanvasPointerLeave={() => undefined}
|
|
61
|
+
onSelectionChange={onSelectionChange}
|
|
62
|
+
onBlockedMove={() => undefined}
|
|
63
|
+
onPathOffsetCommit={() => undefined}
|
|
64
|
+
onGroupPathOffsetCommit={() => undefined}
|
|
65
|
+
onBoxSizeCommit={() => undefined}
|
|
66
|
+
onRotationCommit={() => undefined}
|
|
67
|
+
onMarqueeSelect={() => undefined}
|
|
68
|
+
/>,
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Like Chrome: a default-prevented pointerdown sends no compatibility mousedown.
|
|
74
|
+
function press(target: Element, shiftKey = false) {
|
|
75
|
+
const init = { bubbles: true, cancelable: true, button: 0, pointerId: 1, shiftKey };
|
|
76
|
+
const down = new PointerEvent("pointerdown", init);
|
|
77
|
+
act(() => void target.dispatchEvent(down));
|
|
78
|
+
if (!down.defaultPrevented)
|
|
79
|
+
act(() => void target.dispatchEvent(new MouseEvent("mousedown", init)));
|
|
80
|
+
act(() => void target.dispatchEvent(new PointerEvent("pointerup", init)));
|
|
81
|
+
act(() => void target.dispatchEvent(new MouseEvent("click", init)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
onCanvasMouseDown.mockClear();
|
|
87
|
+
onSelectionChange.mockClear();
|
|
88
|
+
pointTarget.current = null;
|
|
89
|
+
rect.current = null;
|
|
90
|
+
document.body.innerHTML = "";
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it.each([
|
|
94
|
+
["a press on empty canvas", false],
|
|
95
|
+
["a shift+click add", true],
|
|
96
|
+
])("after %s, the next click on an element selects it", (_, shiftFirst) => {
|
|
97
|
+
const { host, element, hover } = mount();
|
|
98
|
+
const overlay = () => host.firstElementChild!;
|
|
99
|
+
|
|
100
|
+
pointTarget.current = shiftFirst ? element : null;
|
|
101
|
+
render(shiftFirst ? hover : null);
|
|
102
|
+
press(overlay(), shiftFirst);
|
|
103
|
+
expect(onCanvasMouseDown).not.toHaveBeenCalled();
|
|
104
|
+
|
|
105
|
+
pointTarget.current = element;
|
|
106
|
+
render(hover);
|
|
107
|
+
press(overlay());
|
|
108
|
+
expect(onCanvasMouseDown).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("a shift+click on a selected box that cannot move toggles it once", () => {
|
|
112
|
+
const { host, element, hover } = mount();
|
|
113
|
+
hover.capabilities.canApplyManualOffset = false;
|
|
114
|
+
rect.current = { left: 20, top: 30, width: 100, height: 40, editScaleX: 1, editScaleY: 1 };
|
|
115
|
+
pointTarget.current = element;
|
|
116
|
+
render(hover, hover);
|
|
117
|
+
|
|
118
|
+
press(host.querySelector('[data-dom-edit-selection-box="true"]')!, true);
|
|
119
|
+
|
|
120
|
+
expect(onSelectionChange).toHaveBeenCalledTimes(1);
|
|
121
|
+
expect(onCanvasMouseDown).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { act } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { FontFamilyField } from "./propertyPanelFont";
|
|
7
|
+
import { sortFontOptions } from "./propertyPanelHelpers";
|
|
8
|
+
|
|
9
|
+
vi.mock("./propertyPanelHelpers", async (importOriginal) => {
|
|
10
|
+
const actual = await importOriginal<typeof import("./propertyPanelHelpers")>();
|
|
11
|
+
return { ...actual, sortFontOptions: vi.fn(actual.sortFontOptions) };
|
|
12
|
+
});
|
|
7
13
|
|
|
8
14
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
15
|
|
|
16
|
+
// The font APIs are stubbed and every family is off the Google lists, so no test reaches the network.
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.stubGlobal(
|
|
19
|
+
"fetch",
|
|
20
|
+
vi.fn(async (url: string) => ({
|
|
21
|
+
ok: true,
|
|
22
|
+
json: async () => ({ fonts: url.includes("google") ? ["Roboto Slab"] : ["Arial"] }),
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
10
27
|
afterEach(() => {
|
|
11
28
|
document.body.innerHTML = "";
|
|
29
|
+
document.head.innerHTML = "";
|
|
30
|
+
vi.unstubAllGlobals();
|
|
12
31
|
});
|
|
13
32
|
|
|
14
33
|
describe("FontFamilyField flat trigger", () => {
|
|
@@ -17,14 +36,38 @@ describe("FontFamilyField flat trigger", () => {
|
|
|
17
36
|
document.body.append(host);
|
|
18
37
|
const root = createRoot(host);
|
|
19
38
|
act(() => {
|
|
20
|
-
root.render(
|
|
21
|
-
<FontFamilyField flat value="JetBrains Mono" importedFonts={[]} onCommit={vi.fn()} />,
|
|
22
|
-
);
|
|
39
|
+
root.render(<FontFamilyField flat value="Georgia" importedFonts={[]} onCommit={vi.fn()} />);
|
|
23
40
|
});
|
|
24
41
|
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
|
|
25
42
|
expect(trigger).not.toBeNull();
|
|
26
43
|
expect(trigger?.className).not.toContain("border-neutral-800");
|
|
27
|
-
expect(host.textContent).toContain("
|
|
44
|
+
expect(host.textContent).toContain("Georgia");
|
|
28
45
|
act(() => root.unmount());
|
|
29
46
|
});
|
|
30
47
|
});
|
|
48
|
+
|
|
49
|
+
describe("FontFamilyField font list", () => {
|
|
50
|
+
it("builds the list only while the dropdown is open", async () => {
|
|
51
|
+
vi.mocked(sortFontOptions).mockClear();
|
|
52
|
+
const host = document.createElement("div");
|
|
53
|
+
document.body.append(host);
|
|
54
|
+
const root = createRoot(host);
|
|
55
|
+
const render = (value: string) =>
|
|
56
|
+
root.render(<FontFamilyField flat value={value} importedFonts={[]} onCommit={vi.fn()} />);
|
|
57
|
+
try {
|
|
58
|
+
await act(async () => render("Arial"));
|
|
59
|
+
// The font lists arrive and the value changes while the dropdown is closed, as during a drag.
|
|
60
|
+
await act(async () => render("Georgia"));
|
|
61
|
+
expect(sortFontOptions).not.toHaveBeenCalled();
|
|
62
|
+
|
|
63
|
+
const trigger = host.querySelector<HTMLButtonElement>('[data-flat-font-trigger="true"]');
|
|
64
|
+
await act(async () => trigger?.click());
|
|
65
|
+
expect(sortFontOptions).toHaveBeenCalledTimes(1);
|
|
66
|
+
expect(host.textContent).toContain("Roboto Slab");
|
|
67
|
+
expect(document.head.querySelector('link[href*="fonts.googleapis.com"]')).toBeNull();
|
|
68
|
+
} finally {
|
|
69
|
+
act(() => root.unmount());
|
|
70
|
+
host.remove();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -276,6 +276,7 @@ export function FontFamilyField({
|
|
|
276
276
|
);
|
|
277
277
|
|
|
278
278
|
const options = useMemo(() => {
|
|
279
|
+
if (!open) return [];
|
|
279
280
|
const documentFonts = collectDocumentFontFamilies();
|
|
280
281
|
const googleSet = new Set(googleFonts.map((f) => f.toLowerCase()));
|
|
281
282
|
const taggedLocal = localFonts.map(
|
|
@@ -294,7 +295,7 @@ export function FontFamilyField({
|
|
|
294
295
|
...DEFAULT_FONT_FAMILIES.map((f): FontOption => ({ family: f, source: "System" })),
|
|
295
296
|
]),
|
|
296
297
|
);
|
|
297
|
-
}, [currentFamily, googleFonts, localFonts, projectFontAssets]);
|
|
298
|
+
}, [open, currentFamily, googleFonts, localFonts, projectFontAssets]);
|
|
298
299
|
|
|
299
300
|
const filteredOptions = useMemo(() => {
|
|
300
301
|
const matches = options.filter((o) => fontMatchesQuery(o.family, query));
|
|
@@ -193,7 +193,6 @@ describe("a shift press on a selected box starts the drag", () => {
|
|
|
193
193
|
selectionKey="box"
|
|
194
194
|
groupSelectionCount={0}
|
|
195
195
|
gestures={spies as never}
|
|
196
|
-
onBoxMouseDown={vi.fn()}
|
|
197
196
|
onBoxClick={vi.fn()}
|
|
198
197
|
/>
|
|
199
198
|
));
|
|
@@ -207,7 +206,6 @@ describe("a shift press on a selected box starts the drag", () => {
|
|
|
207
206
|
allowBodyDrag
|
|
208
207
|
groupCanMove={groupCanMove}
|
|
209
208
|
gestures={spies as never}
|
|
210
|
-
onBoxMouseDown={vi.fn()}
|
|
211
209
|
onBoxClick={vi.fn()}
|
|
212
210
|
/>
|
|
213
211
|
));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { Timeline } from "../../player";
|
|
3
3
|
import type { TimelineElement, TimelineTimeRange } from "../../player";
|
|
4
|
+
import type { TimelineProps } from "../../player/components/TimelineTypes";
|
|
4
5
|
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
|
|
5
6
|
import { AudioMeterStrip } from "./AudioMeterStrip";
|
|
6
7
|
import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
|
|
@@ -45,6 +46,7 @@ export interface TimelinePaneProps {
|
|
|
45
46
|
onPasteClip?: () => Promise<void>;
|
|
46
47
|
onDuplicateClip?: () => Promise<boolean>;
|
|
47
48
|
canPasteClip?: () => boolean;
|
|
49
|
+
clipMenuItems?: TimelineProps["clipMenuItems"];
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export function TimelinePane({
|
|
@@ -64,6 +66,7 @@ export function TimelinePane({
|
|
|
64
66
|
onPasteClip,
|
|
65
67
|
onDuplicateClip,
|
|
66
68
|
canPasteClip,
|
|
69
|
+
clipMenuItems,
|
|
67
70
|
}: TimelinePaneProps) {
|
|
68
71
|
const {
|
|
69
72
|
seek,
|
|
@@ -119,6 +122,7 @@ export function TimelinePane({
|
|
|
119
122
|
onPasteClip={onPasteClip}
|
|
120
123
|
onDuplicateClip={onDuplicateClip}
|
|
121
124
|
canPasteClip={canPasteClip}
|
|
125
|
+
clipMenuItems={clipMenuItems}
|
|
122
126
|
/>
|
|
123
127
|
</div>
|
|
124
128
|
<AudioMeterStrip />
|
|
@@ -5,10 +5,8 @@ import { dragEditOutcome, preflightGsapRotationIntercept } from "./gsapRuntimeBr
|
|
|
5
5
|
import { preflightGsapResizeIntercept } from "./gsapResizePreflight";
|
|
6
6
|
import { GSAP_EDIT_BLOCK_COPY, type GsapEditOutcome } from "./gsapEditOutcome";
|
|
7
7
|
import { fetchParsedAnimations, parseCacheKey } from "./keyframeCacheAstLoad";
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
selectElementAnimationsOrRetry,
|
|
11
|
-
} from "./useGsapAnimationFetchFallback";
|
|
8
|
+
import { getAnimationsForElement } from "./gsapElementMatch";
|
|
9
|
+
import { gsapSourceFileForSelection } from "./useGsapAnimationFetchFallback";
|
|
12
10
|
|
|
13
11
|
interface CommitPreflight {
|
|
14
12
|
offset: GsapEditOutcome;
|
|
@@ -23,13 +21,7 @@ function runCommitPreflights(
|
|
|
23
21
|
group: boolean,
|
|
24
22
|
): CommitPreflight {
|
|
25
23
|
const target = { id: selection.id ?? null, selector: selection.selector ?? null };
|
|
26
|
-
const
|
|
27
|
-
{ animations: fileAnimations },
|
|
28
|
-
target,
|
|
29
|
-
selection.element,
|
|
30
|
-
);
|
|
31
|
-
// A file the server parsed with no tweens at all is a definitive answer here.
|
|
32
|
-
const animations = matched.kind === "resolved" ? matched.animations : [];
|
|
24
|
+
const animations = getAnimationsForElement(fileAnimations, target, selection.element);
|
|
33
25
|
return {
|
|
34
26
|
offset: dragEditOutcome(selection, animations, iframe, [], group),
|
|
35
27
|
size: preflightGsapResizeIntercept(selection, animations, iframe),
|
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
6
|
import type { GsapAnimation, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
|
5
7
|
import {
|
|
6
8
|
gsapSourceFileForSelection,
|
|
7
9
|
selectElementAnimationsOrRetry,
|
|
10
|
+
useGsapAnimationFetchFallback,
|
|
8
11
|
} from "./useGsapAnimationFetchFallback";
|
|
12
|
+
import { fetchParsedAnimations } from "./useGsapTweenCache";
|
|
9
13
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
14
|
|
|
15
|
+
vi.mock("./useGsapTweenCache", async (importOriginal) => ({
|
|
16
|
+
...(await importOriginal<typeof import("./useGsapTweenCache")>()),
|
|
17
|
+
fetchParsedAnimations: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
21
|
+
|
|
11
22
|
describe("gsapSourceFileForSelection", () => {
|
|
12
23
|
it("uses the explicit target source instead of ambient selection state", () => {
|
|
13
24
|
expect(
|
|
@@ -28,8 +39,11 @@ describe("selectElementAnimationsOrRetry", () => {
|
|
|
28
39
|
expect(selectElementAnimationsOrRetry(null, target)).toEqual({ kind: "fetch-error" });
|
|
29
40
|
});
|
|
30
41
|
|
|
31
|
-
it("
|
|
32
|
-
expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({
|
|
42
|
+
it("resolves to [] for a file with no tweens at all: the endpoint parses the file per request", () => {
|
|
43
|
+
expect(selectElementAnimationsOrRetry(parsed([]), target)).toEqual({
|
|
44
|
+
kind: "resolved",
|
|
45
|
+
animations: [],
|
|
46
|
+
});
|
|
33
47
|
});
|
|
34
48
|
|
|
35
49
|
it("resolves the matching animations from a warm parse", () => {
|
|
@@ -58,3 +72,30 @@ describe("selectElementAnimationsOrRetry", () => {
|
|
|
58
72
|
expect(outcome).toEqual({ kind: "resolved", animations: [] });
|
|
59
73
|
});
|
|
60
74
|
});
|
|
75
|
+
|
|
76
|
+
describe("useGsapAnimationFetchFallback", () => {
|
|
77
|
+
const fetchFor = (projectId: string) => {
|
|
78
|
+
let fetcher: ReturnType<typeof useGsapAnimationFetchFallback> | null = null;
|
|
79
|
+
function Probe() {
|
|
80
|
+
fetcher = useGsapAnimationFetchFallback(projectId);
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const root = createRoot(document.createElement("div"));
|
|
84
|
+
act(() => root.render(React.createElement(Probe)));
|
|
85
|
+
act(() => root.unmount());
|
|
86
|
+
const element = document.createElement("div");
|
|
87
|
+
return fetcher!({ id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
it("answers a GSAP-free file with one parse request, no retries", async () => {
|
|
91
|
+
vi.mocked(fetchParsedAnimations).mockReset().mockResolvedValue(parsed([]));
|
|
92
|
+
await expect(fetchFor("demo")()).resolves.toEqual([]);
|
|
93
|
+
expect(fetchParsedAnimations).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("retries a failed fetch once, then reports no animation", async () => {
|
|
97
|
+
vi.mocked(fetchParsedAnimations).mockReset().mockResolvedValue(null);
|
|
98
|
+
await expect(fetchFor("demo")()).resolves.toEqual([]);
|
|
99
|
+
expect(fetchParsedAnimations).toHaveBeenCalledTimes(2);
|
|
100
|
+
});
|
|
101
|
+
});
|