mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 18:01:20 +08:00
cef3b86c95
## 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.
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { useCallback, useRef } from "react";
|
|
import { liveTime, usePlayerStore } from "../player";
|
|
import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
|
|
import { type DomEditSelection } from "../components/editor/domEditing";
|
|
import type { ApplyDomSelectionOptions, ResolveDomSelectionOptions } from "./useDomSelection";
|
|
import { trackStudioEvent } from "../utils/studioTelemetry";
|
|
|
|
// ── Types ──
|
|
|
|
export interface UsePreviewInteractionParams {
|
|
captionEditMode: boolean;
|
|
compositionLoading: boolean;
|
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
|
|
// From useDomSelection
|
|
applyDomSelection: (
|
|
selection: DomEditSelection | null,
|
|
options?: ApplyDomSelectionOptions,
|
|
) => void;
|
|
resolveDomSelectionFromPreviewPoint: (
|
|
clientX: number,
|
|
clientY: number,
|
|
options?: ResolveDomSelectionOptions,
|
|
) => Promise<DomEditSelection | null>;
|
|
resolveAllDomSelectionsFromPreviewPoint: (
|
|
clientX: number,
|
|
clientY: number,
|
|
) => Promise<DomEditSelection[]>;
|
|
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
|
/** Drill into a group (double-click on the canvas) so its children become selectable. */
|
|
setActiveGroupElement: (el: HTMLElement | null) => void;
|
|
|
|
onClickToSource?: (selection: DomEditSelection) => void;
|
|
}
|
|
|
|
interface ClickCycleState {
|
|
x: number;
|
|
y: number;
|
|
candidates: DomEditSelection[];
|
|
index: number;
|
|
at: number;
|
|
}
|
|
|
|
export interface PreviewMouseDownOptions {
|
|
preferClipAncestor?: boolean;
|
|
hoverSelection?: DomEditSelection | null;
|
|
}
|
|
|
|
const CYCLE_RADIUS_PX = 6;
|
|
const CYCLE_WINDOW_MS = 600;
|
|
// Manual double-click window. `e.detail` can't be trusted here: the first click
|
|
// selects the group and re-renders the overlay, so the second click lands on a
|
|
// fresh element and the browser's native click-counter resets to 1 — drill-in
|
|
// (which keyed off `e.detail >= 2`) never fired. We track time+position instead.
|
|
const DOUBLE_CLICK_MS = 400;
|
|
const DOUBLE_CLICK_RADIUS_PX = 6;
|
|
|
|
// ── Hook ──
|
|
|
|
export function usePreviewInteraction({
|
|
captionEditMode,
|
|
compositionLoading,
|
|
previewIframeRef,
|
|
showToast,
|
|
applyDomSelection,
|
|
resolveDomSelectionFromPreviewPoint,
|
|
resolveAllDomSelectionsFromPreviewPoint,
|
|
updateDomEditHoverSelection,
|
|
setActiveGroupElement,
|
|
onClickToSource,
|
|
}: UsePreviewInteractionParams) {
|
|
const cycleRef = useRef<ClickCycleState | null>(null);
|
|
const lastDownRef = useRef<{ t: number; x: number; y: number } | null>(null);
|
|
|
|
const pausePreviewPlayback = useCallback(() => {
|
|
const pausedTime = pauseStudioPreviewPlayback(previewIframeRef.current);
|
|
const playerStore = usePlayerStore.getState();
|
|
playerStore.setIsPlaying(false);
|
|
if (pausedTime != null) {
|
|
playerStore.setCurrentTime(pausedTime);
|
|
liveTime.notify(pausedTime);
|
|
}
|
|
}, [previewIframeRef]);
|
|
|
|
const handlePreviewCanvasMouseDown = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (e: React.MouseEvent<HTMLDivElement>, options?: PreviewMouseDownOptions) => {
|
|
if (captionEditMode || compositionLoading) return;
|
|
|
|
// Manual double-click detection (see DOUBLE_CLICK_MS): the first click
|
|
// re-renders the overlay so `e.detail` never reaches 2 on the canvas.
|
|
const downTs = Date.now();
|
|
const lastDown = lastDownRef.current;
|
|
const isDoubleClick =
|
|
e.detail >= 2 ||
|
|
(lastDown != null &&
|
|
downTs - lastDown.t < DOUBLE_CLICK_MS &&
|
|
Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < DOUBLE_CLICK_RADIUS_PX);
|
|
lastDownRef.current = { t: downTs, x: e.clientX, y: e.clientY };
|
|
const wasPlaying = usePlayerStore.getState().isPlaying;
|
|
pausePreviewPlayback();
|
|
// A click that resolves to nothing (dead-zone / deselect) shouldn't leave
|
|
// playback paused — pausing before sampling only exists to keep the hit
|
|
// target stable while resolving; resume if nothing was selected.
|
|
const resumeIfNothingSelected = () => {
|
|
if (wasPlaying) usePlayerStore.getState().setIsPlaying(true);
|
|
};
|
|
|
|
// Double-click a group → drill into it and select the child under the
|
|
// pointer (resolve with the group as the explicit drill-in scope, since the
|
|
// activeGroupElement state hasn't re-rendered yet within this handler).
|
|
if (isDoubleClick && !e.shiftKey) {
|
|
const hit = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY);
|
|
const cycle = cycleRef.current;
|
|
const hasStackCycleAtSpot =
|
|
cycle !== null &&
|
|
cycle.candidates.length > 1 &&
|
|
Math.hypot(e.clientX - cycle.x, e.clientY - cycle.y) < CYCLE_RADIUS_PX &&
|
|
downTs - cycle.at < CYCLE_WINDOW_MS;
|
|
if (hit?.element.hasAttribute("data-hf-group")) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
cycleRef.current = null;
|
|
trackStudioEvent("group", { action: "drill_in" });
|
|
setActiveGroupElement(hit.element);
|
|
const child = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
activeGroupElement: hit.element,
|
|
});
|
|
applyDomSelection(child ?? hit);
|
|
return;
|
|
}
|
|
if (
|
|
hit &&
|
|
!hasStackCycleAtSpot &&
|
|
!hit.element.hasAttribute("data-composition-src") &&
|
|
!hit.element.hasAttribute("data-composition-file")
|
|
) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
cycleRef.current = null;
|
|
applyDomSelection(hit);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const now = Date.now();
|
|
const prev = cycleRef.current;
|
|
const dx = prev ? e.clientX - prev.x : Infinity;
|
|
const dy = prev ? e.clientY - prev.y : Infinity;
|
|
const sameSpot =
|
|
prev !== null &&
|
|
Math.sqrt(dx * dx + dy * dy) < CYCLE_RADIUS_PX &&
|
|
now - prev.at < CYCLE_WINDOW_MS;
|
|
|
|
if (e.shiftKey) {
|
|
// Additive selection — no cycling
|
|
cycleRef.current = null;
|
|
const nextSelection =
|
|
(await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
})) ??
|
|
options?.hoverSelection ??
|
|
null;
|
|
if (!nextSelection) {
|
|
resumeIfNothingSelected();
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
applyDomSelection(nextSelection, { additive: true });
|
|
return;
|
|
}
|
|
|
|
if (sameSpot && prev) {
|
|
// Cycle to next candidate in z-stack
|
|
const nextIndex = (prev.index + 1) % prev.candidates.length;
|
|
const nextSel = prev.candidates[nextIndex];
|
|
cycleRef.current = { ...prev, index: nextIndex, at: now };
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
applyDomSelection(nextSel);
|
|
return;
|
|
}
|
|
|
|
// Fresh click — resolve topmost element
|
|
let nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
});
|
|
// A null result while drilled into a group means the click landed OUTSIDE that
|
|
// group (resolveGroupCapture → out-of-scope). Drill-in isn't sticky: exit it and
|
|
// re-resolve at the top level so this click selects whatever's there (or the
|
|
// group as a unit). Without this, a stale drill-in keeps selecting children and
|
|
// the "first click selects the group" expectation breaks.
|
|
if (!nextSelection) {
|
|
setActiveGroupElement(null);
|
|
nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
activeGroupElement: null,
|
|
});
|
|
}
|
|
nextSelection = nextSelection ?? options?.hoverSelection ?? null;
|
|
if (!nextSelection) {
|
|
cycleRef.current = null;
|
|
applyDomSelection(null, { revealPanel: false });
|
|
resumeIfNothingSelected();
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
applyDomSelection(nextSelection);
|
|
|
|
if (!e.shiftKey && e.altKey && onClickToSource) {
|
|
onClickToSource(nextSelection);
|
|
}
|
|
|
|
// Resolve all stacked candidates so a subsequent click at the same
|
|
// position can cycle to the next layer (issues #1124, #1125).
|
|
const all = await resolveAllDomSelectionsFromPreviewPoint(e.clientX, e.clientY);
|
|
cycleRef.current =
|
|
all.length > 1 ? { x: e.clientX, y: e.clientY, candidates: all, index: 0, at: now } : null;
|
|
},
|
|
[
|
|
applyDomSelection,
|
|
captionEditMode,
|
|
compositionLoading,
|
|
onClickToSource,
|
|
pausePreviewPlayback,
|
|
resolveAllDomSelectionsFromPreviewPoint,
|
|
resolveDomSelectionFromPreviewPoint,
|
|
setActiveGroupElement,
|
|
],
|
|
);
|
|
|
|
const handlePreviewCanvasPointerMove = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
|
|
if (captionEditMode || compositionLoading) {
|
|
updateDomEditHoverSelection(null);
|
|
return null;
|
|
}
|
|
|
|
const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
|
|
preferClipAncestor: options?.preferClipAncestor ?? false,
|
|
skipSourceProbe: true,
|
|
});
|
|
updateDomEditHoverSelection(nextSelection);
|
|
return nextSelection;
|
|
},
|
|
[
|
|
captionEditMode,
|
|
compositionLoading,
|
|
resolveDomSelectionFromPreviewPoint,
|
|
updateDomEditHoverSelection,
|
|
],
|
|
);
|
|
|
|
const handlePreviewCanvasPointerLeave = useCallback(() => {
|
|
updateDomEditHoverSelection(null);
|
|
}, [updateDomEditHoverSelection]);
|
|
|
|
const handleBlockedDomMove = useCallback(
|
|
(selection: DomEditSelection) => {
|
|
showToast(
|
|
selection.capabilities.reasonIfDisabled ??
|
|
"This element can't be adjusted directly from the preview.",
|
|
"info",
|
|
);
|
|
},
|
|
[showToast],
|
|
);
|
|
|
|
const handleDomManualDragStart = useCallback(() => {
|
|
pausePreviewPlayback();
|
|
}, [pausePreviewPlayback]);
|
|
|
|
return {
|
|
handlePreviewCanvasMouseDown,
|
|
handlePreviewCanvasPointerMove,
|
|
handlePreviewCanvasPointerLeave,
|
|
handleBlockedDomMove,
|
|
handleDomManualDragStart,
|
|
};
|
|
}
|