@hyperframes/studio 0.7.37 → 0.7.38
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-BLRTwY5l.js +396 -0
- package/dist/assets/{index-BXYJbIax.js → index-ClUipc8i.js} +1 -1
- package/dist/assets/index-DJaiR8T2.css +1 -0
- package/dist/assets/{index-Ct-X2wlf.js → index-Ykq7ihge.js} +1 -1
- package/dist/index.d.ts +35 -10
- package/dist/index.html +2 -2
- package/dist/index.js +5146 -3321
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +19 -18
- 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/StudioRightPanel.tsx +154 -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/PropertyPanel.tsx +18 -17
- package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
- package/src/components/editor/colorGradingScopePatch.ts +57 -0
- package/src/components/editor/manualEditingAvailability.test.ts +0 -12
- package/src/components/editor/manualEditingAvailability.ts +0 -6
- 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 +22 -91
- package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
- package/src/components/editor/propertyPanelTypes.ts +111 -0
- 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/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/useCompositionContentLoader.ts +40 -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/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
|
@@ -30,22 +30,24 @@ const variantStyles: Record<ButtonVariant, string> = {
|
|
|
30
30
|
primary: [
|
|
31
31
|
"bg-white text-neutral-950 font-medium",
|
|
32
32
|
"shadow-btn-primary",
|
|
33
|
-
"hover:bg-neutral-200",
|
|
34
|
-
"active:scale-[0.97]",
|
|
33
|
+
"enabled:hover:bg-neutral-200",
|
|
34
|
+
"enabled:active:scale-[0.97]",
|
|
35
35
|
].join(" "),
|
|
36
36
|
secondary: [
|
|
37
37
|
"bg-transparent text-neutral-300 font-medium",
|
|
38
38
|
"border border-border",
|
|
39
|
-
"hover:bg-surface-hover hover:text-white hover:border-border-strong",
|
|
40
|
-
"active:scale-[0.98]",
|
|
39
|
+
"enabled:hover:bg-surface-hover enabled:hover:text-white enabled:hover:border-border-strong",
|
|
40
|
+
"enabled:active:scale-[0.98]",
|
|
41
|
+
].join(" "),
|
|
42
|
+
danger: [
|
|
43
|
+
"bg-accent-red text-white font-medium",
|
|
44
|
+
"enabled:hover:bg-red-600",
|
|
45
|
+
"enabled:active:scale-[0.97]",
|
|
41
46
|
].join(" "),
|
|
42
|
-
danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(
|
|
43
|
-
" ",
|
|
44
|
-
),
|
|
45
47
|
ghost: [
|
|
46
48
|
"bg-transparent text-neutral-400",
|
|
47
|
-
"hover:bg-surface-hover hover:text-white",
|
|
48
|
-
"active:scale-[0.98]",
|
|
49
|
+
"enabled:hover:bg-surface-hover enabled:hover:text-white",
|
|
50
|
+
"enabled:active:scale-[0.98]",
|
|
49
51
|
].join(" "),
|
|
50
52
|
};
|
|
51
53
|
|
|
@@ -55,6 +57,8 @@ const sizeStyles: Record<ButtonSize, string> = {
|
|
|
55
57
|
lg: "h-9 px-4 text-base gap-2 rounded-button",
|
|
56
58
|
};
|
|
57
59
|
|
|
60
|
+
// Imported by the shell/renders PRs later in this stack.
|
|
61
|
+
// fallow-ignore-next-line unused-export
|
|
58
62
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
59
63
|
(
|
|
60
64
|
{
|
|
@@ -76,8 +80,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
76
80
|
className={[
|
|
77
81
|
"inline-flex items-center justify-center",
|
|
78
82
|
"transition-all duration-press ease-standard",
|
|
79
|
-
|
|
83
|
+
// No pointer-events-none: disabled buttons must still receive hover
|
|
84
|
+
// so a wrapping Tooltip can explain WHY they're disabled (A5).
|
|
85
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
80
86
|
"select-none cursor-pointer",
|
|
87
|
+
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
|
|
81
88
|
variantStyles[variant],
|
|
82
89
|
sizeStyles[size],
|
|
83
90
|
className,
|
|
@@ -126,6 +133,7 @@ const iconSizeStyles: Record<ButtonSize, string> = {
|
|
|
126
133
|
lg: "min-w-9 min-h-9 rounded-button", // 36px
|
|
127
134
|
};
|
|
128
135
|
|
|
136
|
+
// fallow-ignore-next-line unused-export
|
|
129
137
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
130
138
|
({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
|
|
131
139
|
return (
|
|
@@ -134,8 +142,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
|
|
134
142
|
className={[
|
|
135
143
|
"inline-flex items-center justify-center",
|
|
136
144
|
"transition-all duration-press ease-standard",
|
|
137
|
-
"disabled:opacity-40 disabled:
|
|
145
|
+
"disabled:opacity-40 disabled:cursor-not-allowed",
|
|
138
146
|
"select-none cursor-pointer",
|
|
147
|
+
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
|
|
139
148
|
variantStyles[variant],
|
|
140
149
|
iconSizeStyles[size],
|
|
141
150
|
className,
|
|
@@ -25,7 +25,7 @@ export function HyperframesLoader({
|
|
|
25
25
|
const markFrameSize = Math.round(size * 1.16);
|
|
26
26
|
|
|
27
27
|
return (
|
|
28
|
-
<div className="hf-loader" draggable={false}>
|
|
28
|
+
<div className="hf-loader" role="status" draggable={false}>
|
|
29
29
|
<div
|
|
30
30
|
className="hf-loader-mark-frame"
|
|
31
31
|
style={{ width: markFrameSize, height: markFrameSize }}
|
|
@@ -83,7 +83,13 @@ export function HyperframesLoader({
|
|
|
83
83
|
<div className="hf-loader-title">{title}</div>
|
|
84
84
|
{detail && <div className="hf-loader-detail">{detail}</div>}
|
|
85
85
|
{boundedProgress !== undefined && (
|
|
86
|
-
<div
|
|
86
|
+
<div
|
|
87
|
+
className="hf-loader-progress"
|
|
88
|
+
role="progressbar"
|
|
89
|
+
aria-valuemin={0}
|
|
90
|
+
aria-valuemax={100}
|
|
91
|
+
aria-valuenow={Math.round(boundedProgress * 100)}
|
|
92
|
+
>
|
|
87
93
|
<div
|
|
88
94
|
className="hf-loader-progress__fill"
|
|
89
95
|
style={{ transform: `scaleX(${boundedProgress})` }}
|
|
@@ -95,6 +101,7 @@ export function HyperframesLoader({
|
|
|
95
101
|
);
|
|
96
102
|
}
|
|
97
103
|
|
|
104
|
+
// fallow-ignore-next-line unused-export
|
|
98
105
|
export function StatusFrame(props: HyperframesLoaderProps) {
|
|
99
106
|
return (
|
|
100
107
|
<div className="hf-frame">
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// fallow-ignore-file unused-file
|
|
2
|
+
// (consumers land in the sidebar/panels PR later in this stack)
|
|
3
|
+
import { type InputHTMLAttributes } from "react";
|
|
4
|
+
|
|
5
|
+
interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
6
|
+
/** Accessible name — placeholder alone is not one. */
|
|
7
|
+
"aria-label": string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Shared search input — one visual system (panel-input tokens) for every
|
|
12
|
+
* panel search box, with a required accessible name.
|
|
13
|
+
*/
|
|
14
|
+
export function SearchInput({ className = "", ...props }: SearchInputProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
className={`flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] ${className}`}
|
|
18
|
+
>
|
|
19
|
+
<svg
|
|
20
|
+
width="12"
|
|
21
|
+
height="12"
|
|
22
|
+
viewBox="0 0 256 256"
|
|
23
|
+
fill="none"
|
|
24
|
+
className="flex-shrink-0"
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
>
|
|
27
|
+
<circle
|
|
28
|
+
cx="116"
|
|
29
|
+
cy="116"
|
|
30
|
+
r="76"
|
|
31
|
+
stroke="currentColor"
|
|
32
|
+
strokeWidth="22"
|
|
33
|
+
className="text-panel-text-5"
|
|
34
|
+
/>
|
|
35
|
+
<line
|
|
36
|
+
x1="170"
|
|
37
|
+
y1="170"
|
|
38
|
+
x2="232"
|
|
39
|
+
y2="232"
|
|
40
|
+
stroke="currentColor"
|
|
41
|
+
strokeWidth="22"
|
|
42
|
+
strokeLinecap="round"
|
|
43
|
+
className="text-panel-text-5"
|
|
44
|
+
/>
|
|
45
|
+
</svg>
|
|
46
|
+
<input
|
|
47
|
+
type="text"
|
|
48
|
+
className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useRef, useCallback, type ReactNode } from "react";
|
|
1
|
+
import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
|
|
4
4
|
interface TooltipProps {
|
|
@@ -8,22 +8,46 @@ interface TooltipProps {
|
|
|
8
8
|
side?: "top" | "bottom";
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// Rough bubble height (padding + one text line) used to decide flipping
|
|
12
|
+
// before the bubble has rendered; exact height isn't needed for the guard.
|
|
13
|
+
const APPROX_BUBBLE_H = 28;
|
|
14
|
+
const VIEWPORT_MARGIN = 8;
|
|
15
|
+
|
|
11
16
|
export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
|
|
12
17
|
const [visible, setVisible] = useState(false);
|
|
13
18
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
|
19
|
+
const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
|
|
14
20
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
15
21
|
const triggerRef = useRef<HTMLSpanElement>(null);
|
|
22
|
+
// WCAG 4.1.2: programmatically associate the bubble with its trigger.
|
|
23
|
+
const tooltipId = useId();
|
|
16
24
|
|
|
17
25
|
const show = useCallback(() => {
|
|
26
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
18
27
|
timerRef.current = setTimeout(() => {
|
|
19
28
|
const el = triggerRef.current;
|
|
20
29
|
if (!el) return;
|
|
21
30
|
const child = el.firstElementChild as HTMLElement | null;
|
|
22
31
|
const rect = (child ?? el).getBoundingClientRect();
|
|
23
32
|
if (rect.width === 0 && rect.height === 0) return;
|
|
33
|
+
// Flip when the preferred side would clip the viewport edge.
|
|
34
|
+
let nextSide = side;
|
|
35
|
+
if (side === "top" && rect.top - APPROX_BUBBLE_H - 6 < VIEWPORT_MARGIN) {
|
|
36
|
+
nextSide = "bottom";
|
|
37
|
+
} else if (
|
|
38
|
+
side === "bottom" &&
|
|
39
|
+
rect.bottom + APPROX_BUBBLE_H + 6 > window.innerHeight - VIEWPORT_MARGIN
|
|
40
|
+
) {
|
|
41
|
+
nextSide = "top";
|
|
42
|
+
}
|
|
43
|
+
const x = Math.min(
|
|
44
|
+
Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN),
|
|
45
|
+
window.innerWidth - VIEWPORT_MARGIN,
|
|
46
|
+
);
|
|
47
|
+
setResolvedSide(nextSide);
|
|
24
48
|
setPos({
|
|
25
|
-
x
|
|
26
|
-
y:
|
|
49
|
+
x,
|
|
50
|
+
y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
|
|
27
51
|
});
|
|
28
52
|
setVisible(true);
|
|
29
53
|
}, delay);
|
|
@@ -37,9 +61,27 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|
|
37
61
|
setVisible(false);
|
|
38
62
|
}, []);
|
|
39
63
|
|
|
64
|
+
// WCAG 1.4.13: tooltip content must be dismissible with Escape.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!visible) return;
|
|
67
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
68
|
+
if (e.key === "Escape") hide();
|
|
69
|
+
};
|
|
70
|
+
document.addEventListener("keydown", onKeyDown);
|
|
71
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
72
|
+
}, [visible, hide]);
|
|
73
|
+
|
|
40
74
|
return (
|
|
41
75
|
<>
|
|
42
|
-
<span
|
|
76
|
+
<span
|
|
77
|
+
ref={triggerRef}
|
|
78
|
+
onPointerEnter={show}
|
|
79
|
+
onPointerLeave={hide}
|
|
80
|
+
onFocus={show}
|
|
81
|
+
onBlur={hide}
|
|
82
|
+
aria-describedby={visible ? tooltipId : undefined}
|
|
83
|
+
className="contents"
|
|
84
|
+
>
|
|
43
85
|
{children}
|
|
44
86
|
</span>
|
|
45
87
|
{visible &&
|
|
@@ -49,10 +91,14 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
|
|
|
49
91
|
style={{
|
|
50
92
|
left: pos.x,
|
|
51
93
|
top: pos.y,
|
|
52
|
-
transform:
|
|
94
|
+
transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
|
|
53
95
|
}}
|
|
54
96
|
>
|
|
55
|
-
<div
|
|
97
|
+
<div
|
|
98
|
+
role="tooltip"
|
|
99
|
+
id={tooltipId}
|
|
100
|
+
className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg"
|
|
101
|
+
>
|
|
56
102
|
{label}
|
|
57
103
|
</div>
|
|
58
104
|
</div>,
|
|
@@ -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
|
+
}
|
|
@@ -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;
|