Files
hyperframes/packages/studio/src/hooks/useDomEditWiring.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

278 lines
8.9 KiB
TypeScript

/**
* Wiring layer for DOM edit sessions: click-to-source navigation,
* DOM selection to timeline sync, GSAP cache invalidation on refresh,
* GSAP cache population, animation resolution for the selected element,
* and preview sync side-effects.
*
* Extracted from useDomEditSession to isolate orchestration wiring from
* the GSAP-aware geometry intercept logic.
*/
import { useCallback, useEffect, useRef } from "react";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { usePlayerStore } from "../player";
import { useDomEditPreviewSync } from "./useDomEditPreviewSync";
import { useGsapAnimationsForElement, usePopulateKeyframeCacheForFile } from "./useGsapTweenCache";
import { useGsapAnimationFetchFallback } from "./useGsapAnimationFetchFallback";
import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
import { useGsapSelectionHandlers } from "./useGsapSelectionHandlers";
import type { PatchTarget } from "../utils/sourcePatcher";
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
export interface UseDomEditWiringParams {
projectId: string | null;
activeCompPath: string | null;
domEditSelection: DomEditSelection | null;
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
previewIframe: HTMLIFrameElement | null;
captionEditMode: boolean;
refreshKey: number;
gsapCacheVersion: number;
bumpGsapCache: () => void;
showToast: (message: string, tone?: "error" | "info") => void;
refreshPreviewDocumentVersion: () => void;
syncPreviewHistoryHotkey: (iframe: HTMLIFrameElement | null) => void;
applyStudioManualEditsToPreviewRef: React.MutableRefObject<
(iframe: HTMLIFrameElement) => Promise<void>
>;
applyDomSelection: (
selection: DomEditSelection | null,
options?: { revealPanel?: boolean; preserveGroup?: boolean },
) => void;
buildDomSelectionFromTarget: (element: HTMLElement) => Promise<DomEditSelection | null>;
openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
selectSidebarTab?: (tab: SidebarTab) => void;
getSidebarTab?: () => SidebarTab;
// GSAP script commit ops (from useGsapScriptCommits)
updateGsapProperty: (
sel: DomEditSelection,
animId: string,
prop: string,
value: number | string,
) => void;
updateGsapMeta: (
sel: DomEditSelection,
animId: string,
updates: { duration?: number; ease?: string; position?: number },
) => Promise<void>;
deleteGsapAnimation: (sel: DomEditSelection, animId: string) => Promise<void>;
deleteAllForSelector: (sel: DomEditSelection, targetSelector: string) => Promise<void>;
addGsapAnimation: (
sel: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
time: number,
) => Promise<void>;
addGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
removeGsapProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
updateGsapFromProperty: (
sel: DomEditSelection,
animId: string,
prop: string,
value: number | string,
) => Promise<void>;
addGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
removeGsapFromProperty: (sel: DomEditSelection, animId: string, prop: string) => Promise<void>;
addKeyframe: (
sel: DomEditSelection,
animId: string,
percentage: number,
property: string,
value: number | string,
) => void;
addKeyframeBatch: (
sel: DomEditSelection,
animId: string,
percentage: number,
properties: Record<string, number | string>,
) => Promise<void>;
removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void;
moveKeyframe: (
sel: DomEditSelection,
animId: string,
fromPercentage: number,
toPercentage: number,
) => Promise<boolean>;
resizeKeyframedTween: (
sel: DomEditSelection,
animId: string,
position: number,
duration: number,
pctRemap: Array<{ from: number; to: number }>,
) => Promise<boolean>;
convertToKeyframes: (
sel: DomEditSelection,
animId: string,
resolvedFromValues?: Record<string, number | string>,
) => Promise<void>;
removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
handleDomManualEditsReset: (sel: DomEditSelection) => void;
}
// fallow-ignore-next-line complexity
export function useDomEditWiring({
// fallow-ignore-next-line code-duplication
projectId,
activeCompPath,
domEditSelection,
domEditSelectionRef,
previewIframeRef,
previewIframe,
captionEditMode,
refreshKey,
gsapCacheVersion,
bumpGsapCache,
showToast,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
applyDomSelection,
buildDomSelectionFromTarget,
openSourceForSelection,
selectSidebarTab,
getSidebarTab,
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
addKeyframeBatch,
removeKeyframe,
moveKeyframe,
resizeKeyframedTween,
convertToKeyframes,
removeAllKeyframes,
handleDomManualEditsReset,
}: UseDomEditWiringParams) {
// ── Click-to-source navigation ──
const onClickToSource = useCallback(
(selection: DomEditSelection) => {
if (!openSourceForSelection || !selectSidebarTab) return;
if (!selection.sourceFile) return;
selectSidebarTab("code");
openSourceForSelection(selection.sourceFile, {
id: selection.id,
selector: selection.selector,
selectorIndex: selection.selectorIndex,
});
},
[openSourceForSelection, selectSidebarTab],
);
// ── DOM selection -> timeline element sync ──
useEffect(() => {
if (!domEditSelection?.id) return;
const { selectedElementId, elements, setSelectedElementId } = usePlayerStore.getState();
const matchKey = elements.find(
(el) => el.domId === domEditSelection.id || el.id === domEditSelection.id,
);
const key = matchKey ? (matchKey.key ?? matchKey.id) : null;
if (key && key !== selectedElementId) setSelectedElementId(key);
}, [domEditSelection?.id]);
// ── GSAP cache sync ──
// Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe
// reload via refreshKey but don't go through commitMutation, so the cache
// would otherwise retain stale keyframe entries).
const prevRefreshKeyRef = useRef(refreshKey);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (refreshKey !== prevRefreshKeyRef.current) {
prevRefreshKeyRef.current = refreshKey;
bumpGsapCache();
}
}, [refreshKey, bumpGsapCache]);
const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html";
usePopulateKeyframeCacheForFile(
projectId ?? null,
gsapSourceFile,
gsapCacheVersion,
previewIframeRef,
);
const {
animations: selectedGsapAnimations,
multipleTimelines: gsapMultipleTimelines,
unsupportedTimelinePattern: gsapUnsupportedTimelinePattern,
} = useGsapAnimationsForElement(
projectId ?? null,
gsapSourceFile,
domEditSelection
? { id: domEditSelection.id ?? null, selector: domEditSelection.selector ?? null }
: null,
gsapCacheVersion,
// Pass the preview iframe so class/selector tweens (e.g. `.dot`) resolve to
// the live element and surface in the inspector — not just by #id match.
previewIframeRef,
);
// ── Telemetry & fallback ──
const trackGsapInteractionFailure = useGsapInteractionFailureTelemetry(activeCompPath, showToast);
const makeFetchFallback = useGsapAnimationFetchFallback(projectId, gsapSourceFile);
// ── GSAP selection handlers ──
const gsapSelectionHandlers = useGsapSelectionHandlers({
domEditSelection,
updateGsapProperty,
updateGsapMeta,
deleteGsapAnimation,
deleteAllForSelector,
addGsapAnimation,
addGsapProperty,
removeGsapProperty,
updateGsapFromProperty,
addGsapFromProperty,
removeGsapFromProperty,
addKeyframe,
addKeyframeBatch,
removeKeyframe,
moveKeyframe,
resizeKeyframedTween,
convertToKeyframes,
removeAllKeyframes,
handleDomManualEditsReset,
selectedGsapAnimations,
showToast,
});
// ── Preview sync side-effects ──
useDomEditPreviewSync({
previewIframe,
activeCompPath,
captionEditMode,
domEditSelectionRef,
domEditSelection,
applyDomSelection,
buildDomSelectionFromTarget,
refreshPreviewDocumentVersion,
syncPreviewHistoryHotkey,
applyStudioManualEditsToPreviewRef,
openSourceForSelection,
getSidebarTab,
gsapCacheVersion,
});
return {
onClickToSource,
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
trackGsapInteractionFailure,
makeFetchFallback,
...gsapSelectionHandlers,
};
}