Files
hyperframes/packages/studio/src/hooks/useStudioContextValue.ts
T
Miguel Ángel cef3b86c95 chore(studio): remove fully rolled-out studio feature flags (#2889)
## What

Removes six Studio feature flags that have been default-`true` for 7+ weeks. Each is reachable under two env names, so this deletes **12 `VITE_STUDIO_*` env vars**:

| Flag constant | Env names removed | Default-on since |
|---|---|---|
| `STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | `VITE_STUDIO_ENABLE_PREVIEW_MANUAL_DRAGGING`, `VITE_STUDIO_PREVIEW_MANUAL_EDITING_ENABLED` | 2026-05-12 |
| `STUDIO_INSPECTOR_PANELS_ENABLED` (+ its `STUDIO_PREVIEW_SELECTION_ENABLED` alias) | `VITE_STUDIO_ENABLE_INSPECTOR_PANELS`, `VITE_STUDIO_INSPECTOR_PANELS_ENABLED` | 2026-05-12 |
| `STUDIO_BLOCKS_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_BLOCKS_PANEL`, `VITE_STUDIO_BLOCKS_PANEL_ENABLED` | 2026-05-18 |
| `STUDIO_GSAP_PANEL_ENABLED` | `VITE_STUDIO_ENABLE_GSAP_PANEL`, `VITE_STUDIO_GSAP_PANEL_ENABLED` | 2026-05-28 |
| `STUDIO_KEYFRAMES_ENABLED` | `VITE_STUDIO_ENABLE_KEYFRAMES`, `VITE_STUDIO_KEYFRAMES_ENABLED` | 2026-06-05 |
| `STUDIO_RAZOR_TOOL_ENABLED` | `VITE_STUDIO_ENABLE_RAZOR_TOOL`, `VITE_STUDIO_RAZOR_TOOL_ENABLED` | 2026-06-10 |

## Why

Every one of these shipped as a rollout gate, went to `true`, and then stayed. Because none of them was ever flipped back, the `false` branch was unreachable in practice while still costing a real import, a real conditional, and a real "what happens if this is off?" question at ~90 call sites across 25 files.

The bigger cost is what the dead branch kept alive. Removing the flags also removes the disabled-Studio code paths that only existed to serve them:

- the greyed-out, `disabled`, "Manual editing is temporarily disabled" Inspector button in `StudioHeader` (and the `STUDIO_MANUAL_EDITING_DISABLED_TITLE` constant behind it)
- the inspector-off reset `useEffect` in `useDomSelection`, which force-cleared selection and redirected the right panel to Renders
- three selection kill-switch early-returns in `useDomSelection` (`applyDomSelection`, `handleTimelineElementSelect`, `applyMarqueeSelection`)
- the tab-redirect branch in `normalizeStudioUrlPanelTab`, whose `options.inspectorPanelsEnabled` parameter had no production caller at all (only tests passed it)

## How

No behavior change: every flag was removed by keeping its default-`true` side.

The call-site edits are three mechanical boolean shapes (`X && rest` → `rest`, `rest && X` → `rest`, `!X || rest` → `rest`), applied by script for uniformity. Everything else (ternaries, `if` guards, unreachable blocks, JSX wrappers that had no other condition) was done by hand and the whole diff was read line by line afterwards.

`resolveStudioBooleanEnvFlag` and the `import.meta.env` / `window.__HF_STUDIO_ENV__` plumbing stay: three flags still use them (`STUDIO_FLAT_INSPECTOR_ENABLED`, `STUDIO_SDK_CUTOVER_ENABLED`, `STUDIO_SDK_RESOLVER_SHADOW_ENABLED`). Its unit tests kept their coverage but now exercise a live flag pair instead of retired env names, so no dead `VITE_STUDIO_*` string is left in the repo.

Net **-191 lines** (236 insertions, 427 deletions across 25 files); most insertions are reindentation of JSX that lost a wrapper.

### Deliberately not in scope

Flags authored by other people are untouched, even where they look similarly settled:

- `VITE_STUDIO_ENABLE_FLAT_INSPECTOR` / `VITE_STUDIO_FLAT_INSPECTOR_ENABLED` (default true, but not mine)
- `VITE_STUDIO_SDK_CUTOVER_ENABLED`, `VITE_STUDIO_SDK_CUTOVER_FAMILIES`, `VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (SDK cutover canary, still soaking)
- `VITE_HYPERFRAMES_NO_TELEMETRY`

Mine but genuinely long-lived configuration rather than rollout gates, so they stay: `VITE_STUDIO_DISCOVERY_PORTS`, `VITE_HYPERFRAMES_FEEDBACK_INTERVAL`, `VITE_HYPERFRAMES_NO_FEEDBACK` (a documented user opt-out), plus the `HYPERFRAMES_*` binary paths, API URLs, cache sizes, and timeouts.

`VITE_STUDIO_ENABLE_MOTION_PANEL` / `VITE_STUDIO_MOTION_PANEL_ENABLED` were already retired from production code before this PR; they only survived as placeholder names inside the resolver's unit tests, and this PR swaps those out.

## Test plan

- [x] Unit tests added/updated - dropped the two tests asserting removed flag defaults; retargeted the `resolveStudioBooleanEnvFlag` cases at a live flag pair; updated `studioUrlState` tests for the narrowed `normalizeStudioUrlPanelTab` signature (now also asserts an unknown tab returns `null`).
- [x] Manual testing performed - see below.
- [ ] Documentation updated (if applicable) - not needed; no removed name appears in `docs/`, `skills/`, or `registry/`. (`docs/changelog.mdx` has one historical entry naming `STUDIO_KEYFRAMES_ENABLED`; changelog history is left as written.)

```
packages/studio: bunx vitest run          # 280 files, 3116 tests pass, 1 skipped
packages/studio: bunx tsc --noEmit        # clean
bun run build                             # green (all packages)
bunx oxlint  <25 changed files>           # 0 warnings, 0 errors
bunx oxfmt --check <25 changed files>     # clean
```

Two extra checks, because part of this diff was script-generated:

1. Zero references to any removed flag constant or env name remain anywhere outside `docs/changelog.mdx`.
2. Diffed every string literal in each changed non-test file against `origin/main`. The only differences are the intended removals: the 12 env names, `"Manual editing is temporarily disabled"`, the `"cursor-not-allowed …"` disabled class, the 3-column `"1fr 1fr 1fr"` grid, and the `"renders"` redirect literals. No user-facing label, tooltip, or class string changed by accident.
2026-07-30 02:00:05 +02:00

160 lines
5.9 KiB
TypeScript

import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { StudioContextValue } from "../contexts/StudioContext";
import type { RightInspectorPanes } from "../utils/studioHelpers";
import type { TimelineFileDropHandler } from "./useTimelineEditingTypes";
import { usePlayerStore } from "../player";
interface StudioContextInput {
projectId: string;
activeCompPath: string | null;
setActiveCompPath: (path: string | null) => void;
showToast: (message: string, tone?: "error" | "info") => void;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
captionEditMode: boolean;
compositionLoading: boolean;
refreshKey: number;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
timelineElements: StudioContextValue["timelineElements"];
isPlaying: boolean;
editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
handleUndo: StudioContextValue["handleUndo"];
handleRedo: StudioContextValue["handleRedo"];
renderQueue: {
jobs: unknown[];
isRendering: boolean;
loadError: string | null;
actionError: string | null;
dismissActionError: () => void;
reloadRenders: () => void;
deleteRender: (id: string) => void;
cancelRender: (id: string) => void;
clearCompleted: () => void;
startRender: (options: unknown) => Promise<void>;
};
compositionDimensions: { width: number; height: number } | null;
waitForPendingDomEditSaves: () => Promise<void>;
handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
refreshPreviewDocumentVersion: () => void;
}
// fallow-ignore-next-line complexity
export function buildStudioContextValue(input: StudioContextInput): StudioContextValue {
return {
projectId: input.projectId,
activeCompPath: input.activeCompPath,
setActiveCompPath: input.setActiveCompPath,
showToast: input.showToast,
previewIframeRef: input.previewIframeRef,
captionEditMode: input.captionEditMode,
compositionLoading: input.compositionLoading,
refreshKey: input.refreshKey,
setRefreshKey: input.setRefreshKey,
timelineElements: input.timelineElements,
isPlaying: input.isPlaying,
editHistory: input.editHistory,
handleUndo: input.handleUndo,
handleRedo: input.handleRedo,
renderQueue: input.renderQueue,
compositionDimensions: input.compositionDimensions,
waitForPendingDomEditSaves: input.waitForPendingDomEditSaves,
handlePreviewIframeRef: input.handlePreviewIframeRef,
refreshPreviewDocumentVersion: input.refreshPreviewDocumentVersion,
};
}
export interface InspectorState {
layersPanelActive: boolean;
designPanelActive: boolean;
inspectorPanelActive: boolean;
inspectorButtonActive: boolean;
shouldShowMotionPath: boolean;
shouldShowSelectedDomBounds: boolean;
}
export function useInspectorState(
rightPanelTab: string,
rightInspectorPanes: RightInspectorPanes,
rightCollapsed: boolean,
isPlaying: boolean,
domEditSelection: DomEditSelection | null,
isGestureRecording?: boolean,
): InspectorState {
// fallow-ignore-next-line complexity
return useMemo(() => {
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
const layersPanelActive = inspectorTabActive && rightInspectorPanes.layers;
const designPanelActive = inspectorTabActive && rightInspectorPanes.design;
const inspectorPanelActive = layersPanelActive || designPanelActive;
return {
layersPanelActive,
designPanelActive,
inspectorPanelActive,
inspectorButtonActive: !rightCollapsed && inspectorPanelActive,
// Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path
// handles ARE the arc-drag affordance, so gating them on an open Inspector
// would make keyframe path editing reachable only from a side panel.
shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording,
// Keep the selection box drawn even when the Inspector is collapsed —
// closing the panel shouldn't visually deselect the element.
// The Variables tab also works against the canvas selection (bind card),
// so the selection outline stays visible there too.
shouldShowSelectedDomBounds:
(inspectorPanelActive || rightPanelTab === "variables") &&
!isPlaying &&
!isGestureRecording,
};
}, [
rightPanelTab,
rightInspectorPanes,
rightCollapsed,
isPlaying,
isGestureRecording,
domEditSelection,
]);
}
// fallow-ignore-next-line complexity
function useDragOverlay(onImportFiles: (files: FileList) => void) {
const [active, setActive] = useState(false);
const counterRef = useRef(0);
const onDragOver = useCallback((e: DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
}, []);
const onDragEnter = useCallback((e: DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
counterRef.current++;
setActive(true);
}, []);
const onDragLeave = useCallback(() => {
counterRef.current--;
if (counterRef.current === 0) setActive(false);
}, []);
const onDrop = useCallback(
(e: DragEvent) => {
counterRef.current = 0;
setActive(false);
if (e.defaultPrevented) return;
e.preventDefault();
if (e.dataTransfer.files.length) onImportFiles(e.dataTransfer.files);
},
[onImportFiles],
);
return { active, onDragOver, onDragEnter, onDragLeave, onDrop };
}
/** Global OS file drop: imports and places at the playhead position. */
export function useGlobalFileDrop(handleTimelineFileDrop: TimelineFileDropHandler) {
const onDrop = useCallback(
(files: FileList) => {
const start = usePlayerStore.getState().currentTime;
void handleTimelineFileDrop(Array.from(files), { start, track: 0 });
},
[handleTimelineFileDrop],
);
return useDragOverlay(onDrop);
}