@hyperframes/studio 0.8.52 → 0.8.53
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-I5YRzB02.js → hyperframes-player-DKWM-Ons.js} +1 -1
- package/dist/assets/index-DrHQAuGO.css +1 -0
- package/dist/assets/index-HfP3sap2.js +390 -0
- package/dist/assets/{index-BTAfgesM.js → index-UvW6BEwf.js} +1 -1
- package/dist/assets/{index-Du5x7KUR.js → index-Y4OnpTEM.js} +1 -1
- package/dist/index.css +29 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +13 -6
- package/dist/index.html +2 -2
- package/dist/index.js +7664 -7953
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -0
- package/package.json +8 -7
- package/src/App.tsx +42 -48
- package/src/components/EditorShell.selectionSync.test.tsx +9 -4
- package/src/components/EditorShell.tsx +52 -56
- package/src/components/StudioHeader.dom.test.tsx +1 -1
- package/src/components/StudioHeader.test.ts +0 -12
- package/src/components/StudioHeader.tsx +10 -13
- package/src/components/StudioLeftPanels.tsx +202 -0
- package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -206
- package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
- package/src/components/dock/Dock.test.tsx +16 -2
- package/src/components/dock/Dock.tsx +13 -3
- package/src/components/dock/dockLayoutStore.test.ts +28 -0
- package/src/components/dock/dockLayoutStore.ts +21 -4
- package/src/components/editor/SnapToolbar.test.tsx +0 -3
- package/src/components/nle/NLEContext.tsx +0 -43
- package/src/components/nle/TimelinePane.tsx +46 -67
- package/src/components/panels/SlideshowPanel.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +1 -1
- package/src/components/renders/RenderQueue.test.tsx +133 -25
- package/src/components/renders/RenderQueue.tsx +99 -92
- package/src/components/renders/RenderQueueItem.tsx +69 -64
- package/src/components/renders/RenderQueuePanel.tsx +1 -1
- package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
- package/src/components/sidebar/CompositionsPanel.tsx +15 -0
- package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
- package/src/components/sidebar/SidebarLintButton.tsx +50 -0
- package/src/components/ui/Select.tsx +4 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -57
- package/src/headerExports.test.tsx +23 -0
- package/src/hooks/appHotkeysDispatch.ts +3 -4
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
- package/src/hooks/useAppHotkeys.test.ts +0 -1
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
- package/src/hooks/useAppHotkeys.ts +0 -4
- package/src/hooks/useDomEditPreviewSync.ts +4 -6
- package/src/hooks/useDomEditSession.ts +0 -7
- package/src/hooks/useDomEditWiring.ts +4 -9
- package/src/hooks/usePanelLayout.test.ts +108 -296
- package/src/hooks/usePanelLayout.ts +50 -255
- package/src/hooks/useRightPanelIntents.test.ts +181 -0
- package/src/hooks/useRightPanelIntents.ts +64 -0
- package/src/hooks/useSlideshowTabState.test.ts +9 -31
- package/src/hooks/useSlideshowTabState.ts +6 -19
- package/src/hooks/useStudioContextValue.test.ts +6 -18
- package/src/hooks/useStudioContextValue.ts +5 -26
- package/src/index.ts +2 -1
- package/src/main.tsx +26 -0
- package/src/player/components/Timeline.tsx +1 -1
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
- package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
- package/src/utils/studioHelpers.ts +0 -7
- package/src/utils/studioUiPreferences.test.ts +10 -16
- package/src/utils/studioUiPreferences.ts +0 -16
- package/dist/assets/index-CxAu_Okg.css +0 -1
- package/dist/assets/index-DocJlW2v.js +0 -376
- package/src/components/StudioLeftSidebar.tsx +0 -207
- package/src/components/nle/TimelineResizeDivider.tsx +0 -101
- package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
- package/src/components/sidebar/LeftSidebar.tsx +0 -348
- package/src/hooks/useInspectorSplitResize.ts +0 -51
- package/src/utils/fitPanels.test.ts +0 -164
- package/src/utils/fitPanels.ts +0 -151
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
import { useCallback, type RefObject } from "react";
|
|
2
|
-
import { SourceEditor } from "./editor/SourceEditor";
|
|
3
|
-
import { LeftSidebar, type LeftSidebarHandle } from "./sidebar/LeftSidebar";
|
|
4
|
-
import { MediaPreview } from "./MediaPreview";
|
|
5
|
-
import { isMediaFile } from "../utils/mediaTypes";
|
|
6
|
-
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
|
7
|
-
import { useStudioShellContext } from "../contexts/StudioContext";
|
|
8
|
-
import { useFileManagerContext } from "../contexts/FileManagerContext";
|
|
9
|
-
import { getPersistedRenderSettings } from "./renders/renderSettings";
|
|
10
|
-
import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
|
|
11
|
-
|
|
12
|
-
export interface StudioLeftSidebarProps {
|
|
13
|
-
leftSidebarRef: RefObject<LeftSidebarHandle | null>;
|
|
14
|
-
onSelectComposition: (comp: string) => void;
|
|
15
|
-
onAddBlock: (blockName: string) => void;
|
|
16
|
-
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
|
17
|
-
onLint: () => void;
|
|
18
|
-
linting: boolean;
|
|
19
|
-
lintFindingCount?: number;
|
|
20
|
-
lintHasError?: boolean;
|
|
21
|
-
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
|
22
|
-
onAddAssetToTimeline?: (path: string) => void;
|
|
23
|
-
onAddCompositionToTimeline?: (path: string) => void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// fallow-ignore-next-line complexity
|
|
27
|
-
export function StudioLeftSidebar({
|
|
28
|
-
leftSidebarRef,
|
|
29
|
-
onSelectComposition,
|
|
30
|
-
onAddBlock,
|
|
31
|
-
onPreviewBlock,
|
|
32
|
-
onLint,
|
|
33
|
-
linting,
|
|
34
|
-
lintFindingCount,
|
|
35
|
-
lintHasError,
|
|
36
|
-
lintFindingsByFile,
|
|
37
|
-
onAddAssetToTimeline,
|
|
38
|
-
onAddCompositionToTimeline,
|
|
39
|
-
}: StudioLeftSidebarProps) {
|
|
40
|
-
const {
|
|
41
|
-
effectiveLeftCollapsed,
|
|
42
|
-
leftWidth,
|
|
43
|
-
adjustPanelWidth,
|
|
44
|
-
toggleLeftSidebar,
|
|
45
|
-
handlePanelResizeStart,
|
|
46
|
-
handlePanelResizeMove,
|
|
47
|
-
handlePanelResizeEnd,
|
|
48
|
-
setRightPanelTab,
|
|
49
|
-
setRightCollapsed,
|
|
50
|
-
} = usePanelLayoutContext();
|
|
51
|
-
const { projectId, renderQueue, waitForPendingDomEditSaves } = useStudioShellContext();
|
|
52
|
-
const {
|
|
53
|
-
compositions,
|
|
54
|
-
assets,
|
|
55
|
-
editingFile,
|
|
56
|
-
fileTree,
|
|
57
|
-
revealSourceOffset,
|
|
58
|
-
handleFileSelect,
|
|
59
|
-
handleCreateFile,
|
|
60
|
-
handleCreateFolder,
|
|
61
|
-
handleDeleteFile,
|
|
62
|
-
handleRenameFile,
|
|
63
|
-
handleDuplicateFile,
|
|
64
|
-
handleMoveFile,
|
|
65
|
-
handleImportFiles,
|
|
66
|
-
handleContentChange,
|
|
67
|
-
} = useFileManagerContext();
|
|
68
|
-
|
|
69
|
-
const handleRenderComposition = useCallback(
|
|
70
|
-
async (comp: string) => {
|
|
71
|
-
// startRender refuses without an encoder, so nothing unfinishable gets
|
|
72
|
-
// queued either way. What it cannot do from here is show the reason:
|
|
73
|
-
// its refusal lands as a row in the Renders panel, which may be
|
|
74
|
-
// collapsed or on another tab, so the click would look like nothing
|
|
75
|
-
// happened. Same move the header makes: put the prompt in front of the
|
|
76
|
-
// user, then stop.
|
|
77
|
-
if (renderQueue.ffmpegMissing) {
|
|
78
|
-
setRightPanelTab("renders");
|
|
79
|
-
setRightCollapsed(false);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
await waitForPendingDomEditSaves();
|
|
83
|
-
const { format, quality, fps } = getPersistedRenderSettings();
|
|
84
|
-
await renderQueue.startRender({ composition: comp, format, quality, fps });
|
|
85
|
-
},
|
|
86
|
-
[renderQueue, waitForPendingDomEditSaves, setRightPanelTab, setRightCollapsed],
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
if (effectiveLeftCollapsed) {
|
|
90
|
-
return (
|
|
91
|
-
<div className="mr-0.5 flex w-10 shrink-0 flex-col items-center rounded-lg border border-neutral-800/50 bg-neutral-950 pt-1">
|
|
92
|
-
<button
|
|
93
|
-
type="button"
|
|
94
|
-
onClick={toggleLeftSidebar}
|
|
95
|
-
className="flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
|
|
96
|
-
title="Show sidebar"
|
|
97
|
-
aria-label="Show sidebar"
|
|
98
|
-
>
|
|
99
|
-
<svg
|
|
100
|
-
width="14"
|
|
101
|
-
height="14"
|
|
102
|
-
viewBox="0 0 24 24"
|
|
103
|
-
fill="none"
|
|
104
|
-
stroke="currentColor"
|
|
105
|
-
strokeWidth="1.5"
|
|
106
|
-
strokeLinecap="round"
|
|
107
|
-
strokeLinejoin="round"
|
|
108
|
-
aria-hidden="true"
|
|
109
|
-
>
|
|
110
|
-
<path d="M5 4v16" />
|
|
111
|
-
<path d="m10 7 5 5-5 5" />
|
|
112
|
-
</svg>
|
|
113
|
-
</button>
|
|
114
|
-
</div>
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return (
|
|
119
|
-
<>
|
|
120
|
-
<LeftSidebar
|
|
121
|
-
ref={leftSidebarRef}
|
|
122
|
-
width={leftWidth}
|
|
123
|
-
projectId={projectId}
|
|
124
|
-
compositions={compositions}
|
|
125
|
-
assets={assets}
|
|
126
|
-
activeComposition={editingFile?.path ?? null}
|
|
127
|
-
onSelectComposition={onSelectComposition}
|
|
128
|
-
fileTree={fileTree}
|
|
129
|
-
editingFile={editingFile}
|
|
130
|
-
onSelectFile={handleFileSelect}
|
|
131
|
-
onCreateFile={handleCreateFile}
|
|
132
|
-
onCreateFolder={handleCreateFolder}
|
|
133
|
-
onDeleteFile={handleDeleteFile}
|
|
134
|
-
onRenameFile={handleRenameFile}
|
|
135
|
-
onDuplicateFile={handleDuplicateFile}
|
|
136
|
-
onMoveFile={handleMoveFile}
|
|
137
|
-
onImportFiles={async (files, dir) => {
|
|
138
|
-
await handleImportFiles(files, dir);
|
|
139
|
-
}}
|
|
140
|
-
codeChildren={
|
|
141
|
-
editingFile ? (
|
|
142
|
-
isMediaFile(editingFile.path) ? (
|
|
143
|
-
<MediaPreview projectId={projectId} filePath={editingFile.path} />
|
|
144
|
-
) : editingFile.content == null ? (
|
|
145
|
-
// Never mount the editor on unloaded content: a keystroke would
|
|
146
|
-
// autosave an empty document over the real file.
|
|
147
|
-
<div className="flex h-full items-center justify-center text-[11px] text-neutral-600">
|
|
148
|
-
Loading {editingFile.path}…
|
|
149
|
-
</div>
|
|
150
|
-
) : (
|
|
151
|
-
<SourceEditor
|
|
152
|
-
content={editingFile.content}
|
|
153
|
-
filePath={editingFile.path}
|
|
154
|
-
onChange={handleContentChange}
|
|
155
|
-
revealOffset={revealSourceOffset}
|
|
156
|
-
/>
|
|
157
|
-
)
|
|
158
|
-
) : undefined
|
|
159
|
-
}
|
|
160
|
-
onRenderComposition={handleRenderComposition}
|
|
161
|
-
isRendering={renderQueue.isRendering}
|
|
162
|
-
onLint={onLint}
|
|
163
|
-
linting={linting}
|
|
164
|
-
lintFindingCount={lintFindingCount}
|
|
165
|
-
lintHasError={lintHasError}
|
|
166
|
-
lintFindingsByFile={lintFindingsByFile}
|
|
167
|
-
onToggleCollapse={toggleLeftSidebar}
|
|
168
|
-
onAddBlock={onAddBlock}
|
|
169
|
-
onPreviewBlock={onPreviewBlock}
|
|
170
|
-
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
171
|
-
onAddCompositionToTimeline={onAddCompositionToTimeline}
|
|
172
|
-
/>
|
|
173
|
-
{/* Vertical resize divider: 3px visible seam, 13px pointer-capture zone via
|
|
174
|
-
the absolutely-positioned inner hit area. The outer element is w-[3px] so
|
|
175
|
-
it contributes only 3px of gap in the flex row; the inner left-[-2px]
|
|
176
|
-
element widens the hit area without affecting layout. */}
|
|
177
|
-
<div
|
|
178
|
-
role="separator"
|
|
179
|
-
aria-label="Resize sidebar"
|
|
180
|
-
aria-orientation="vertical"
|
|
181
|
-
tabIndex={0}
|
|
182
|
-
className="group relative w-[3px] shrink-0 cursor-col-resize outline-hidden focus-visible:bg-studio-accent/20"
|
|
183
|
-
style={{ touchAction: "none" }}
|
|
184
|
-
onPointerDown={(e) => handlePanelResizeStart("left", e)}
|
|
185
|
-
onPointerMove={handlePanelResizeMove}
|
|
186
|
-
onPointerUp={handlePanelResizeEnd}
|
|
187
|
-
onPointerCancel={handlePanelResizeEnd}
|
|
188
|
-
onKeyDown={(e) => {
|
|
189
|
-
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
190
|
-
e.preventDefault();
|
|
191
|
-
const delta = e.key === "ArrowLeft" ? -16 : 16;
|
|
192
|
-
adjustPanelWidth("left", delta);
|
|
193
|
-
}}
|
|
194
|
-
>
|
|
195
|
-
{/* Expanded hit zone, deliberately asymmetric: 2px into the sidebar card,
|
|
196
|
-
the 3px seam, then 8px into the preview pane's p-2 stage gutter — the
|
|
197
|
-
only dead space adjacent to this seam. It stops at 13px rather than the
|
|
198
|
-
24px WCAG 2.2 (2.5.8) target because the next pixel on either side is
|
|
199
|
-
live: the sidebar's scrolling tab content on the left, the preview
|
|
200
|
-
stage on the right. Silently stealing their clicks is the worse bug. */}
|
|
201
|
-
<div className="absolute inset-y-0 left-[-2px] w-[13px]" />
|
|
202
|
-
{/* Visible hairline */}
|
|
203
|
-
<div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
204
|
-
</div>
|
|
205
|
-
</>
|
|
206
|
-
);
|
|
207
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { useCallback, useRef } from "react";
|
|
2
|
-
import { MIN_PREVIEW_H, MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Horizontal drag/keyboard-resizable divider between the preview and the
|
|
6
|
-
* timeline. Implements the separator pattern: ArrowUp grows the timeline,
|
|
7
|
-
* ArrowDown shrinks it (mirrors the drag direction).
|
|
8
|
-
*/
|
|
9
|
-
export function TimelineResizeDivider({
|
|
10
|
-
timelineH,
|
|
11
|
-
setTimelineH,
|
|
12
|
-
persistTimelineH,
|
|
13
|
-
containerRef,
|
|
14
|
-
disabled,
|
|
15
|
-
}: {
|
|
16
|
-
timelineH: number;
|
|
17
|
-
setTimelineH: React.Dispatch<React.SetStateAction<number>>;
|
|
18
|
-
persistTimelineH: (h: number) => void;
|
|
19
|
-
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
20
|
-
disabled: boolean;
|
|
21
|
-
}) {
|
|
22
|
-
const isDragging = useRef(false);
|
|
23
|
-
const timelineHRef = useRef(timelineH);
|
|
24
|
-
timelineHRef.current = timelineH;
|
|
25
|
-
|
|
26
|
-
const handlePointerDown = useCallback(
|
|
27
|
-
(e: React.PointerEvent) => {
|
|
28
|
-
if (disabled) return;
|
|
29
|
-
e.preventDefault();
|
|
30
|
-
isDragging.current = true;
|
|
31
|
-
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
32
|
-
},
|
|
33
|
-
[disabled],
|
|
34
|
-
);
|
|
35
|
-
|
|
36
|
-
const handlePointerMove = useCallback(
|
|
37
|
-
(e: React.PointerEvent) => {
|
|
38
|
-
if (disabled) return;
|
|
39
|
-
if (!isDragging.current || !containerRef.current) return;
|
|
40
|
-
const rect = containerRef.current.getBoundingClientRect();
|
|
41
|
-
const mouseY = e.clientY - rect.top;
|
|
42
|
-
setTimelineH(fitTimelineHeight(rect.height, rect.height - mouseY));
|
|
43
|
-
},
|
|
44
|
-
[disabled, containerRef, setTimelineH],
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
const handlePointerUp = useCallback(() => {
|
|
48
|
-
if (isDragging.current) persistTimelineH(timelineHRef.current);
|
|
49
|
-
isDragging.current = false;
|
|
50
|
-
}, [persistTimelineH]);
|
|
51
|
-
|
|
52
|
-
const handleKeyDown = useCallback(
|
|
53
|
-
(e: React.KeyboardEvent) => {
|
|
54
|
-
if (disabled) return;
|
|
55
|
-
if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
56
|
-
e.preventDefault();
|
|
57
|
-
const containerH = containerRef.current?.getBoundingClientRect().height ?? 0;
|
|
58
|
-
const delta = e.key === "ArrowUp" ? 16 : -16;
|
|
59
|
-
setTimelineH((prev) => {
|
|
60
|
-
const next = fitTimelineHeight(containerH, prev + delta);
|
|
61
|
-
persistTimelineH(next);
|
|
62
|
-
return next;
|
|
63
|
-
});
|
|
64
|
-
},
|
|
65
|
-
[disabled, containerRef, setTimelineH, persistTimelineH],
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
return (
|
|
69
|
-
// Horizontal resize divider: 3px visible seam (h-[3px]), 10px pointer-capture
|
|
70
|
-
// zone via the absolutely-positioned inner hit area so the layout gap stays
|
|
71
|
-
// at 3px while draggability is preserved over the full band.
|
|
72
|
-
<div
|
|
73
|
-
role="separator"
|
|
74
|
-
aria-orientation="horizontal"
|
|
75
|
-
aria-label="Resize timeline (arrow keys)"
|
|
76
|
-
aria-valuenow={Math.round(timelineH)}
|
|
77
|
-
aria-valuemin={MIN_TIMELINE_H}
|
|
78
|
-
aria-valuemax={Math.round(
|
|
79
|
-
(containerRef.current?.getBoundingClientRect().height ?? 600) - MIN_PREVIEW_H,
|
|
80
|
-
)}
|
|
81
|
-
tabIndex={0}
|
|
82
|
-
className="group relative h-[3px] shrink-0 cursor-row-resize z-10 outline-hidden focus-visible:bg-studio-accent/20"
|
|
83
|
-
style={{ touchAction: "none" }}
|
|
84
|
-
onPointerDown={handlePointerDown}
|
|
85
|
-
onPointerMove={handlePointerMove}
|
|
86
|
-
onPointerUp={handlePointerUp}
|
|
87
|
-
onPointerCancel={handlePointerUp}
|
|
88
|
-
onKeyDown={handleKeyDown}
|
|
89
|
-
>
|
|
90
|
-
{/* Expanded hit zone, deliberately asymmetric: 4px up into the transport
|
|
91
|
-
row's vertical centring slack, the 3px seam, then 3px down into the
|
|
92
|
-
timeline card's border + toolbar padding. It stops at 10px rather than
|
|
93
|
-
the 24px WCAG 2.2 (2.5.8) target because that is all the dead space
|
|
94
|
-
there is: 28px transport buttons sit above and 28px toolbar buttons
|
|
95
|
-
below, and silently stealing their clicks is the worse bug. */}
|
|
96
|
-
<div className="absolute inset-x-0 top-[-4px] h-[10px]" />
|
|
97
|
-
{/* Visible hairline — invisible at rest, subtle wash on hover/drag/focus */}
|
|
98
|
-
<div className="h-[3px] w-full bg-transparent transition-colors group-hover:bg-white/12 group-active:bg-white/18 group-focus-visible:bg-studio-accent/60" />
|
|
99
|
-
</div>
|
|
100
|
-
);
|
|
101
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// @vitest-environment happy-dom
|
|
2
|
-
|
|
3
|
-
import { afterEach, expect, it, vi } from "vitest";
|
|
4
|
-
import { getPersistedTab } from "./LeftSidebar";
|
|
5
|
-
|
|
6
|
-
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
7
|
-
|
|
8
|
-
const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
|
9
|
-
|
|
10
|
-
afterEach(() => {
|
|
11
|
-
if (originalDescriptor) Object.defineProperty(globalThis, "localStorage", originalDescriptor);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("falls back to the default tab when localStorage is blocked", () => {
|
|
15
|
-
// Chrome throws on the property read itself when site data is blocked for
|
|
16
|
-
// the document. This runs as a useState initializer, so throwing here takes
|
|
17
|
-
// Studio to the crash boundary.
|
|
18
|
-
Object.defineProperty(globalThis, "localStorage", {
|
|
19
|
-
configurable: true,
|
|
20
|
-
get() {
|
|
21
|
-
throw new Error(
|
|
22
|
-
"Failed to read the 'localStorage' property from 'Window': Access is denied for this document.",
|
|
23
|
-
);
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
expect(getPersistedTab()).toBe("compositions");
|
|
28
|
-
});
|
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
memo,
|
|
3
|
-
useState,
|
|
4
|
-
useCallback,
|
|
5
|
-
useImperativeHandle,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
forwardRef,
|
|
9
|
-
type ReactNode,
|
|
10
|
-
} from "react";
|
|
11
|
-
import { CompositionsTab } from "./CompositionsTab";
|
|
12
|
-
import { AssetsTab } from "./AssetsTab";
|
|
13
|
-
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
14
|
-
import { safeLocalStorage } from "../../utils/safeStorage";
|
|
15
|
-
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
|
|
16
|
-
import { BlocksTab, type BlockPreviewInfo } from "./BlocksTab";
|
|
17
|
-
import { FileTree } from "../editor/FileTree";
|
|
18
|
-
import { Tooltip } from "../ui";
|
|
19
|
-
|
|
20
|
-
export type SidebarTab = "compositions" | "assets" | "code" | "blocks";
|
|
21
|
-
|
|
22
|
-
export interface LeftSidebarHandle {
|
|
23
|
-
selectTab: (tab: SidebarTab) => void;
|
|
24
|
-
getTab: () => SidebarTab;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const STORAGE_KEY = "hf-studio-sidebar-tab";
|
|
28
|
-
|
|
29
|
-
const SIDEBAR_TABS: Array<{ id: SidebarTab; label: string; tooltip: string }> = [
|
|
30
|
-
{ id: "code", label: "Code", tooltip: "Source code editor" },
|
|
31
|
-
{ id: "compositions", label: "Comps", tooltip: "Compositions and sub-compositions" },
|
|
32
|
-
{ id: "assets", label: "Assets", tooltip: "Videos, images, audio, fonts" },
|
|
33
|
-
{ id: "blocks", label: "Catalog", tooltip: "Browse blocks and components" },
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
// Both the `localStorage` reference and `getItem` itself can throw when the
|
|
37
|
-
// browsing context is partitioned or site data is blocked — the same case
|
|
38
|
-
// telemetry/config.ts documents. This runs as a `useState` initializer, so an
|
|
39
|
-
// unguarded throw here takes the whole editor to the crash boundary rather
|
|
40
|
-
// than losing one remembered tab.
|
|
41
|
-
export function getPersistedTab(): SidebarTab {
|
|
42
|
-
let stored: string | null = null;
|
|
43
|
-
try {
|
|
44
|
-
stored = safeLocalStorage()?.getItem(STORAGE_KEY) ?? null;
|
|
45
|
-
} catch {
|
|
46
|
-
/* storage unavailable — fall back to the default tab */
|
|
47
|
-
}
|
|
48
|
-
if (stored === "assets") return "assets";
|
|
49
|
-
if (stored === "code") return "code";
|
|
50
|
-
if (stored === "blocks") return "blocks";
|
|
51
|
-
return "compositions";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interface LeftSidebarProps {
|
|
55
|
-
width?: number;
|
|
56
|
-
projectId: string;
|
|
57
|
-
compositions: string[];
|
|
58
|
-
assets: string[];
|
|
59
|
-
activeComposition: string | null;
|
|
60
|
-
onSelectComposition: (comp: string) => void;
|
|
61
|
-
onImportFiles?: (files: FileList, dir?: string) => void | Promise<void>;
|
|
62
|
-
fileTree?: string[];
|
|
63
|
-
editingFile?: { path: string; content: string | null } | null;
|
|
64
|
-
onSelectFile?: (path: string) => void;
|
|
65
|
-
onCreateFile?: (path: string) => void;
|
|
66
|
-
onCreateFolder?: (path: string) => void;
|
|
67
|
-
onDeleteFile?: (path: string) => void;
|
|
68
|
-
onRenameFile?: (oldPath: string, newPath: string) => void;
|
|
69
|
-
onDuplicateFile?: (path: string) => void;
|
|
70
|
-
onMoveFile?: (oldPath: string, newPath: string) => void;
|
|
71
|
-
codeChildren?: ReactNode;
|
|
72
|
-
onRenderComposition?: (comp: string) => void;
|
|
73
|
-
isRendering?: boolean;
|
|
74
|
-
onLint?: () => void;
|
|
75
|
-
linting?: boolean;
|
|
76
|
-
lintFindingCount?: number;
|
|
77
|
-
lintHasError?: boolean;
|
|
78
|
-
lintFindingsByFile?: Map<string, { count: number; messages: string[] }>;
|
|
79
|
-
onToggleCollapse?: () => void;
|
|
80
|
-
onAddBlock?: (blockName: string) => void | Promise<void>;
|
|
81
|
-
onPreviewBlock?: (preview: BlockPreviewInfo | null) => void;
|
|
82
|
-
takeoverContent?: ReactNode;
|
|
83
|
-
onAddAssetToTimeline?: (path: string) => void;
|
|
84
|
-
onAddCompositionToTimeline?: (path: string) => void;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export const LeftSidebar = memo(
|
|
88
|
-
forwardRef<LeftSidebarHandle, LeftSidebarProps>(function LeftSidebar(
|
|
89
|
-
{
|
|
90
|
-
width = 240,
|
|
91
|
-
projectId,
|
|
92
|
-
compositions,
|
|
93
|
-
assets,
|
|
94
|
-
activeComposition,
|
|
95
|
-
onSelectComposition,
|
|
96
|
-
onImportFiles,
|
|
97
|
-
fileTree: fileProp,
|
|
98
|
-
editingFile,
|
|
99
|
-
onSelectFile,
|
|
100
|
-
onCreateFile,
|
|
101
|
-
onCreateFolder,
|
|
102
|
-
onDeleteFile,
|
|
103
|
-
onRenameFile,
|
|
104
|
-
onDuplicateFile,
|
|
105
|
-
onMoveFile,
|
|
106
|
-
codeChildren,
|
|
107
|
-
onRenderComposition,
|
|
108
|
-
isRendering,
|
|
109
|
-
onLint,
|
|
110
|
-
linting,
|
|
111
|
-
lintFindingCount,
|
|
112
|
-
lintHasError,
|
|
113
|
-
lintFindingsByFile,
|
|
114
|
-
onToggleCollapse,
|
|
115
|
-
onAddBlock,
|
|
116
|
-
onPreviewBlock,
|
|
117
|
-
takeoverContent,
|
|
118
|
-
onAddAssetToTimeline,
|
|
119
|
-
onAddCompositionToTimeline,
|
|
120
|
-
},
|
|
121
|
-
ref,
|
|
122
|
-
) {
|
|
123
|
-
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
|
124
|
-
const tabRef = useRef(tab);
|
|
125
|
-
tabRef.current = tab;
|
|
126
|
-
const tablistRef = useRef<HTMLDivElement>(null);
|
|
127
|
-
const masterCompositionPath = useMemo(
|
|
128
|
-
() => resolveMasterCompositionPath(compositions),
|
|
129
|
-
[compositions],
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const selectTab = useCallback((t: SidebarTab) => {
|
|
133
|
-
setTab(t);
|
|
134
|
-
try {
|
|
135
|
-
safeLocalStorage()?.setItem(STORAGE_KEY, t);
|
|
136
|
-
} catch {
|
|
137
|
-
/* storage unavailable — the tab just won't be remembered */
|
|
138
|
-
}
|
|
139
|
-
trackStudioEvent("tab_switch", { panel: "left_sidebar", tab: t });
|
|
140
|
-
}, []);
|
|
141
|
-
|
|
142
|
-
const getTab = useCallback(() => tabRef.current, []);
|
|
143
|
-
|
|
144
|
-
useImperativeHandle(ref, () => ({ selectTab, getTab }), [selectTab, getTab]);
|
|
145
|
-
|
|
146
|
-
// APG tabs pattern: Left/Right move focus AND selection between tabs.
|
|
147
|
-
const handleTablistKeyDown = useCallback(
|
|
148
|
-
(e: React.KeyboardEvent) => {
|
|
149
|
-
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
150
|
-
e.preventDefault();
|
|
151
|
-
const ids = SIDEBAR_TABS.map((t) => t.id);
|
|
152
|
-
const idx = ids.indexOf(tabRef.current);
|
|
153
|
-
const next = ids[(idx + (e.key === "ArrowRight" ? 1 : -1) + ids.length) % ids.length];
|
|
154
|
-
selectTab(next);
|
|
155
|
-
tablistRef.current?.querySelector<HTMLButtonElement>(`[data-tab-id="${next}"]`)?.focus();
|
|
156
|
-
},
|
|
157
|
-
[selectTab],
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
return (
|
|
161
|
-
<div
|
|
162
|
-
className="flex flex-col h-full overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
|
|
163
|
-
style={{ width }}
|
|
164
|
-
>
|
|
165
|
-
{takeoverContent ? (
|
|
166
|
-
<div className="flex min-h-0 flex-1">{takeoverContent}</div>
|
|
167
|
-
) : (
|
|
168
|
-
<>
|
|
169
|
-
{/* Tabs — Code first */}
|
|
170
|
-
<div className="border-b border-neutral-800/50 px-3 py-3 shrink-0">
|
|
171
|
-
<div className="flex items-center gap-2">
|
|
172
|
-
<div
|
|
173
|
-
ref={tablistRef}
|
|
174
|
-
role="tablist"
|
|
175
|
-
aria-label="Sidebar panels"
|
|
176
|
-
onKeyDown={handleTablistKeyDown}
|
|
177
|
-
className="grid min-w-0 flex-1 gap-0.5 rounded-[18px] bg-neutral-900 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.03)]"
|
|
178
|
-
style={{ gridTemplateColumns: "1fr 1fr 1fr 1fr" }}
|
|
179
|
-
>
|
|
180
|
-
{SIDEBAR_TABS.map((t) => (
|
|
181
|
-
<Tooltip key={t.id} label={t.tooltip} side="bottom">
|
|
182
|
-
<button
|
|
183
|
-
type="button"
|
|
184
|
-
role="tab"
|
|
185
|
-
data-tab-id={t.id}
|
|
186
|
-
aria-selected={tab === t.id}
|
|
187
|
-
aria-controls={`sidebar-panel-${t.id}`}
|
|
188
|
-
tabIndex={tab === t.id ? 0 : -1}
|
|
189
|
-
onClick={() => selectTab(t.id)}
|
|
190
|
-
className={`rounded-[14px] px-1.5 py-2 text-[10px] font-semibold truncate transition-all active:scale-[0.97] ${
|
|
191
|
-
tab === t.id
|
|
192
|
-
? "bg-neutral-800 text-white"
|
|
193
|
-
: "text-neutral-500 hover:text-neutral-200"
|
|
194
|
-
}`}
|
|
195
|
-
>
|
|
196
|
-
{t.label}
|
|
197
|
-
</button>
|
|
198
|
-
</Tooltip>
|
|
199
|
-
))}
|
|
200
|
-
</div>
|
|
201
|
-
{onToggleCollapse && (
|
|
202
|
-
<button
|
|
203
|
-
type="button"
|
|
204
|
-
onClick={onToggleCollapse}
|
|
205
|
-
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-transparent text-neutral-500 transition-colors hover:border-neutral-800 hover:bg-neutral-900 hover:text-neutral-300"
|
|
206
|
-
title="Hide sidebar"
|
|
207
|
-
aria-label="Hide sidebar"
|
|
208
|
-
>
|
|
209
|
-
<svg
|
|
210
|
-
width="14"
|
|
211
|
-
height="14"
|
|
212
|
-
viewBox="0 0 24 24"
|
|
213
|
-
fill="none"
|
|
214
|
-
stroke="currentColor"
|
|
215
|
-
strokeWidth="1.5"
|
|
216
|
-
strokeLinecap="round"
|
|
217
|
-
strokeLinejoin="round"
|
|
218
|
-
aria-hidden="true"
|
|
219
|
-
>
|
|
220
|
-
<path d="m14 7-5 5 5 5" />
|
|
221
|
-
<path d="M19 4v16" />
|
|
222
|
-
</svg>
|
|
223
|
-
</button>
|
|
224
|
-
)}
|
|
225
|
-
</div>
|
|
226
|
-
</div>
|
|
227
|
-
|
|
228
|
-
{/* Tab content */}
|
|
229
|
-
{tab === "compositions" && (
|
|
230
|
-
<div
|
|
231
|
-
id="sidebar-panel-compositions"
|
|
232
|
-
role="tabpanel"
|
|
233
|
-
className="flex flex-col flex-1 min-h-0"
|
|
234
|
-
>
|
|
235
|
-
<CompositionsTab
|
|
236
|
-
projectId={projectId}
|
|
237
|
-
compositions={compositions}
|
|
238
|
-
activeComposition={activeComposition}
|
|
239
|
-
masterCompositionPath={masterCompositionPath}
|
|
240
|
-
onSelect={onSelectComposition}
|
|
241
|
-
onAddToTimeline={onAddCompositionToTimeline}
|
|
242
|
-
onRenderComposition={onRenderComposition}
|
|
243
|
-
isRendering={isRendering}
|
|
244
|
-
lintFindingsByFile={lintFindingsByFile}
|
|
245
|
-
/>
|
|
246
|
-
</div>
|
|
247
|
-
)}
|
|
248
|
-
{tab === "assets" && (
|
|
249
|
-
<div
|
|
250
|
-
id="sidebar-panel-assets"
|
|
251
|
-
role="tabpanel"
|
|
252
|
-
className="flex flex-col flex-1 min-h-0"
|
|
253
|
-
>
|
|
254
|
-
<AssetsTab
|
|
255
|
-
projectId={projectId}
|
|
256
|
-
assets={assets}
|
|
257
|
-
onImport={onImportFiles}
|
|
258
|
-
onDelete={onDeleteFile}
|
|
259
|
-
onRename={onRenameFile}
|
|
260
|
-
onAddAssetToTimeline={onAddAssetToTimeline}
|
|
261
|
-
/>
|
|
262
|
-
</div>
|
|
263
|
-
)}
|
|
264
|
-
{tab === "code" && (
|
|
265
|
-
<div id="sidebar-panel-code" role="tabpanel" className="flex flex-1 min-h-0">
|
|
266
|
-
{(fileProp?.length ?? 0) > 0 && (
|
|
267
|
-
<div className="w-[160px] shrink-0 border-r border-neutral-800 overflow-y-auto">
|
|
268
|
-
<FileTree
|
|
269
|
-
files={fileProp ?? []}
|
|
270
|
-
activeFile={editingFile?.path ?? null}
|
|
271
|
-
onSelectFile={onSelectFile ?? (() => {})}
|
|
272
|
-
onCreateFile={onCreateFile}
|
|
273
|
-
onCreateFolder={onCreateFolder}
|
|
274
|
-
onDeleteFile={onDeleteFile}
|
|
275
|
-
onRenameFile={onRenameFile}
|
|
276
|
-
onDuplicateFile={onDuplicateFile}
|
|
277
|
-
onMoveFile={onMoveFile}
|
|
278
|
-
onImportFiles={onImportFiles}
|
|
279
|
-
lintFindingsByFile={lintFindingsByFile}
|
|
280
|
-
/>
|
|
281
|
-
</div>
|
|
282
|
-
)}
|
|
283
|
-
<div className="flex-1 overflow-hidden min-w-0">
|
|
284
|
-
{codeChildren ?? (
|
|
285
|
-
<div className="flex items-center justify-center h-full text-neutral-600 text-sm">
|
|
286
|
-
Select a file to edit
|
|
287
|
-
</div>
|
|
288
|
-
)}
|
|
289
|
-
</div>
|
|
290
|
-
</div>
|
|
291
|
-
)}
|
|
292
|
-
|
|
293
|
-
{tab === "blocks" && (
|
|
294
|
-
<div
|
|
295
|
-
id="sidebar-panel-blocks"
|
|
296
|
-
role="tabpanel"
|
|
297
|
-
className="flex flex-col flex-1 min-h-0"
|
|
298
|
-
>
|
|
299
|
-
<BlocksTab onAddBlock={onAddBlock} onPreviewBlock={onPreviewBlock} />
|
|
300
|
-
</div>
|
|
301
|
-
)}
|
|
302
|
-
|
|
303
|
-
{/* Lint button pinned at the bottom */}
|
|
304
|
-
{onLint && (
|
|
305
|
-
<div className="border-t border-neutral-800 p-2 shrink-0">
|
|
306
|
-
<button
|
|
307
|
-
onClick={onLint}
|
|
308
|
-
disabled={linting}
|
|
309
|
-
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 enabled:hover:text-amber-300 enabled:hover:bg-neutral-800 enabled:active:scale-[0.98] transition-colors disabled:opacity-40"
|
|
310
|
-
>
|
|
311
|
-
<svg
|
|
312
|
-
width="12"
|
|
313
|
-
height="12"
|
|
314
|
-
viewBox="0 0 24 24"
|
|
315
|
-
fill="none"
|
|
316
|
-
stroke="currentColor"
|
|
317
|
-
strokeWidth="2"
|
|
318
|
-
>
|
|
319
|
-
<path d="M9 11l3 3L22 4" />
|
|
320
|
-
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
|
321
|
-
</svg>
|
|
322
|
-
{linting ? "Linting…" : "Lint"}
|
|
323
|
-
{!linting && lintFindingCount != null && lintFindingCount > 0 && (
|
|
324
|
-
<span
|
|
325
|
-
data-lint-badge={lintHasError ? "error" : "warning"}
|
|
326
|
-
className={
|
|
327
|
-
lintHasError
|
|
328
|
-
? "ml-1 min-w-[16px] rounded-full bg-panel-danger/25 px-1 text-[9px] font-bold text-panel-danger animate-pulse motion-reduce:animate-none"
|
|
329
|
-
: "ml-1 min-w-[16px] rounded-full bg-amber-500/20 px-1 text-[9px] font-bold text-amber-400"
|
|
330
|
-
}
|
|
331
|
-
>
|
|
332
|
-
{lintFindingCount}
|
|
333
|
-
<span className="sr-only">
|
|
334
|
-
{lintHasError
|
|
335
|
-
? " lint findings, including errors"
|
|
336
|
-
: " lint findings, warnings only"}
|
|
337
|
-
</span>
|
|
338
|
-
</span>
|
|
339
|
-
)}
|
|
340
|
-
</button>
|
|
341
|
-
</div>
|
|
342
|
-
)}
|
|
343
|
-
</>
|
|
344
|
-
)}
|
|
345
|
-
</div>
|
|
346
|
-
);
|
|
347
|
-
}),
|
|
348
|
-
);
|