@hyperframes/studio 0.8.48 → 0.8.50
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-CxxDxHTW.js +464 -0
- package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6209 -5924
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +20 -51
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +10 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.50",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -48,11 +48,11 @@
|
|
|
48
48
|
"gsap": "^3.13.0",
|
|
49
49
|
"marked": "^14.1.4",
|
|
50
50
|
"mediabunny": "^1.45.3",
|
|
51
|
-
"@hyperframes/
|
|
52
|
-
"@hyperframes/
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
55
|
-
"@hyperframes/
|
|
51
|
+
"@hyperframes/player": "0.8.50",
|
|
52
|
+
"@hyperframes/core": "0.8.50",
|
|
53
|
+
"@hyperframes/studio-server": "0.8.50",
|
|
54
|
+
"@hyperframes/sdk": "0.8.50",
|
|
55
|
+
"@hyperframes/parsers": "0.8.50"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/react": "19",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"vite": "^6.4.2",
|
|
70
70
|
"vitest": "^4.1.11",
|
|
71
71
|
"zustand": "^5.0.0",
|
|
72
|
-
"@hyperframes/producer": "0.8.
|
|
72
|
+
"@hyperframes/producer": "0.8.50"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
75
|
"react": "19",
|
package/src/App.tsx
CHANGED
|
@@ -211,7 +211,7 @@ export function StudioApp() {
|
|
|
211
211
|
const domEditDeleteBridge: DomEditDelete = (s, o) => handleDomEditElementDeleteRef.current(s, o);
|
|
212
212
|
const resetKeyframesRef = useRef<() => boolean>(() => false);
|
|
213
213
|
const deleteSelectedKeyframesRef = useRef<() => void>(() => {});
|
|
214
|
-
const { handleCopy, handlePaste, handleCut } = useClipboard({
|
|
214
|
+
const { handleCopy, handlePaste, handleCut, handleDuplicate, canPaste } = useClipboard({
|
|
215
215
|
projectId,
|
|
216
216
|
activeCompPath,
|
|
217
217
|
domEditSelectionRef: domEditSelectionBridgeRef,
|
|
@@ -219,7 +219,7 @@ export function StudioApp() {
|
|
|
219
219
|
writeProjectFile: fileManager.writeProjectFile,
|
|
220
220
|
recordEdit: editHistory.recordEdit,
|
|
221
221
|
reloadPreview,
|
|
222
|
-
|
|
222
|
+
handleTimelineElementsDelete: timelineEditing.handleTimelineElementsDelete,
|
|
223
223
|
handleDomEditElementDelete: domEditDeleteBridge,
|
|
224
224
|
previewIframeRef,
|
|
225
225
|
});
|
|
@@ -240,6 +240,7 @@ export function StudioApp() {
|
|
|
240
240
|
handleCopy,
|
|
241
241
|
handlePaste,
|
|
242
242
|
handleCut,
|
|
243
|
+
handleDuplicate,
|
|
243
244
|
onResetKeyframes: () => resetKeyframesRef.current(),
|
|
244
245
|
onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(),
|
|
245
246
|
onAfterUndoRedo: () => invalidateGsapCacheRef.current(),
|
|
@@ -341,7 +342,7 @@ export function StudioApp() {
|
|
|
341
342
|
setConsoleErrors,
|
|
342
343
|
resetErrors: resetConsoleErrors,
|
|
343
344
|
} = useConsoleErrorCapture(previewIframe);
|
|
344
|
-
const
|
|
345
|
+
const fileDrop = useGlobalFileDrop(timelineEditing.handleTimelineFileDrop);
|
|
345
346
|
const handleToggleRecordingRef = useRef<() => void>(() => {});
|
|
346
347
|
const domEditSessionRef = useRef(domEditSession);
|
|
347
348
|
domEditSessionRef.current = domEditSession;
|
|
@@ -446,10 +447,8 @@ export function StudioApp() {
|
|
|
446
447
|
<DomEditProvider value={domEditSession}>
|
|
447
448
|
<div
|
|
448
449
|
className="flex flex-col h-full w-full bg-neutral-950 relative"
|
|
449
|
-
onDragOver={
|
|
450
|
-
|
|
451
|
-
onDragLeave={dragOverlay.onDragLeave}
|
|
452
|
-
onDrop={dragOverlay.onDrop}
|
|
450
|
+
onDragOver={fileDrop.onDragOver}
|
|
451
|
+
onDrop={fileDrop.onDrop}
|
|
453
452
|
>
|
|
454
453
|
<StudioHeader
|
|
455
454
|
captureFrameHref={frameCapture.captureFrameHref}
|
|
@@ -538,6 +537,10 @@ export function StudioApp() {
|
|
|
538
537
|
handleTimelineElementSplit={timelineEditing.handleTimelineElementSplit}
|
|
539
538
|
handleRazorSplit={timelineEditing.handleRazorSplit}
|
|
540
539
|
handleRazorSplitAll={timelineEditing.handleRazorSplitAll}
|
|
540
|
+
onCopyClip={handleCopy}
|
|
541
|
+
onPasteClip={handlePaste}
|
|
542
|
+
onDuplicateClip={handleDuplicate}
|
|
543
|
+
canPasteClip={canPaste}
|
|
541
544
|
setCompIdToSrc={setCompIdToSrc}
|
|
542
545
|
setCompositionLoading={setCompositionLoading}
|
|
543
546
|
shouldShowMotionPath={shouldShowMotionPath}
|
|
@@ -568,7 +571,6 @@ export function StudioApp() {
|
|
|
568
571
|
clearConsoleErrors={() => setConsoleErrors(null)}
|
|
569
572
|
domEditSession={domEditSession}
|
|
570
573
|
activeCompPath={activeCompPath}
|
|
571
|
-
dragOverlayActive={dragOverlay.active}
|
|
572
574
|
toasts={toasts}
|
|
573
575
|
dismissToast={dismissToast}
|
|
574
576
|
/>
|
|
@@ -86,6 +86,10 @@ describe("EditorShell timeline selection sync", () => {
|
|
|
86
86
|
handleTimelineElementSplit={vi.fn()}
|
|
87
87
|
handleRazorSplit={vi.fn()}
|
|
88
88
|
handleRazorSplitAll={vi.fn()}
|
|
89
|
+
onCopyClip={vi.fn(() => false)}
|
|
90
|
+
onPasteClip={vi.fn(async () => {})}
|
|
91
|
+
onDuplicateClip={vi.fn(async () => false)}
|
|
92
|
+
canPasteClip={vi.fn(() => false)}
|
|
89
93
|
setCompIdToSrc={vi.fn()}
|
|
90
94
|
setCompositionLoading={vi.fn()}
|
|
91
95
|
shouldShowMotionPath={false}
|
|
@@ -55,6 +55,10 @@ export interface EditorShellProps extends TimelineEditCallbackDeps {
|
|
|
55
55
|
files: File[],
|
|
56
56
|
placement?: TimelineDropPlacement,
|
|
57
57
|
) => Promise<void> | void;
|
|
58
|
+
onCopyClip: () => boolean;
|
|
59
|
+
onPasteClip: () => Promise<void>;
|
|
60
|
+
onDuplicateClip: () => Promise<boolean>;
|
|
61
|
+
canPasteClip: () => boolean;
|
|
58
62
|
setCompIdToSrc: (map: Map<string, string>) => void;
|
|
59
63
|
setCompositionLoading: (loading: boolean) => void;
|
|
60
64
|
shouldShowMotionPath: boolean;
|
|
@@ -93,6 +97,10 @@ export function EditorShell({
|
|
|
93
97
|
handleTimelineElementSplit,
|
|
94
98
|
handleRazorSplit,
|
|
95
99
|
handleRazorSplitAll,
|
|
100
|
+
onCopyClip,
|
|
101
|
+
onPasteClip,
|
|
102
|
+
onDuplicateClip,
|
|
103
|
+
canPasteClip,
|
|
96
104
|
setCompIdToSrc,
|
|
97
105
|
setCompositionLoading,
|
|
98
106
|
shouldShowMotionPath,
|
|
@@ -181,6 +189,10 @@ export function EditorShell({
|
|
|
181
189
|
onBlockDrop={handleTimelineBlockDrop}
|
|
182
190
|
onCompositionDrop={handleTimelineCompositionDrop}
|
|
183
191
|
onDeleteElement={handleTimelineElementDelete}
|
|
192
|
+
onCopyClip={onCopyClip}
|
|
193
|
+
onPasteClip={onPasteClip}
|
|
194
|
+
onDuplicateClip={onDuplicateClip}
|
|
195
|
+
canPasteClip={canPasteClip}
|
|
184
196
|
previewOverlay={
|
|
185
197
|
<PreviewOverlays
|
|
186
198
|
shouldShowMotionPath={shouldShowMotionPath}
|
|
@@ -219,6 +231,10 @@ interface EditorShellBodyProps {
|
|
|
219
231
|
placement: TimelineDropPlacement,
|
|
220
232
|
) => Promise<void> | void;
|
|
221
233
|
onDeleteElement: (element: TimelineElement) => Promise<void> | void;
|
|
234
|
+
onCopyClip: () => boolean;
|
|
235
|
+
onPasteClip: () => Promise<void>;
|
|
236
|
+
onDuplicateClip: () => Promise<boolean>;
|
|
237
|
+
canPasteClip: () => boolean;
|
|
222
238
|
}
|
|
223
239
|
|
|
224
240
|
function EditorShellBody({
|
|
@@ -235,6 +251,10 @@ function EditorShellBody({
|
|
|
235
251
|
onBlockDrop,
|
|
236
252
|
onCompositionDrop,
|
|
237
253
|
onDeleteElement,
|
|
254
|
+
onCopyClip,
|
|
255
|
+
onPasteClip,
|
|
256
|
+
onDuplicateClip,
|
|
257
|
+
canPasteClip,
|
|
238
258
|
}: EditorShellBodyProps) {
|
|
239
259
|
const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
|
|
240
260
|
|
|
@@ -289,6 +309,10 @@ function EditorShellBody({
|
|
|
289
309
|
onBlockDrop={onBlockDrop}
|
|
290
310
|
onCompositionDrop={onCompositionDrop}
|
|
291
311
|
onDeleteElement={onDeleteElement}
|
|
312
|
+
onCopyClip={onCopyClip}
|
|
313
|
+
onPasteClip={onPasteClip}
|
|
314
|
+
onDuplicateClip={onDuplicateClip}
|
|
315
|
+
canPasteClip={canPasteClip}
|
|
292
316
|
onSelectTimelineElement={onSelectTimelineElement}
|
|
293
317
|
timelineFooter={
|
|
294
318
|
captionEditMode ? (
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { ComponentProps } from "react";
|
|
2
2
|
import { LintModal } from "./LintModal";
|
|
3
3
|
import { AskAgentModal } from "./AskAgentModal";
|
|
4
|
-
import { StudioGlobalDragOverlay } from "./StudioGlobalDragOverlay";
|
|
5
4
|
import { StudioToast } from "./StudioToast";
|
|
6
5
|
import { StudioFeedbackCard } from "./feedback/StudioFeedbackCard";
|
|
7
6
|
import { buildAgentContextPreview } from "./editor/domEditingAgentPrompt";
|
|
@@ -19,14 +18,13 @@ export interface StudioOverlaysProps {
|
|
|
19
18
|
clearConsoleErrors: () => void;
|
|
20
19
|
domEditSession: ReturnType<typeof useDomEditSession>;
|
|
21
20
|
activeCompPath: string | null;
|
|
22
|
-
dragOverlayActive: boolean;
|
|
23
21
|
toasts: ReturnType<typeof useToast>["toasts"];
|
|
24
22
|
dismissToast: (id: number) => void;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
/**
|
|
28
26
|
* Floating overlays for the studio shell: lint / console-error modals, the
|
|
29
|
-
* ask-agent modal,
|
|
27
|
+
* ask-agent modal, and the toast. Extracted from
|
|
30
28
|
* `App.tsx` to keep the shell within the studio's 600-line decomposition budget.
|
|
31
29
|
*/
|
|
32
30
|
// fallow-ignore-next-line complexity
|
|
@@ -39,7 +37,6 @@ export function StudioOverlays({
|
|
|
39
37
|
clearConsoleErrors,
|
|
40
38
|
domEditSession,
|
|
41
39
|
activeCompPath,
|
|
42
|
-
dragOverlayActive,
|
|
43
40
|
toasts,
|
|
44
41
|
dismissToast,
|
|
45
42
|
}: StudioOverlaysProps) {
|
|
@@ -78,7 +75,6 @@ export function StudioOverlays({
|
|
|
78
75
|
}}
|
|
79
76
|
/>
|
|
80
77
|
)}
|
|
81
|
-
{dragOverlayActive && <StudioGlobalDragOverlay />}
|
|
82
78
|
{/* One bottom-right stack so the feedback card and toasts queue instead
|
|
83
79
|
of covering each other. Empty when nothing is showing. */}
|
|
84
80
|
<div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { PreviewGuides } from "./PreviewGuides";
|
|
7
|
+
import { usePreviewGuidesStore } from "./previewGuidesStore";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
const compRect = vi.hoisted(() => ({
|
|
12
|
+
current: { left: 20, top: 20, width: 400, height: 225, scaleX: 1, scaleY: 1 },
|
|
13
|
+
}));
|
|
14
|
+
vi.mock("./useDomEditCompositionRect", () => ({
|
|
15
|
+
useDomEditCompositionRect: () => compRect.current,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
compRect.current = { left: 20, top: 20, width: 400, height: 225, scaleX: 1, scaleY: 1 };
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
window.localStorage.clear();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function render(rulerVisible: boolean, safeMarginsVisible: boolean) {
|
|
25
|
+
usePreviewGuidesStore.setState({ rulerVisible, safeMarginsVisible });
|
|
26
|
+
const host = document.createElement("div");
|
|
27
|
+
document.body.append(host);
|
|
28
|
+
act(() => {
|
|
29
|
+
createRoot(host).render(<PreviewGuides iframeRef={{ current: null }} />);
|
|
30
|
+
});
|
|
31
|
+
return host;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("PreviewGuides", () => {
|
|
35
|
+
it("draws nothing when both toggles are off", () => {
|
|
36
|
+
const host = render(false, false);
|
|
37
|
+
expect(host.querySelector("[data-testid]")).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("draws the ruler with ticks 0 to 100 every 10 on both edges", () => {
|
|
41
|
+
const host = render(true, false);
|
|
42
|
+
const top = host.querySelector('[data-testid="preview-ruler-top"]');
|
|
43
|
+
expect(Array.from(top?.children ?? []).map((t) => t.textContent)).toEqual(
|
|
44
|
+
Array.from({ length: 11 }, (_, i) => String(i * 10)),
|
|
45
|
+
);
|
|
46
|
+
expect(host.querySelector('[data-testid="preview-ruler-left"]')).not.toBeNull();
|
|
47
|
+
expect(host.querySelector('[data-testid="preview-safe-90"]')).toBeNull();
|
|
48
|
+
expect(host.querySelector('[data-testid="preview-safe-80"]')).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("draws the 90% and 80% boxes with edge-midpoint ticks and no labels or caption band", () => {
|
|
52
|
+
const host = render(false, true);
|
|
53
|
+
const box = (pct: number) =>
|
|
54
|
+
host.querySelector<HTMLElement>(`[data-testid="preview-safe-${pct}"]`);
|
|
55
|
+
expect([box(90)?.style.left, box(90)?.style.bottom]).toEqual(["5%", "5%"]);
|
|
56
|
+
expect([box(80)?.style.top, box(80)?.style.right]).toEqual(["10%", "10%"]);
|
|
57
|
+
expect(box(90)?.children.length).toBe(4);
|
|
58
|
+
expect(box(80)?.children.length).toBe(4);
|
|
59
|
+
expect(host.textContent).toBe("");
|
|
60
|
+
expect(host.querySelector('[data-testid="preview-ruler-top"]')).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("places the ruler in the gutter outside the frame", () => {
|
|
64
|
+
const host = render(true, false);
|
|
65
|
+
const top = host.querySelector<HTMLElement>('[data-testid="preview-ruler-top"]');
|
|
66
|
+
const left = host.querySelector<HTMLElement>('[data-testid="preview-ruler-left"]');
|
|
67
|
+
expect([top?.style.left, top?.style.top, top?.style.width]).toEqual(["20px", "4px", "400px"]);
|
|
68
|
+
expect([left?.style.left, left?.style.top, left?.style.height]).toEqual([
|
|
69
|
+
"4px",
|
|
70
|
+
"20px",
|
|
71
|
+
"225px",
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("draws nothing until the composition has a size", () => {
|
|
76
|
+
compRect.current = { left: 0, top: 0, width: 0, height: 0, scaleX: 1, scaleY: 1 };
|
|
77
|
+
const host = render(true, true);
|
|
78
|
+
expect(host.querySelector("[data-testid]")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("draws the same boxes on a portrait composition", () => {
|
|
82
|
+
compRect.current = { left: 20, top: 20, width: 225, height: 400, scaleX: 1, scaleY: 1 };
|
|
83
|
+
const host = render(false, true);
|
|
84
|
+
const box = host.querySelector<HTMLElement>('[data-testid="preview-safe-90"]');
|
|
85
|
+
expect([box?.style.left, box?.style.top]).toEqual(["5%", "5%"]);
|
|
86
|
+
expect(host.querySelector('[data-testid="preview-safe-80"]')).not.toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { memo, useRef, type RefObject } from "react";
|
|
2
|
+
import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
|
|
3
|
+
import { RULER_GUTTER_PX, usePreviewGuidesStore } from "./previewGuidesStore";
|
|
4
|
+
import { SAFE_BOX_PERCENTS, safeBoxInsetPercent } from "../../utils/previewSafeMargins";
|
|
5
|
+
|
|
6
|
+
interface PreviewGuidesProps {
|
|
7
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const TICKS = Array.from({ length: 11 }, (_, i) => i * 10);
|
|
11
|
+
const INK = "rgba(255,255,255,0.7)";
|
|
12
|
+
|
|
13
|
+
/** Ruler and safe-margin boxes drawn over the preview pane, never inside the composition. */
|
|
14
|
+
export const PreviewGuides = memo(function PreviewGuides({ iframeRef }: PreviewGuidesProps) {
|
|
15
|
+
const rulerVisible = usePreviewGuidesStore((s) => s.rulerVisible);
|
|
16
|
+
const safeMarginsVisible = usePreviewGuidesStore((s) => s.safeMarginsVisible);
|
|
17
|
+
if (!rulerVisible && !safeMarginsVisible) return null;
|
|
18
|
+
return (
|
|
19
|
+
<ActiveGuides
|
|
20
|
+
iframeRef={iframeRef}
|
|
21
|
+
rulerVisible={rulerVisible}
|
|
22
|
+
safeMarginsVisible={safeMarginsVisible}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
interface ActiveGuidesProps extends PreviewGuidesProps {
|
|
28
|
+
rulerVisible: boolean;
|
|
29
|
+
safeMarginsVisible: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Owns the composition-rect subscription, so it only runs while a guide is on. */
|
|
33
|
+
function ActiveGuides({ iframeRef, rulerVisible, safeMarginsVisible }: ActiveGuidesProps) {
|
|
34
|
+
const paneRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const rect = useDomEditCompositionRect({ iframeRef, overlayRef: paneRef });
|
|
36
|
+
const ready = rect.width > 0 && rect.height > 0;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div ref={paneRef} aria-hidden="true" className="pointer-events-none absolute inset-0 z-20">
|
|
40
|
+
{ready && rulerVisible && (
|
|
41
|
+
<>
|
|
42
|
+
<div
|
|
43
|
+
data-testid="preview-ruler-top"
|
|
44
|
+
className="absolute bg-black/70"
|
|
45
|
+
style={{
|
|
46
|
+
left: rect.left,
|
|
47
|
+
top: rect.top - RULER_GUTTER_PX,
|
|
48
|
+
width: rect.width,
|
|
49
|
+
height: RULER_GUTTER_PX,
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
{TICKS.map((pct) => (
|
|
53
|
+
<span
|
|
54
|
+
key={pct}
|
|
55
|
+
className="absolute bottom-0 border-l text-[9px] leading-none tabular-nums pl-0.5 h-2.5"
|
|
56
|
+
style={{ left: `${pct}%`, borderColor: INK, color: INK }}
|
|
57
|
+
>
|
|
58
|
+
{pct}
|
|
59
|
+
</span>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
<div
|
|
63
|
+
data-testid="preview-ruler-left"
|
|
64
|
+
className="absolute bg-black/70"
|
|
65
|
+
style={{
|
|
66
|
+
left: rect.left - RULER_GUTTER_PX,
|
|
67
|
+
top: rect.top,
|
|
68
|
+
width: RULER_GUTTER_PX,
|
|
69
|
+
height: rect.height,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{TICKS.map((pct) => (
|
|
73
|
+
<span
|
|
74
|
+
key={pct}
|
|
75
|
+
className="absolute right-0 border-t text-[9px] leading-none tabular-nums pt-0.5 w-2.5"
|
|
76
|
+
style={{ top: `${pct}%`, borderColor: INK, color: INK }}
|
|
77
|
+
>
|
|
78
|
+
{pct}
|
|
79
|
+
</span>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</>
|
|
83
|
+
)}
|
|
84
|
+
{ready && safeMarginsVisible && (
|
|
85
|
+
<div
|
|
86
|
+
className="absolute"
|
|
87
|
+
style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
|
|
88
|
+
>
|
|
89
|
+
{SAFE_BOX_PERCENTS.map((boxPercent) => (
|
|
90
|
+
<SafeBox key={boxPercent} boxPercent={boxPercent} />
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const TICK_PX = 7;
|
|
99
|
+
const EDGE_MIDPOINTS = [
|
|
100
|
+
{ edge: "top", style: { left: "50%", top: -(TICK_PX + 1) / 2, width: 1, height: TICK_PX } },
|
|
101
|
+
{ edge: "bottom", style: { left: "50%", bottom: -(TICK_PX + 1) / 2, width: 1, height: TICK_PX } },
|
|
102
|
+
{ edge: "left", style: { top: "50%", left: -(TICK_PX + 1) / 2, width: TICK_PX, height: 1 } },
|
|
103
|
+
{ edge: "right", style: { top: "50%", right: -(TICK_PX + 1) / 2, width: TICK_PX, height: 1 } },
|
|
104
|
+
] as const;
|
|
105
|
+
|
|
106
|
+
/** One thin white box inset from every edge, with a tick at the midpoint of each edge. */
|
|
107
|
+
function SafeBox({ boxPercent }: { boxPercent: number }) {
|
|
108
|
+
const inset = `${safeBoxInsetPercent(boxPercent)}%`;
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
data-testid={`preview-safe-${boxPercent}`}
|
|
112
|
+
className="absolute border border-white/90"
|
|
113
|
+
style={{
|
|
114
|
+
left: inset,
|
|
115
|
+
top: inset,
|
|
116
|
+
right: inset,
|
|
117
|
+
bottom: inset,
|
|
118
|
+
}}
|
|
119
|
+
>
|
|
120
|
+
{EDGE_MIDPOINTS.map(({ edge, style }) => (
|
|
121
|
+
<span key={edge} className="absolute bg-white/90" style={style} />
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -8,6 +8,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
|
|
|
8
8
|
import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
|
|
9
9
|
import type { DomEditSelection } from "./domEditing";
|
|
10
10
|
import { SnapToolbar } from "./SnapToolbar";
|
|
11
|
+
import { usePreviewGuidesStore } from "./previewGuidesStore";
|
|
11
12
|
|
|
12
13
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
14
|
|
|
@@ -53,6 +54,7 @@ function AppHotkeyHarness() {
|
|
|
53
54
|
handleCopy: vi.fn(() => false),
|
|
54
55
|
handlePaste: vi.fn(async () => undefined),
|
|
55
56
|
handleCut: vi.fn(async () => false),
|
|
57
|
+
handleDuplicate: vi.fn(async () => false),
|
|
56
58
|
onResetKeyframes: vi.fn(() => false),
|
|
57
59
|
onDeleteSelectedKeyframes: vi.fn(),
|
|
58
60
|
});
|
|
@@ -119,3 +121,22 @@ describe("SnapToolbar keyboard shortcuts", () => {
|
|
|
119
121
|
act(() => root.unmount());
|
|
120
122
|
});
|
|
121
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("SnapToolbar ruler and safe-margin toggles", () => {
|
|
126
|
+
it.each([
|
|
127
|
+
["Toggle ruler", "rulerVisible"],
|
|
128
|
+
["Toggle safe margins", "safeMarginsVisible"],
|
|
129
|
+
])("%s flips %s and remembers it", (label, key) => {
|
|
130
|
+
usePreviewGuidesStore.setState({ rulerVisible: false, safeMarginsVisible: false });
|
|
131
|
+
const { root } = renderToolbar();
|
|
132
|
+
const button = () => document.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`);
|
|
133
|
+
expect(button()?.getAttribute("aria-pressed")).toBe("false");
|
|
134
|
+
|
|
135
|
+
act(() => button()?.click());
|
|
136
|
+
|
|
137
|
+
expect(button()?.getAttribute("aria-pressed")).toBe("true");
|
|
138
|
+
const stored = window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}";
|
|
139
|
+
expect(JSON.parse(stored)[key]).toBe(true);
|
|
140
|
+
act(() => root.unmount());
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react";
|
|
2
|
+
import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
|
|
3
3
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
4
4
|
import { usePlayerStore } from "../../player/store/playerStore";
|
|
5
|
+
import { usePreviewGuidesStore } from "./previewGuidesStore";
|
|
5
6
|
|
|
6
7
|
const SNAP_DEFAULTS = {
|
|
7
8
|
snapEnabled: true,
|
|
@@ -39,6 +40,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
39
40
|
const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
|
|
40
41
|
const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
|
|
41
42
|
const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
|
|
43
|
+
const guides = usePreviewGuidesStore();
|
|
42
44
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
43
45
|
const gridButtonRef = useRef<HTMLButtonElement>(null);
|
|
44
46
|
|
|
@@ -116,6 +118,28 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
|
|
|
116
118
|
<Path size={16} weight={motionPathArmed ? "fill" : "regular"} />
|
|
117
119
|
</button>
|
|
118
120
|
)}
|
|
121
|
+
{(
|
|
122
|
+
[
|
|
123
|
+
["rulerVisible", "Ruler", Ruler],
|
|
124
|
+
["safeMarginsVisible", "Safe margins", FrameCorners],
|
|
125
|
+
] as const
|
|
126
|
+
).map(([key, label, Icon]) => (
|
|
127
|
+
<button
|
|
128
|
+
key={key}
|
|
129
|
+
type="button"
|
|
130
|
+
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
|
131
|
+
guides[key]
|
|
132
|
+
? "bg-studio-accent/20 text-studio-accent"
|
|
133
|
+
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
|
|
134
|
+
}`}
|
|
135
|
+
onClick={() => guides.toggle(key)}
|
|
136
|
+
title={`${label} ${guides[key] ? "on" : "off"}`}
|
|
137
|
+
aria-label={`Toggle ${label.toLowerCase()}`}
|
|
138
|
+
aria-pressed={guides[key]}
|
|
139
|
+
>
|
|
140
|
+
<Icon size={16} weight={guides[key] ? "fill" : "regular"} />
|
|
141
|
+
</button>
|
|
142
|
+
))}
|
|
119
143
|
<button
|
|
120
144
|
type="button"
|
|
121
145
|
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
|
|
@@ -96,6 +96,7 @@ export function buildElementAgentPrompt({
|
|
|
96
96
|
selectionContext,
|
|
97
97
|
userInstruction,
|
|
98
98
|
sourceFilePath,
|
|
99
|
+
timeline,
|
|
99
100
|
}: {
|
|
100
101
|
selection: DomEditSelection;
|
|
101
102
|
currentTime: number;
|
|
@@ -103,6 +104,8 @@ export function buildElementAgentPrompt({
|
|
|
103
104
|
selectionContext?: string;
|
|
104
105
|
userInstruction?: string;
|
|
105
106
|
sourceFilePath?: string;
|
|
107
|
+
/** The `formatTimelineBlock` text; omitted or empty when the timeline has no clips. */
|
|
108
|
+
timeline?: string;
|
|
106
109
|
}): string {
|
|
107
110
|
const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
|
|
108
111
|
const info: AgentPromptElementInfo = {
|
|
@@ -136,6 +139,8 @@ export function buildElementAgentPrompt({
|
|
|
136
139
|
|
|
137
140
|
lines.push(...buildElementDetailLines(info));
|
|
138
141
|
|
|
142
|
+
if (timeline) lines.push("", timeline);
|
|
143
|
+
|
|
139
144
|
if (tagSnippet) {
|
|
140
145
|
lines.push("", "Target HTML:", tagSnippet);
|
|
141
146
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
3
|
+
|
|
4
|
+
/** Space the preview fit reserves above and left of the frame while the ruler is on. */
|
|
5
|
+
export const RULER_GUTTER_PX = 16;
|
|
6
|
+
|
|
7
|
+
type GuideKey = "rulerVisible" | "safeMarginsVisible";
|
|
8
|
+
|
|
9
|
+
interface PreviewGuidesState {
|
|
10
|
+
rulerVisible: boolean;
|
|
11
|
+
safeMarginsVisible: boolean;
|
|
12
|
+
toggle: (key: GuideKey) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const usePreviewGuidesStore = create<PreviewGuidesState>((set, get) => {
|
|
16
|
+
const stored = readStudioUiPreferences();
|
|
17
|
+
return {
|
|
18
|
+
rulerVisible: stored.rulerVisible ?? false,
|
|
19
|
+
safeMarginsVisible: stored.safeMarginsVisible ?? false,
|
|
20
|
+
toggle: (key) => {
|
|
21
|
+
const next = !get()[key];
|
|
22
|
+
writeStudioUiPreferences({ [key]: next });
|
|
23
|
+
set({ [key]: next });
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
});
|
|
@@ -134,6 +134,19 @@ describe("getPreviewPlayerKey", () => {
|
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
describe("resolvePreviewStageSize", () => {
|
|
137
|
+
it("reserves the ruler gutter on both sides of both axes", () => {
|
|
138
|
+
const wide = { width: 1920, height: 1080 };
|
|
139
|
+
const tall = { width: 1080, height: 1920 };
|
|
140
|
+
expect(resolvePreviewStageSize(512, 402, wide, undefined, 16)).toEqual({
|
|
141
|
+
width: 464,
|
|
142
|
+
height: 261,
|
|
143
|
+
});
|
|
144
|
+
expect(resolvePreviewStageSize(512, 402, tall, undefined, 16)).toEqual({
|
|
145
|
+
width: 199.125,
|
|
146
|
+
height: 354,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
137
150
|
it("fits portrait composition dimensions by height in a narrow viewport", () => {
|
|
138
151
|
expect(resolvePreviewStageSize(512, 402, { width: 1080, height: 1920 }, undefined)).toEqual({
|
|
139
152
|
width: 217.125,
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
toDomPrecision,
|
|
12
12
|
type PreviewZoomState,
|
|
13
13
|
} from "./previewZoom";
|
|
14
|
+
import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
|
|
14
15
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
15
16
|
interface NLEPreviewProps {
|
|
16
17
|
projectId: string;
|
|
@@ -89,9 +90,10 @@ export function resolvePreviewStageSize(
|
|
|
89
90
|
viewportHeight: number,
|
|
90
91
|
compositionSize: PreviewCompositionSize | null,
|
|
91
92
|
portrait: boolean | undefined,
|
|
93
|
+
gutterPx = 0,
|
|
92
94
|
): { width: number; height: number } {
|
|
93
|
-
const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX);
|
|
94
|
-
const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX);
|
|
95
|
+
const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX - 2 * gutterPx);
|
|
96
|
+
const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX - 2 * gutterPx);
|
|
95
97
|
const aspectRatio =
|
|
96
98
|
compositionSize && compositionSize.width > 0 && compositionSize.height > 0
|
|
97
99
|
? compositionSize.width / compositionSize.height
|
|
@@ -135,6 +137,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
135
137
|
onStageRef?.(stageRef);
|
|
136
138
|
}, [onStageRef]);
|
|
137
139
|
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
|
|
140
|
+
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
|
|
138
141
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
139
142
|
|
|
140
143
|
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
|
|
@@ -164,14 +167,16 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
164
167
|
|
|
165
168
|
const updateStageSize = () => {
|
|
166
169
|
const rect = viewport.getBoundingClientRect();
|
|
167
|
-
setStageSize(
|
|
170
|
+
setStageSize(
|
|
171
|
+
resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait, gutterPx),
|
|
172
|
+
);
|
|
168
173
|
};
|
|
169
174
|
|
|
170
175
|
updateStageSize();
|
|
171
176
|
const observer = new ResizeObserver(updateStageSize);
|
|
172
177
|
observer.observe(viewport);
|
|
173
178
|
return () => observer.disconnect();
|
|
174
|
-
}, [compositionSize, portrait]);
|
|
179
|
+
}, [compositionSize, portrait, gutterPx]);
|
|
175
180
|
|
|
176
181
|
const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
|
|
177
182
|
onCompositionSizeChangeRef.current = onCompositionSizeChange;
|