@hyperframes/studio 0.7.37 → 0.7.39
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-BBrKzTGd.js +459 -0
- package/dist/assets/index-0P10SwC_.css +1 -0
- package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
- package/dist/assets/index-B9YvRJz1.js +396 -0
- package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
- package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
- package/dist/chunk-RCLGSZ7C.js.map +1 -0
- package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
- package/dist/index.d.ts +48 -10
- package/dist/index.html +2 -2
- package/dist/index.js +6045 -3507
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +26 -24
- package/src/components/AskAgentModal.tsx +23 -6
- package/src/components/LintModal.tsx +40 -10
- package/src/components/MediaPreview.tsx +36 -1
- package/src/components/SaveQueuePausedBanner.tsx +7 -6
- package/src/components/StudioErrorBoundary.tsx +19 -31
- package/src/components/StudioFeedbackBar.tsx +11 -2
- package/src/components/StudioGlobalDragOverlay.tsx +1 -1
- package/src/components/StudioHeader.tsx +184 -106
- package/src/components/StudioLeftSidebar.tsx +24 -3
- package/src/components/StudioOverlays.tsx +34 -8
- package/src/components/StudioPreviewArea.tsx +8 -0
- package/src/components/StudioRightPanel.tsx +160 -9
- package/src/components/StudioSplash.tsx +6 -3
- package/src/components/StudioToast.tsx +12 -11
- package/src/components/TimelineToolbar.test.tsx +9 -3
- package/src/components/TimelineToolbar.tsx +63 -29
- package/src/components/editor/DomEditCropHandles.tsx +238 -0
- package/src/components/editor/DomEditOverlay.tsx +111 -108
- package/src/components/editor/DomEditRotateHandle.tsx +41 -0
- package/src/components/editor/PropertyPanel.test.ts +48 -0
- package/src/components/editor/PropertyPanel.tsx +27 -47
- package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
- package/src/components/editor/SnapToolbar.tsx +20 -1
- package/src/components/editor/clipPathHelpers.ts +113 -0
- package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -0
- package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
- package/src/components/editor/domEditOverlayCrop.ts +115 -0
- package/src/components/editor/domEditOverlayGeometry.ts +14 -0
- package/src/components/editor/domEditOverlayShape.ts +39 -0
- package/src/components/editor/domEditing.test.ts +2 -0
- package/src/components/editor/domEditingTypes.ts +3 -0
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- package/src/components/editor/marqueeCommit.ts +3 -2
- package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
- package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
- package/src/components/editor/propertyPanelHelpers.ts +32 -125
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
- package/src/components/editor/propertyPanelTypes.ts +113 -0
- package/src/components/editor/snapTargetCollection.ts +2 -3
- package/src/components/editor/useDomEditCompositionRect.ts +68 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
- package/src/components/editor/useDomEditOverlayRects.ts +5 -3
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
- package/src/components/nle/NLELayout.tsx +54 -51
- package/src/components/nle/NLEPreview.tsx +41 -5
- package/src/components/nle/TimelineResizeDivider.tsx +97 -0
- package/src/components/nle/usePreviewBlockDrop.ts +31 -7
- package/src/components/renders/RenderQueue.tsx +125 -24
- package/src/components/renders/RenderQueueItem.tsx +132 -64
- package/src/components/renders/useRenderQueue.ts +136 -24
- package/src/components/sidebar/AssetsTab.test.ts +87 -0
- package/src/components/sidebar/AssetsTab.tsx +113 -15
- package/src/components/sidebar/BlocksTab.tsx +2 -1
- package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
- package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
- package/src/components/storyboard/StoryboardView.tsx +6 -0
- package/src/components/studioColorGradingScope.ts +124 -0
- package/src/components/studioMediaJobs.ts +123 -0
- package/src/components/ui/Button.tsx +20 -11
- package/src/components/ui/HyperframesLoader.tsx +9 -2
- package/src/components/ui/SearchInput.tsx +53 -0
- package/src/components/ui/Tooltip.tsx +52 -6
- package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
- package/src/components/ui/useDialogBehavior.ts +83 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/StudioContext.tsx +7 -0
- package/src/hooks/domSelectionTestHarness.ts +1 -0
- package/src/hooks/useCompositionContentLoader.ts +40 -0
- package/src/hooks/useCropMode.ts +91 -0
- package/src/hooks/useDomEditCommits.test.tsx +2 -0
- package/src/hooks/useEditorSave.ts +14 -0
- package/src/hooks/useFileManager.ts +39 -25
- package/src/hooks/useFrameCapture.ts +12 -1
- package/src/hooks/usePanelLayout.ts +1 -0
- package/src/hooks/usePreviewPersistence.ts +24 -15
- package/src/hooks/useStudioContextValue.ts +5 -0
- package/src/hooks/useToast.ts +66 -13
- package/src/player/components/ShortcutsPanel.tsx +9 -0
- package/src/player/store/playerStore.ts +14 -0
- package/src/styles/studio.css +61 -0
- package/src/utils/sdkResolverShadow.test.ts +53 -3
- package/src/utils/sdkResolverShadow.ts +41 -1
- package/src/utils/studioPendingEdits.test.ts +43 -0
- package/src/utils/studioPendingEdits.ts +45 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
- package/dist/assets/index-DmkOvZns.css +0 -1
- package/dist/assets/index-SYSbQyK4.js +0 -396
- package/dist/chunk-JND3XUJL.js.map +0 -1
- /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
|
@@ -5,10 +5,19 @@ import { useState, useEffect } from "react";
|
|
|
5
5
|
* video + canvas. Seeks to ~10% of duration to avoid black opening frames.
|
|
6
6
|
* Used by AssetThumbnail (assets tab) and RenderQueueItem (renders tab).
|
|
7
7
|
*/
|
|
8
|
-
export function VideoFrameThumbnail({
|
|
8
|
+
export function VideoFrameThumbnail({
|
|
9
|
+
src,
|
|
10
|
+
fallbackLabel,
|
|
11
|
+
}: {
|
|
12
|
+
src: string;
|
|
13
|
+
/** Shown instead of an endless shimmer when the video can't be decoded. */
|
|
14
|
+
fallbackLabel?: string;
|
|
15
|
+
}) {
|
|
9
16
|
const [frame, setFrame] = useState<string | null>(null);
|
|
17
|
+
const [failed, setFailed] = useState(false);
|
|
10
18
|
|
|
11
19
|
useEffect(() => {
|
|
20
|
+
setFailed(false);
|
|
12
21
|
const video = document.createElement("video");
|
|
13
22
|
video.crossOrigin = "anonymous";
|
|
14
23
|
video.muted = true;
|
|
@@ -35,15 +44,29 @@ export function VideoFrameThumbnail({ src }: { src: string }) {
|
|
|
35
44
|
cleanup();
|
|
36
45
|
});
|
|
37
46
|
|
|
38
|
-
video.addEventListener("error",
|
|
47
|
+
video.addEventListener("error", () => {
|
|
48
|
+
// Resolve the loading state — a permanent shimmer reads as "still loading".
|
|
49
|
+
setFailed(true);
|
|
50
|
+
cleanup();
|
|
51
|
+
});
|
|
39
52
|
video.src = src;
|
|
40
53
|
video.load();
|
|
41
54
|
|
|
42
55
|
return cleanup;
|
|
43
56
|
}, [src]);
|
|
44
57
|
|
|
58
|
+
if (failed && !frame) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="w-full h-full bg-neutral-800 flex items-center justify-center">
|
|
61
|
+
<span className="text-[9px] font-medium text-neutral-600">{fallbackLabel ?? "VIDEO"}</span>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
if (!frame) {
|
|
46
|
-
return
|
|
67
|
+
return (
|
|
68
|
+
<div className="w-full h-full bg-neutral-800 animate-pulse motion-reduce:animate-none" />
|
|
69
|
+
);
|
|
47
70
|
}
|
|
48
71
|
|
|
49
72
|
return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// fallow-ignore-file unused-file
|
|
2
|
+
// (consumers land in the shell/sidebar PRs later in this stack)
|
|
3
|
+
import { useEffect, useCallback, useRef, type RefObject } from "react";
|
|
4
|
+
|
|
5
|
+
const FOCUSABLE =
|
|
6
|
+
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
|
|
7
|
+
|
|
8
|
+
interface DialogBehaviorOptions {
|
|
9
|
+
open: boolean;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
containerRef: RefObject<HTMLElement | null>;
|
|
12
|
+
/**
|
|
13
|
+
* Return false to veto a close triggered by Escape/backdrop (e.g. a dirty
|
|
14
|
+
* draft the user hasn't submitted). Direct onClose calls are not guarded.
|
|
15
|
+
*/
|
|
16
|
+
canClose?: () => boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shared dialog contract for the studio's custom modals: document-level
|
|
21
|
+
* Escape, Tab focus trap, focus-first-control on open, focus restore on close.
|
|
22
|
+
* The consumer still renders its own markup and should set role="dialog" and
|
|
23
|
+
* aria-modal="true" on the container.
|
|
24
|
+
*/
|
|
25
|
+
export function useDialogBehavior({
|
|
26
|
+
open,
|
|
27
|
+
onClose,
|
|
28
|
+
containerRef,
|
|
29
|
+
canClose,
|
|
30
|
+
}: DialogBehaviorOptions) {
|
|
31
|
+
const restoreRef = useRef<HTMLElement | null>(null);
|
|
32
|
+
const canCloseRef = useRef(canClose);
|
|
33
|
+
canCloseRef.current = canClose;
|
|
34
|
+
const onCloseRef = useRef(onClose);
|
|
35
|
+
onCloseRef.current = onClose;
|
|
36
|
+
|
|
37
|
+
const requestClose = useCallback(() => {
|
|
38
|
+
const guard = canCloseRef.current;
|
|
39
|
+
if (guard && !guard()) return;
|
|
40
|
+
onCloseRef.current();
|
|
41
|
+
}, []);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!open) return;
|
|
45
|
+
const previouslyFocused = document.activeElement;
|
|
46
|
+
restoreRef.current = previouslyFocused instanceof HTMLElement ? previouslyFocused : null;
|
|
47
|
+
|
|
48
|
+
const container = containerRef.current;
|
|
49
|
+
const first = container?.querySelector<HTMLElement>(FOCUSABLE);
|
|
50
|
+
(first ?? container)?.focus();
|
|
51
|
+
|
|
52
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
53
|
+
if (e.key === "Escape") {
|
|
54
|
+
e.stopPropagation();
|
|
55
|
+
requestClose();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (e.key !== "Tab") return;
|
|
59
|
+
const el = containerRef.current;
|
|
60
|
+
if (!el) return;
|
|
61
|
+
const focusables = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE));
|
|
62
|
+
if (focusables.length === 0) return;
|
|
63
|
+
const firstEl = focusables[0];
|
|
64
|
+
const lastEl = focusables[focusables.length - 1];
|
|
65
|
+
const active = document.activeElement;
|
|
66
|
+
if (e.shiftKey && (active === firstEl || !el.contains(active))) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
lastEl.focus();
|
|
69
|
+
} else if (!e.shiftKey && (active === lastEl || !el.contains(active))) {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
firstEl.focus();
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
75
|
+
return () => {
|
|
76
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
77
|
+
restoreRef.current?.focus();
|
|
78
|
+
restoreRef.current = null;
|
|
79
|
+
};
|
|
80
|
+
}, [open, containerRef, requestClose]);
|
|
81
|
+
|
|
82
|
+
return { requestClose };
|
|
83
|
+
}
|
|
@@ -16,6 +16,7 @@ export function PanelLayoutProvider({
|
|
|
16
16
|
leftWidth,
|
|
17
17
|
setLeftWidth,
|
|
18
18
|
rightWidth,
|
|
19
|
+
setRightWidth,
|
|
19
20
|
leftCollapsed,
|
|
20
21
|
setLeftCollapsed,
|
|
21
22
|
rightCollapsed,
|
|
@@ -39,6 +40,7 @@ export function PanelLayoutProvider({
|
|
|
39
40
|
leftWidth,
|
|
40
41
|
setLeftWidth,
|
|
41
42
|
rightWidth,
|
|
43
|
+
setRightWidth,
|
|
42
44
|
leftCollapsed,
|
|
43
45
|
setLeftCollapsed,
|
|
44
46
|
rightCollapsed,
|
|
@@ -56,6 +58,7 @@ export function PanelLayoutProvider({
|
|
|
56
58
|
leftWidth,
|
|
57
59
|
setLeftWidth,
|
|
58
60
|
rightWidth,
|
|
61
|
+
setRightWidth,
|
|
59
62
|
leftCollapsed,
|
|
60
63
|
setLeftCollapsed,
|
|
61
64
|
rightCollapsed,
|
|
@@ -19,7 +19,12 @@ export interface StudioShellValue {
|
|
|
19
19
|
renderQueue: {
|
|
20
20
|
jobs: unknown[];
|
|
21
21
|
isRendering: boolean;
|
|
22
|
+
loadError: string | null;
|
|
23
|
+
actionError: string | null;
|
|
24
|
+
dismissActionError: () => void;
|
|
25
|
+
reloadRenders: () => void;
|
|
22
26
|
deleteRender: (jobId: string) => void;
|
|
27
|
+
cancelRender: (jobId: string) => void;
|
|
23
28
|
clearCompleted: () => void;
|
|
24
29
|
startRender: (options: unknown) => Promise<void>;
|
|
25
30
|
};
|
|
@@ -58,6 +63,7 @@ export function useStudioPlaybackContext(): StudioPlaybackValue {
|
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
/** @deprecated Use useStudioShellContext and/or useStudioPlaybackContext instead. */
|
|
66
|
+
// fallow-ignore-next-line unused-export
|
|
61
67
|
export function useStudioContext(): StudioContextValue {
|
|
62
68
|
const shell = useStudioShellContext();
|
|
63
69
|
const playback = useStudioPlaybackContext();
|
|
@@ -166,6 +172,7 @@ export function StudioPlaybackProvider({
|
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
/** @deprecated Use StudioShellProvider and StudioPlaybackProvider instead. */
|
|
175
|
+
// fallow-ignore-next-line unused-export
|
|
169
176
|
export function StudioProvider({
|
|
170
177
|
value,
|
|
171
178
|
children,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads a composition file's content for the source editor when a composition
|
|
5
|
+
* is selected. Content stays null until the fetch resolves — the source editor
|
|
6
|
+
* must not mount on a null-content file, or its autosave would overwrite the
|
|
7
|
+
* real file with an empty document. Load failures surface as an error toast
|
|
8
|
+
* instead of silently rendering an empty (and autosave-armed) editor.
|
|
9
|
+
*/
|
|
10
|
+
export function useCompositionContentLoader({
|
|
11
|
+
projectId,
|
|
12
|
+
setEditingFile,
|
|
13
|
+
setActiveCompPath,
|
|
14
|
+
showToast,
|
|
15
|
+
}: {
|
|
16
|
+
projectId: string | null;
|
|
17
|
+
setEditingFile: (file: { path: string; content: string | null }) => void;
|
|
18
|
+
setActiveCompPath: (path: string | null) => void;
|
|
19
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
20
|
+
}) {
|
|
21
|
+
return useCallback(
|
|
22
|
+
(comp: string) => {
|
|
23
|
+
setActiveCompPath(comp.endsWith(".html") ? comp : null);
|
|
24
|
+
setEditingFile({ path: comp, content: null });
|
|
25
|
+
fetch(`/api/projects/${projectId}/files/${comp}`)
|
|
26
|
+
.then(async (r) => {
|
|
27
|
+
if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
|
|
28
|
+
return r.json();
|
|
29
|
+
})
|
|
30
|
+
.then((data: { content?: string }) => {
|
|
31
|
+
if (typeof data.content !== "string") throw new Error(`No content returned for ${comp}`);
|
|
32
|
+
setEditingFile({ path: comp, content: data.content });
|
|
33
|
+
})
|
|
34
|
+
.catch((err) => {
|
|
35
|
+
showToast(err instanceof Error ? err.message : `Failed to load ${comp}`, "error");
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
[projectId, setEditingFile, setActiveCompPath, showToast],
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useEffect, useMemo, useReducer } from "react";
|
|
2
|
+
import { usePlayerStore } from "../player";
|
|
3
|
+
|
|
4
|
+
export interface CropModeProps {
|
|
5
|
+
cropMode: boolean;
|
|
6
|
+
onCropModeChange: (active: boolean) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Crop mode lives in the player store so the canvas toolbar, the Clip panel,
|
|
10
|
+
* and the overlay all share one switch without prop threading. */
|
|
11
|
+
export function useCropModeProps(): CropModeProps {
|
|
12
|
+
const cropMode = usePlayerStore((s) => s.cropMode);
|
|
13
|
+
const setCropMode = usePlayerStore((s) => s.setCropMode);
|
|
14
|
+
return useMemo(
|
|
15
|
+
() => ({
|
|
16
|
+
cropMode,
|
|
17
|
+
onCropModeChange: setCropMode,
|
|
18
|
+
}),
|
|
19
|
+
[cropMode, setCropMode],
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
import type { OverlayRect } from "../components/editor/domEditOverlayGeometry";
|
|
24
|
+
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
25
|
+
import { readElementCropInsets } from "../components/editor/domEditOverlayCrop";
|
|
26
|
+
|
|
27
|
+
/** Overlay-side crop state: Escape-to-exit, toolbar availability publishing,
|
|
28
|
+
* and the box clip that makes the selection outline hug the cropped region.
|
|
29
|
+
* The box div itself always sits at the FULL element bounds — gestures write
|
|
30
|
+
* its position directly during drags, so moving/resizing it in React would
|
|
31
|
+
* fight them. The hug is purely visual: the element's inset clip-path scaled
|
|
32
|
+
* into overlay space and applied to the box. */
|
|
33
|
+
export function useCropOverlay(params: {
|
|
34
|
+
selection: DomEditSelection | null;
|
|
35
|
+
groupCount: number;
|
|
36
|
+
cropMode: boolean;
|
|
37
|
+
onCropModeChange?: (active: boolean) => void;
|
|
38
|
+
overlayRect: OverlayRect | null;
|
|
39
|
+
}) {
|
|
40
|
+
const { selection, groupCount, cropMode, onCropModeChange, overlayRect } = params;
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!cropMode || !onCropModeChange) return;
|
|
44
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
45
|
+
if (event.key === "Escape") onCropModeChange(false);
|
|
46
|
+
};
|
|
47
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
48
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
49
|
+
}, [cropMode, onCropModeChange]);
|
|
50
|
+
|
|
51
|
+
// Publish availability so the canvas toolbar shows the Crop button only
|
|
52
|
+
// when the selection can take a clip-path crop.
|
|
53
|
+
const setCropAvailable = usePlayerStore((s) => s.setCropAvailable);
|
|
54
|
+
const cropAvailable = Boolean(selection && groupCount <= 1 && selection.capabilities.canCrop);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
setCropAvailable(cropAvailable);
|
|
57
|
+
return () => setCropAvailable(false);
|
|
58
|
+
}, [cropAvailable, setCropAvailable]);
|
|
59
|
+
|
|
60
|
+
// Crop-mode exit restores the element's clip in an effect cleanup — after
|
|
61
|
+
// this hook already read it. One forced re-render picks up the fresh insets
|
|
62
|
+
// so the selection box hugs the crop immediately.
|
|
63
|
+
const [, bumpAfterExit] = useReducer((x: number) => x + 1, 0);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!cropMode) bumpAfterExit();
|
|
66
|
+
}, [cropMode]);
|
|
67
|
+
|
|
68
|
+
const cropInsets = selection ? readElementCropInsets(selection.element) : null;
|
|
69
|
+
const hasCropInsets = Boolean(
|
|
70
|
+
cropInsets &&
|
|
71
|
+
(cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// Scaled insets for the crop outline child + the resize-handle shift. The
|
|
75
|
+
// box div itself stays border-less at full bounds; a child draws the
|
|
76
|
+
// outline ON the crop boundary (a clip on the box would swallow the
|
|
77
|
+
// border everywhere the crop edge doesn't touch the element edge).
|
|
78
|
+
const sx = overlayRect && overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1;
|
|
79
|
+
const sy = overlayRect && overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1;
|
|
80
|
+
const cropOutlineInsetPx =
|
|
81
|
+
cropInsets && hasCropInsets && !cropMode
|
|
82
|
+
? {
|
|
83
|
+
top: cropInsets.top * sy,
|
|
84
|
+
right: cropInsets.right * sx,
|
|
85
|
+
bottom: cropInsets.bottom * sy,
|
|
86
|
+
left: cropInsets.left * sx,
|
|
87
|
+
}
|
|
88
|
+
: undefined;
|
|
89
|
+
|
|
90
|
+
return { hasCropInsets, cropOutlineInsetPx };
|
|
91
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// fallow-ignore-file code-duplication
|
|
1
2
|
// @vitest-environment happy-dom
|
|
2
3
|
import { act, createElement } from "react";
|
|
3
4
|
import { createRoot, type Root } from "react-dom/client";
|
|
@@ -183,6 +184,7 @@ function createSelection(
|
|
|
183
184
|
capabilities: {
|
|
184
185
|
canSelect: true,
|
|
185
186
|
canEditStyles: true,
|
|
187
|
+
canCrop: true,
|
|
186
188
|
canMove: true,
|
|
187
189
|
canResize: true,
|
|
188
190
|
canApplyManualOffset: true,
|
|
@@ -18,6 +18,7 @@ interface UseEditorSaveOptions {
|
|
|
18
18
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
19
19
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
20
20
|
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
|
21
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function useEditorSave({
|
|
@@ -28,9 +29,13 @@ export function useEditorSave({
|
|
|
28
29
|
recordEdit,
|
|
29
30
|
domEditSaveTimestampRef,
|
|
30
31
|
setRefreshKey,
|
|
32
|
+
showToast,
|
|
31
33
|
}: UseEditorSaveOptions) {
|
|
32
34
|
const saveRafRef = useRef<number | null>(null);
|
|
33
35
|
const refreshRafRef = useRef<number | null>(null);
|
|
36
|
+
// One error toast per burst of failures — every keystroke retries the save,
|
|
37
|
+
// and error toasts persist until dismissed, so don't stack duplicates.
|
|
38
|
+
const lastFailureToastAtRef = useRef(0);
|
|
34
39
|
|
|
35
40
|
const handleContentChange = useCallback(
|
|
36
41
|
(content: string) => {
|
|
@@ -61,6 +66,14 @@ export function useEditorSave({
|
|
|
61
66
|
source: "code_editor",
|
|
62
67
|
error_message: error instanceof Error ? error.message : "unknown",
|
|
63
68
|
});
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
if (now - lastFailureToastAtRef.current > 5000) {
|
|
71
|
+
lastFailureToastAtRef.current = now;
|
|
72
|
+
showToast(
|
|
73
|
+
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
|
|
74
|
+
"error",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
64
77
|
});
|
|
65
78
|
});
|
|
66
79
|
},
|
|
@@ -71,6 +84,7 @@ export function useEditorSave({
|
|
|
71
84
|
readProjectFile,
|
|
72
85
|
recordEdit,
|
|
73
86
|
setRefreshKey,
|
|
87
|
+
showToast,
|
|
74
88
|
writeProjectFile,
|
|
75
89
|
],
|
|
76
90
|
);
|
|
@@ -126,6 +126,7 @@ export function useFileManager({
|
|
|
126
126
|
recordEdit,
|
|
127
127
|
domEditSaveTimestampRef,
|
|
128
128
|
setRefreshKey,
|
|
129
|
+
showToast,
|
|
129
130
|
});
|
|
130
131
|
|
|
131
132
|
// ── File select ──
|
|
@@ -133,26 +134,34 @@ export function useFileManager({
|
|
|
133
134
|
const revealRequestIdRef = useRef(0);
|
|
134
135
|
const revealAbortRef = useRef<AbortController | null>(null);
|
|
135
136
|
|
|
136
|
-
const handleFileSelect = useCallback(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
137
|
+
const handleFileSelect = useCallback(
|
|
138
|
+
(path: string) => {
|
|
139
|
+
const pid = projectIdRef.current;
|
|
140
|
+
if (!pid) return;
|
|
141
|
+
revealAbortRef.current?.abort();
|
|
142
|
+
revealAbortRef.current = null;
|
|
143
|
+
revealRequestIdRef.current++;
|
|
144
|
+
// Skip fetching binary content for media files — just set the path for preview
|
|
145
|
+
if (isMediaFile(path)) {
|
|
146
|
+
setEditingFile({ path, content: null });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
|
|
150
|
+
.then((r) => {
|
|
151
|
+
if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
|
|
152
|
+
return r.json();
|
|
153
|
+
})
|
|
154
|
+
.then((data: { content?: string }) => {
|
|
155
|
+
if (data.content != null) {
|
|
156
|
+
setEditingFile({ path, content: data.content });
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
.catch((err: unknown) => {
|
|
160
|
+
showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
[showToast],
|
|
164
|
+
);
|
|
156
165
|
|
|
157
166
|
// ── Click-to-source ──
|
|
158
167
|
|
|
@@ -253,9 +262,10 @@ export function useFileManager({
|
|
|
253
262
|
} else {
|
|
254
263
|
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
255
264
|
console.error(`Create file failed: ${err.error}`);
|
|
265
|
+
showToast(`Couldn't create ${path}: ${err.error}`, "error");
|
|
256
266
|
}
|
|
257
267
|
},
|
|
258
|
-
[refreshFileTree, handleFileSelect],
|
|
268
|
+
[refreshFileTree, handleFileSelect, showToast],
|
|
259
269
|
);
|
|
260
270
|
|
|
261
271
|
const handleCreateFolder = useCallback(
|
|
@@ -275,9 +285,10 @@ export function useFileManager({
|
|
|
275
285
|
} else {
|
|
276
286
|
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
277
287
|
console.error(`Create folder failed: ${err.error}`);
|
|
288
|
+
showToast(`Couldn't create folder ${path}: ${err.error}`, "error");
|
|
278
289
|
}
|
|
279
290
|
},
|
|
280
|
-
[refreshFileTree],
|
|
291
|
+
[refreshFileTree, showToast],
|
|
281
292
|
);
|
|
282
293
|
|
|
283
294
|
const handleDeleteFile = useCallback(
|
|
@@ -293,9 +304,10 @@ export function useFileManager({
|
|
|
293
304
|
} else {
|
|
294
305
|
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
295
306
|
console.error(`Delete failed: ${err.error}`);
|
|
307
|
+
showToast(`Couldn't delete ${path}: ${err.error}`, "error");
|
|
296
308
|
}
|
|
297
309
|
},
|
|
298
|
-
[refreshFileTree],
|
|
310
|
+
[refreshFileTree, showToast],
|
|
299
311
|
);
|
|
300
312
|
|
|
301
313
|
const handleRenameFile = useCallback(
|
|
@@ -316,9 +328,10 @@ export function useFileManager({
|
|
|
316
328
|
} else {
|
|
317
329
|
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
318
330
|
console.error(`Rename failed: ${err.error}`);
|
|
331
|
+
showToast(`Couldn't rename ${oldPath}: ${err.error}`, "error");
|
|
319
332
|
}
|
|
320
333
|
},
|
|
321
|
-
[refreshFileTree, handleFileSelect, setRefreshKey],
|
|
334
|
+
[refreshFileTree, handleFileSelect, setRefreshKey, showToast],
|
|
322
335
|
);
|
|
323
336
|
|
|
324
337
|
const handleDuplicateFile = useCallback(
|
|
@@ -337,9 +350,10 @@ export function useFileManager({
|
|
|
337
350
|
} else {
|
|
338
351
|
const err = await res.json().catch(() => ({ error: "unknown" }));
|
|
339
352
|
console.error(`Duplicate failed: ${err.error}`);
|
|
353
|
+
showToast(`Couldn't duplicate ${path}: ${err.error}`, "error");
|
|
340
354
|
}
|
|
341
355
|
},
|
|
342
|
-
[refreshFileTree, handleFileSelect],
|
|
356
|
+
[refreshFileTree, handleFileSelect, showToast],
|
|
343
357
|
);
|
|
344
358
|
|
|
345
359
|
const handleMoveFile = handleRenameFile;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, type MouseEvent } from "react";
|
|
1
|
+
import { useState, useCallback, useRef, type MouseEvent } from "react";
|
|
2
2
|
import { useMountEffect } from "./useMountEffect";
|
|
3
3
|
import { liveTime, usePlayerStore } from "../player";
|
|
4
4
|
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
|
|
@@ -17,6 +17,8 @@ export function useFrameCapture({
|
|
|
17
17
|
waitForPendingDomEditSaves,
|
|
18
18
|
}: UseFrameCaptureParams) {
|
|
19
19
|
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
|
20
|
+
const [capturing, setCapturing] = useState(false);
|
|
21
|
+
const capturingRef = useRef(false);
|
|
20
22
|
|
|
21
23
|
useMountEffect(() => {
|
|
22
24
|
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
|
@@ -31,6 +33,11 @@ export function useFrameCapture({
|
|
|
31
33
|
async (event: MouseEvent<HTMLAnchorElement>) => {
|
|
32
34
|
if (!projectId) return;
|
|
33
35
|
event.preventDefault();
|
|
36
|
+
// A capture can take up to ~35s (save drain + server render) — ignore
|
|
37
|
+
// re-entrant clicks instead of firing parallel captures.
|
|
38
|
+
if (capturingRef.current) return;
|
|
39
|
+
capturingRef.current = true;
|
|
40
|
+
setCapturing(true);
|
|
34
41
|
try {
|
|
35
42
|
const time = usePlayerStore.getState().currentTime;
|
|
36
43
|
setCaptureFrameTime(time);
|
|
@@ -79,6 +86,9 @@ export function useFrameCapture({
|
|
|
79
86
|
}
|
|
80
87
|
} catch (err) {
|
|
81
88
|
showToast(err instanceof Error ? err.message : "Capture failed", "error");
|
|
89
|
+
} finally {
|
|
90
|
+
capturingRef.current = false;
|
|
91
|
+
setCapturing(false);
|
|
82
92
|
}
|
|
83
93
|
},
|
|
84
94
|
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
|
@@ -98,5 +108,6 @@ export function useFrameCapture({
|
|
|
98
108
|
captureFrameFilename,
|
|
99
109
|
handleCaptureFrameClick,
|
|
100
110
|
refreshCaptureFrameTime,
|
|
111
|
+
capturing,
|
|
101
112
|
};
|
|
102
113
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
9
9
|
import type { EditHistoryKind } from "../utils/editHistory";
|
|
10
10
|
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
|
|
11
|
+
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
|
|
11
12
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
12
13
|
|
|
13
14
|
// ── Types ──
|
|
@@ -77,6 +78,24 @@ function shouldReloadForStudioFileChange(
|
|
|
77
78
|
return Date.now() - domEditSaveTimestampRef.current >= 4000;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
// fallow-ignore-next-line complexity
|
|
82
|
+
async function clearLegacyStudioMotionFile(
|
|
83
|
+
readOptionalProjectFile: (path: string) => Promise<string>,
|
|
84
|
+
writeProjectFile: (path: string, content: string) => Promise<void>,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const content = await readOptionalProjectFile(STUDIO_MOTION_PATH).catch(() => null);
|
|
87
|
+
if (!content) return;
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(content) as { motions?: unknown[] };
|
|
90
|
+
if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return;
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] })).catch(
|
|
95
|
+
() => {},
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
// ── Hook ──
|
|
81
100
|
|
|
82
101
|
export function usePreviewPersistence({
|
|
@@ -135,6 +154,7 @@ export function usePreviewPersistence({
|
|
|
135
154
|
}, []);
|
|
136
155
|
|
|
137
156
|
const waitForPendingDomEditSaves = useCallback(async () => {
|
|
157
|
+
await flushStudioPendingEdits();
|
|
138
158
|
await domEditSaveQueueRef.current?.waitForIdle();
|
|
139
159
|
}, []);
|
|
140
160
|
|
|
@@ -185,20 +205,7 @@ export function usePreviewPersistence({
|
|
|
185
205
|
// could still fire alongside the new seek-reapply runtime. Empty the file so
|
|
186
206
|
// the legacy codepath no-ops.
|
|
187
207
|
useMountEffect(() => {
|
|
188
|
-
_readOptionalProjectFile
|
|
189
|
-
.then((content) => {
|
|
190
|
-
if (!content) return;
|
|
191
|
-
try {
|
|
192
|
-
const parsed = JSON.parse(content) as { motions?: unknown[] };
|
|
193
|
-
if (!Array.isArray(parsed.motions) || parsed.motions.length === 0) return;
|
|
194
|
-
} catch {
|
|
195
|
-
return;
|
|
196
|
-
}
|
|
197
|
-
return _writeProjectFile(STUDIO_MOTION_PATH, JSON.stringify({ version: 1, motions: [] }));
|
|
198
|
-
})
|
|
199
|
-
.catch(() => {
|
|
200
|
-
/* best-effort migration — ignore failures */
|
|
201
|
-
});
|
|
208
|
+
void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
|
|
202
209
|
});
|
|
203
210
|
|
|
204
211
|
// ── Listen for external file changes (HMR / SSE) ──
|
|
@@ -210,8 +217,10 @@ export function usePreviewPersistence({
|
|
|
210
217
|
pendingTimelineEditPathRef,
|
|
211
218
|
domEditSaveTimestampRef,
|
|
212
219
|
)
|
|
213
|
-
)
|
|
220
|
+
) {
|
|
221
|
+
// fallow-ignore-next-line code-duplication
|
|
214
222
|
reloadPreview();
|
|
223
|
+
}
|
|
215
224
|
};
|
|
216
225
|
if (import.meta.hot) {
|
|
217
226
|
import.meta.hot.on("hf:file-change", handler);
|
|
@@ -21,7 +21,12 @@ interface StudioContextInput {
|
|
|
21
21
|
renderQueue: {
|
|
22
22
|
jobs: unknown[];
|
|
23
23
|
isRendering: boolean;
|
|
24
|
+
loadError: string | null;
|
|
25
|
+
actionError: string | null;
|
|
26
|
+
dismissActionError: () => void;
|
|
27
|
+
reloadRenders: () => void;
|
|
24
28
|
deleteRender: (id: string) => void;
|
|
29
|
+
cancelRender: (id: string) => void;
|
|
25
30
|
clearCompleted: () => void;
|
|
26
31
|
startRender: (options: unknown) => Promise<void>;
|
|
27
32
|
};
|