@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.
Files changed (75) hide show
  1. package/dist/assets/{hyperframes-player-I5YRzB02.js → hyperframes-player-DKWM-Ons.js} +1 -1
  2. package/dist/assets/index-DrHQAuGO.css +1 -0
  3. package/dist/assets/index-HfP3sap2.js +390 -0
  4. package/dist/assets/{index-BTAfgesM.js → index-UvW6BEwf.js} +1 -1
  5. package/dist/assets/{index-Du5x7KUR.js → index-Y4OnpTEM.js} +1 -1
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +13 -6
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +7664 -7953
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles.css +1 -0
  13. package/package.json +8 -7
  14. package/src/App.tsx +42 -48
  15. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  16. package/src/components/EditorShell.tsx +52 -56
  17. package/src/components/StudioHeader.dom.test.tsx +1 -1
  18. package/src/components/StudioHeader.test.ts +0 -12
  19. package/src/components/StudioHeader.tsx +10 -13
  20. package/src/components/StudioLeftPanels.tsx +202 -0
  21. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -206
  22. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  23. package/src/components/dock/Dock.test.tsx +16 -2
  24. package/src/components/dock/Dock.tsx +13 -3
  25. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  26. package/src/components/dock/dockLayoutStore.ts +21 -4
  27. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  28. package/src/components/nle/NLEContext.tsx +0 -43
  29. package/src/components/nle/TimelinePane.tsx +46 -67
  30. package/src/components/panels/SlideshowPanel.tsx +1 -1
  31. package/src/components/panels/VariablesPanel.tsx +1 -1
  32. package/src/components/renders/RenderQueue.test.tsx +133 -25
  33. package/src/components/renders/RenderQueue.tsx +99 -92
  34. package/src/components/renders/RenderQueueItem.tsx +69 -64
  35. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  36. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  37. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  38. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  39. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  40. package/src/components/ui/Select.tsx +4 -0
  41. package/src/contexts/PanelLayoutContext.tsx +3 -57
  42. package/src/headerExports.test.tsx +23 -0
  43. package/src/hooks/appHotkeysDispatch.ts +3 -4
  44. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  45. package/src/hooks/useAppHotkeys.test.ts +0 -1
  46. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  47. package/src/hooks/useAppHotkeys.ts +0 -4
  48. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  49. package/src/hooks/useDomEditSession.ts +0 -7
  50. package/src/hooks/useDomEditWiring.ts +4 -9
  51. package/src/hooks/usePanelLayout.test.ts +108 -296
  52. package/src/hooks/usePanelLayout.ts +50 -255
  53. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  54. package/src/hooks/useRightPanelIntents.ts +64 -0
  55. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  56. package/src/hooks/useSlideshowTabState.ts +6 -19
  57. package/src/hooks/useStudioContextValue.test.ts +6 -18
  58. package/src/hooks/useStudioContextValue.ts +5 -26
  59. package/src/index.ts +2 -1
  60. package/src/main.tsx +26 -0
  61. package/src/player/components/Timeline.tsx +1 -1
  62. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  63. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  64. package/src/utils/studioHelpers.ts +0 -7
  65. package/src/utils/studioUiPreferences.test.ts +10 -16
  66. package/src/utils/studioUiPreferences.ts +0 -16
  67. package/dist/assets/index-CxAu_Okg.css +0 -1
  68. package/dist/assets/index-DocJlW2v.js +0 -376
  69. package/src/components/StudioLeftSidebar.tsx +0 -207
  70. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  71. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  72. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  73. package/src/hooks/useInspectorSplitResize.ts +0 -51
  74. package/src/utils/fitPanels.test.ts +0 -164
  75. 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
- );