mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +00:00
## 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.
571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from "react";
|
|
import type { SelectElementOptions, TimelineElement } from "../player";
|
|
import {
|
|
getAllPreviewTargetsFromPointer,
|
|
getPreviewTargetFromPointer,
|
|
} from "../utils/studioPreviewHelpers";
|
|
import {
|
|
findMatchingTimelineElementId,
|
|
findTimelineIdByAncestor,
|
|
type RightPanelTab,
|
|
} from "../utils/studioHelpers";
|
|
import {
|
|
domEditSelectionsTargetSame,
|
|
domEditSelectionInGroup,
|
|
toggleDomEditGroupSelection,
|
|
replaceDomEditGroupSelection,
|
|
seedDomEditGroupWithSelection,
|
|
} from "../utils/domEditHelpers";
|
|
import {
|
|
findElementForSelection,
|
|
findElementForTimelineElement,
|
|
resolveDomEditSelection,
|
|
type DomEditSelection,
|
|
} from "../components/editor/domEditing";
|
|
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
|
|
import { useStudioTestHooks } from "./useStudioTestHooks";
|
|
|
|
// ── Types ──
|
|
|
|
export interface ApplyDomSelectionOptions {
|
|
revealPanel?: boolean;
|
|
additive?: boolean;
|
|
preserveGroup?: boolean;
|
|
}
|
|
|
|
export interface ResolveDomSelectionOptions {
|
|
preferClipAncestor?: boolean;
|
|
skipSourceProbe?: boolean;
|
|
activeGroupElement?: HTMLElement | null;
|
|
}
|
|
|
|
export interface UseDomSelectionParams {
|
|
projectId: string | null;
|
|
activeCompPath: string | null;
|
|
isMasterView: boolean;
|
|
compIdToSrc: Map<string, string>;
|
|
captionEditMode: boolean;
|
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
timelineElements: TimelineElement[];
|
|
setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
|
|
setRightCollapsed: (collapsed: boolean) => void;
|
|
setRightPanelTab: (tab: RightPanelTab) => void;
|
|
previewIframe: HTMLIFrameElement | null;
|
|
refreshKey: number;
|
|
rightPanelTab: RightPanelTab;
|
|
}
|
|
|
|
export interface UseDomSelectionReturn {
|
|
// State
|
|
domEditSelection: DomEditSelection | null;
|
|
domEditGroupSelections: DomEditSelection[];
|
|
domEditHoverSelection: DomEditSelection | null;
|
|
activeGroupElement: HTMLElement | null;
|
|
// Refs
|
|
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
|
|
domEditHoverSelectionRef: React.MutableRefObject<DomEditSelection | null>;
|
|
activeGroupElementRef: React.MutableRefObject<HTMLElement | null>;
|
|
// State setters (needed by useDomEditSession for agent-prompt reset flows)
|
|
setDomEditSelection: React.Dispatch<React.SetStateAction<DomEditSelection | null>>;
|
|
setDomEditGroupSelections: React.Dispatch<React.SetStateAction<DomEditSelection[]>>;
|
|
setActiveGroupElement: (el: HTMLElement | null) => void;
|
|
// Callbacks
|
|
applyDomSelection: (
|
|
selection: DomEditSelection | null,
|
|
options?: ApplyDomSelectionOptions,
|
|
) => void;
|
|
clearDomSelection: () => void;
|
|
buildDomSelectionFromTarget: (
|
|
target: HTMLElement,
|
|
options?: ResolveDomSelectionOptions,
|
|
) => Promise<DomEditSelection | null>;
|
|
resolveDomSelectionFromPreviewPoint: (
|
|
clientX: number,
|
|
clientY: number,
|
|
options?: ResolveDomSelectionOptions,
|
|
) => Promise<DomEditSelection | null>;
|
|
resolveAllDomSelectionsFromPreviewPoint: (
|
|
clientX: number,
|
|
clientY: number,
|
|
) => Promise<DomEditSelection[]>;
|
|
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
|
|
buildDomSelectionForTimelineElement: (
|
|
element: TimelineElement,
|
|
) => Promise<DomEditSelection | null>;
|
|
handleTimelineElementSelect: (element: TimelineElement | null) => Promise<void>;
|
|
refreshDomEditSelectionFromPreview: (selection: DomEditSelection) => Promise<void>;
|
|
refreshDomEditGroupSelectionsFromPreview: (selections: DomEditSelection[]) => Promise<void>;
|
|
applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void;
|
|
}
|
|
|
|
// ── Hook ──
|
|
|
|
export function useDomSelection({
|
|
projectId,
|
|
activeCompPath,
|
|
isMasterView,
|
|
compIdToSrc,
|
|
captionEditMode,
|
|
previewIframeRef,
|
|
timelineElements,
|
|
setSelectedTimelineElementId,
|
|
setRightCollapsed,
|
|
setRightPanelTab,
|
|
previewIframe,
|
|
refreshKey,
|
|
rightPanelTab,
|
|
}: UseDomSelectionParams): UseDomSelectionReturn {
|
|
// ── State ──
|
|
|
|
const [domEditSelection, setDomEditSelection] = useState<DomEditSelection | null>(null);
|
|
const [domEditGroupSelections, setDomEditGroupSelections] = useState<DomEditSelection[]>([]);
|
|
const [domEditHoverSelection, setDomEditHoverSelection] = useState<DomEditSelection | null>(null);
|
|
// The data-hf-group wrapper the user has drilled into (null = top level).
|
|
const [activeGroupElement, setActiveGroupElementState] = useState<HTMLElement | null>(null);
|
|
|
|
// ── Refs ──
|
|
|
|
const rightPanelTabRef = useRef(rightPanelTab);
|
|
rightPanelTabRef.current = rightPanelTab;
|
|
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
|
|
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
|
|
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
|
|
const activeGroupElementRef = useRef<HTMLElement | null>(activeGroupElement);
|
|
const compositionIdentityRef = useRef({ activeCompPath, projectId });
|
|
// Monotonic token so a rapid A->B timeline-clip select can't let A's slower async
|
|
// resolution land after B and restore the wrong selection.
|
|
const timelineSelectSeqRef = useRef(0);
|
|
|
|
// Keep refs in sync with state
|
|
domEditSelectionRef.current = domEditSelection;
|
|
domEditGroupSelectionsRef.current = domEditGroupSelections;
|
|
domEditHoverSelectionRef.current = domEditHoverSelection;
|
|
activeGroupElementRef.current = activeGroupElement;
|
|
|
|
// ── Callbacks ──
|
|
|
|
const applyDomSelection = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
(
|
|
selection: DomEditSelection | null,
|
|
options?: {
|
|
revealPanel?: boolean;
|
|
additive?: boolean;
|
|
preserveGroup?: boolean;
|
|
},
|
|
) => {
|
|
if (!selection) {
|
|
domEditSelectionRef.current = null;
|
|
domEditGroupSelectionsRef.current = [];
|
|
setDomEditSelection(null);
|
|
setDomEditGroupSelections([]);
|
|
setSelectedTimelineElementId(null);
|
|
return;
|
|
}
|
|
|
|
const isAdditiveSelection = Boolean(options?.additive);
|
|
const currentSelection = domEditSelectionRef.current;
|
|
const previousGroup = domEditGroupSelectionsRef.current;
|
|
const currentGroup = isAdditiveSelection
|
|
? seedDomEditGroupWithSelection(previousGroup, currentSelection)
|
|
: previousGroup;
|
|
const wasInGroup = domEditSelectionInGroup(currentGroup, selection);
|
|
const nextGroup = options?.preserveGroup
|
|
? replaceDomEditGroupSelection(currentGroup, selection)
|
|
: isAdditiveSelection
|
|
? toggleDomEditGroupSelection(currentGroup, selection)
|
|
: [selection];
|
|
const nextSelection = options?.preserveGroup
|
|
? selection
|
|
: isAdditiveSelection && wasInGroup
|
|
? domEditSelectionsTargetSame(currentSelection, selection)
|
|
? (nextGroup[0] ?? null)
|
|
: domEditSelectionInGroup(nextGroup, currentSelection)
|
|
? currentSelection
|
|
: (nextGroup[0] ?? null)
|
|
: selection;
|
|
|
|
domEditSelectionRef.current = nextSelection;
|
|
domEditGroupSelectionsRef.current = nextGroup;
|
|
setDomEditSelection(nextSelection);
|
|
setDomEditGroupSelections(nextGroup);
|
|
|
|
// Selecting something outside the drilled-into group exits the drill-in, so
|
|
// a later click on the group selects it as a unit again (non-sticky drill-in).
|
|
const activeGroup = activeGroupElementRef.current;
|
|
if (activeGroup && nextSelection && !activeGroup.contains(nextSelection.element)) {
|
|
activeGroupElementRef.current = null;
|
|
setActiveGroupElementState(null);
|
|
}
|
|
|
|
if (nextSelection) {
|
|
if (options?.revealPanel !== false) {
|
|
setRightCollapsed(false);
|
|
// Keep the Variables tab in place — selecting elements is part of the bind
|
|
// flow there; yanking to Design would lose the context.
|
|
if (rightPanelTabRef.current !== "variables") {
|
|
setRightPanelTab("design");
|
|
}
|
|
}
|
|
const nextSelectedTimelineId =
|
|
findMatchingTimelineElementId(nextSelection, timelineElements) ??
|
|
findTimelineIdByAncestor(
|
|
nextSelection.element,
|
|
timelineElements,
|
|
nextSelection.sourceFile || "index.html",
|
|
);
|
|
// Late marquee notify: a primary already in the live set must not collapse it.
|
|
setSelectedTimelineElementId(nextSelectedTimelineId, { preserveSet: true });
|
|
return;
|
|
}
|
|
|
|
setSelectedTimelineElementId(null);
|
|
},
|
|
[setSelectedTimelineElementId, timelineElements, setRightCollapsed, setRightPanelTab],
|
|
);
|
|
|
|
const clearDomSelection = useCallback(() => {
|
|
applyDomSelection(null, { revealPanel: false });
|
|
}, [applyDomSelection]);
|
|
|
|
// Drill into / out of a group. Changing scope clears the current selection so
|
|
// the user isn't left with an out-of-scope element selected.
|
|
const setActiveGroupElement = useCallback(
|
|
(el: HTMLElement | null) => {
|
|
if (activeGroupElementRef.current === el) return;
|
|
activeGroupElementRef.current = el;
|
|
setActiveGroupElementState(el);
|
|
applyDomSelection(null, { revealPanel: false });
|
|
},
|
|
[applyDomSelection],
|
|
);
|
|
|
|
const buildDomSelectionFromTarget = useCallback(
|
|
(
|
|
target: HTMLElement,
|
|
options?: {
|
|
preferClipAncestor?: boolean;
|
|
skipSourceProbe?: boolean;
|
|
// Override the drill-in scope (used by canvas double-click to resolve the
|
|
// child inside a group before the activeGroupElement state has re-rendered).
|
|
activeGroupElement?: HTMLElement | null;
|
|
},
|
|
) => {
|
|
return resolveDomEditSelection(target, {
|
|
activeCompositionPath: activeCompPath,
|
|
isMasterView,
|
|
preferClipAncestor: options?.preferClipAncestor,
|
|
skipSourceProbe: options?.skipSourceProbe,
|
|
activeGroupElement:
|
|
options && "activeGroupElement" in options
|
|
? options.activeGroupElement
|
|
: activeGroupElementRef.current,
|
|
projectId,
|
|
});
|
|
},
|
|
[activeCompPath, isMasterView, projectId],
|
|
);
|
|
|
|
const resolveDomSelectionFromPreviewPoint = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (
|
|
clientX: number,
|
|
clientY: number,
|
|
options?: {
|
|
preferClipAncestor?: boolean;
|
|
skipSourceProbe?: boolean;
|
|
activeGroupElement?: HTMLElement | null;
|
|
},
|
|
) => {
|
|
const iframe = previewIframeRef.current;
|
|
if (!iframe || captionEditMode) return null;
|
|
try {
|
|
if (iframe.contentDocument) reapplyPositionEditsAfterSeek(iframe.contentDocument);
|
|
} catch {
|
|
/* cross-origin guard */
|
|
}
|
|
const target = getPreviewTargetFromPointer(iframe, clientX, clientY, activeCompPath);
|
|
if (!target) return null;
|
|
return buildDomSelectionFromTarget(
|
|
target,
|
|
options && "activeGroupElement" in options
|
|
? {
|
|
preferClipAncestor: options.preferClipAncestor,
|
|
skipSourceProbe: options.skipSourceProbe,
|
|
activeGroupElement: options.activeGroupElement,
|
|
}
|
|
: {
|
|
preferClipAncestor: options?.preferClipAncestor,
|
|
skipSourceProbe: options?.skipSourceProbe,
|
|
},
|
|
);
|
|
},
|
|
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
|
|
);
|
|
|
|
const resolveAllDomSelectionsFromPreviewPoint = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (clientX: number, clientY: number): Promise<DomEditSelection[]> => {
|
|
const iframe = previewIframeRef.current;
|
|
if (!iframe || captionEditMode) return [];
|
|
try {
|
|
if (iframe.contentDocument) reapplyPositionEditsAfterSeek(iframe.contentDocument);
|
|
} catch {
|
|
/* cross-origin guard */
|
|
}
|
|
const targets = getAllPreviewTargetsFromPointer(iframe, clientX, clientY, activeCompPath);
|
|
const results: DomEditSelection[] = [];
|
|
for (const target of targets) {
|
|
const sel = await buildDomSelectionFromTarget(target, { skipSourceProbe: true });
|
|
if (sel) results.push(sel);
|
|
}
|
|
return results;
|
|
},
|
|
[activeCompPath, buildDomSelectionFromTarget, captionEditMode, previewIframeRef],
|
|
);
|
|
|
|
const updateDomEditHoverSelection = useCallback((selection: DomEditSelection | null) => {
|
|
if (domEditSelectionsTargetSame(domEditHoverSelectionRef.current, selection)) return;
|
|
domEditHoverSelectionRef.current = selection;
|
|
setDomEditHoverSelection(selection);
|
|
}, []);
|
|
|
|
const buildDomSelectionForTimelineElement = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (element: TimelineElement): Promise<DomEditSelection | null> => {
|
|
const iframe = previewIframeRef.current;
|
|
let doc: Document | null = null;
|
|
try {
|
|
doc = iframe?.contentDocument ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!doc) return null;
|
|
|
|
reapplyPositionEditsAfterSeek(doc);
|
|
|
|
const targetElement = findElementForTimelineElement(doc, element, {
|
|
activeCompositionPath: activeCompPath,
|
|
compIdToSrc,
|
|
isMasterView,
|
|
});
|
|
return targetElement
|
|
? buildDomSelectionFromTarget(targetElement, {
|
|
preferClipAncestor: false,
|
|
})
|
|
: null;
|
|
},
|
|
[activeCompPath, buildDomSelectionFromTarget, compIdToSrc, isMasterView, previewIframeRef],
|
|
);
|
|
|
|
const handleTimelineElementSelect = useCallback(
|
|
async (element: TimelineElement | null) => {
|
|
const seq = ++timelineSelectSeqRef.current;
|
|
if (!element) {
|
|
applyDomSelection(null, { revealPanel: false });
|
|
return;
|
|
}
|
|
|
|
const selection = await buildDomSelectionForTimelineElement(element);
|
|
// A newer selection superseded this one while we were resolving — drop the stale result.
|
|
if (seq !== timelineSelectSeqRef.current) return;
|
|
if (selection) applyDomSelection(selection);
|
|
},
|
|
[applyDomSelection, buildDomSelectionForTimelineElement],
|
|
);
|
|
|
|
const refreshDomEditSelectionFromPreview = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (selection: DomEditSelection) => {
|
|
const iframe = previewIframeRef.current;
|
|
let doc: Document | null = null;
|
|
try {
|
|
doc = iframe?.contentDocument ?? null;
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!doc) return;
|
|
|
|
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
if (!element) {
|
|
applyDomSelection(null, { revealPanel: false });
|
|
return;
|
|
}
|
|
|
|
const nextSelection = await buildDomSelectionFromTarget(element);
|
|
if (nextSelection) {
|
|
applyDomSelection(nextSelection, {
|
|
revealPanel: false,
|
|
preserveGroup: true,
|
|
});
|
|
}
|
|
},
|
|
[activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef],
|
|
);
|
|
|
|
const refreshDomEditGroupSelectionsFromPreview = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
async (selections: DomEditSelection[]) => {
|
|
const iframe = previewIframeRef.current;
|
|
let doc: Document | null = null;
|
|
try {
|
|
doc = iframe?.contentDocument ?? null;
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!doc) return;
|
|
|
|
const nextGroup: DomEditSelection[] = [];
|
|
for (const selection of selections) {
|
|
const element = findElementForSelection(doc, selection, activeCompPath);
|
|
if (!element) continue;
|
|
const nextSelection = await buildDomSelectionFromTarget(element);
|
|
if (nextSelection) nextGroup.push(nextSelection);
|
|
}
|
|
if (nextGroup.length === 0) return;
|
|
|
|
const currentSelection = domEditSelectionRef.current;
|
|
const nextSelection =
|
|
nextGroup.find((selection) => domEditSelectionsTargetSame(selection, currentSelection)) ??
|
|
nextGroup[0] ??
|
|
null;
|
|
|
|
domEditSelectionRef.current = nextSelection;
|
|
domEditGroupSelectionsRef.current = nextGroup;
|
|
setDomEditSelection(nextSelection);
|
|
setDomEditGroupSelections(nextGroup);
|
|
|
|
if (nextSelection) {
|
|
setSelectedTimelineElementId(
|
|
findMatchingTimelineElementId(nextSelection, timelineElements),
|
|
);
|
|
} else {
|
|
setSelectedTimelineElementId(null);
|
|
}
|
|
},
|
|
[
|
|
activeCompPath,
|
|
buildDomSelectionFromTarget,
|
|
setSelectedTimelineElementId,
|
|
timelineElements,
|
|
previewIframeRef,
|
|
],
|
|
);
|
|
|
|
// ── Effects ──
|
|
|
|
// Clear hover unconditionally on composition/project/preview change
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
updateDomEditHoverSelection(null);
|
|
}, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
|
|
|
|
// Clear committed selection only when the composition identity actually changes.
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
const previous = compositionIdentityRef.current;
|
|
if (previous.activeCompPath === activeCompPath && previous.projectId === projectId) return;
|
|
compositionIdentityRef.current = { activeCompPath, projectId };
|
|
activeGroupElementRef.current = null;
|
|
setActiveGroupElementState(null);
|
|
applyDomSelection(null, { revealPanel: false });
|
|
}, [activeCompPath, projectId, applyDomSelection]);
|
|
|
|
// Clear hover conditionally (caption mode, matches selection, disconnected element)
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
if (!domEditHoverSelection) return;
|
|
const shouldClear =
|
|
captionEditMode ||
|
|
domEditSelectionsTargetSame(domEditHoverSelection, domEditSelection) ||
|
|
domEditSelectionInGroup(domEditGroupSelections, domEditHoverSelection) ||
|
|
!domEditHoverSelection.element.isConnected;
|
|
if (shouldClear) updateDomEditHoverSelection(null);
|
|
}, [
|
|
captionEditMode,
|
|
domEditHoverSelection,
|
|
domEditSelection,
|
|
domEditGroupSelections,
|
|
updateDomEditHoverSelection,
|
|
]);
|
|
|
|
// Clear selection on caption mode change
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
useEffect(() => {
|
|
if (!captionEditMode) return;
|
|
applyDomSelection(null, { revealPanel: false });
|
|
}, [applyDomSelection, captionEditMode]);
|
|
|
|
// Dev-only headless-QA shortcut (window.__studioTest.selectByDomId). No-op in prod.
|
|
useStudioTestHooks({ previewIframeRef, buildDomSelectionFromTarget, applyDomSelection });
|
|
|
|
const applyMarqueeSelection = useCallback(
|
|
// fallow-ignore-next-line complexity
|
|
(selections: DomEditSelection[], additive: boolean) => {
|
|
if (selections.length === 0) {
|
|
if (!additive) applyDomSelection(null, { revealPanel: false });
|
|
return;
|
|
}
|
|
const current = domEditSelectionRef.current;
|
|
const currentGroup = domEditGroupSelectionsRef.current;
|
|
let nextGroup: DomEditSelection[];
|
|
if (additive) {
|
|
nextGroup = seedDomEditGroupWithSelection(currentGroup, current);
|
|
for (const s of selections) {
|
|
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup = [...nextGroup, s];
|
|
}
|
|
} else {
|
|
// Dedupe by target: select-as-unit collapses marquee'd members to one group.
|
|
nextGroup = [];
|
|
for (const s of selections) {
|
|
if (!domEditSelectionInGroup(nextGroup, s)) nextGroup.push(s);
|
|
}
|
|
}
|
|
const nextSelection = additive && current ? current : selections[0];
|
|
domEditSelectionRef.current = nextSelection;
|
|
domEditGroupSelectionsRef.current = nextGroup;
|
|
setDomEditSelection(nextSelection);
|
|
setDomEditGroupSelections(nextGroup);
|
|
const nextTimelineId =
|
|
findMatchingTimelineElementId(nextSelection, timelineElements) ??
|
|
findTimelineIdByAncestor(
|
|
nextSelection.element,
|
|
timelineElements,
|
|
nextSelection.sourceFile || "index.html",
|
|
);
|
|
setSelectedTimelineElementId(nextTimelineId);
|
|
},
|
|
[applyDomSelection, timelineElements, setSelectedTimelineElementId],
|
|
);
|
|
|
|
return {
|
|
// State
|
|
domEditSelection,
|
|
domEditGroupSelections,
|
|
domEditHoverSelection,
|
|
activeGroupElement,
|
|
// Refs
|
|
domEditSelectionRef,
|
|
domEditGroupSelectionsRef,
|
|
domEditHoverSelectionRef,
|
|
activeGroupElementRef,
|
|
// State setters
|
|
setDomEditSelection,
|
|
setDomEditGroupSelections,
|
|
setActiveGroupElement,
|
|
// Callbacks
|
|
applyDomSelection,
|
|
clearDomSelection,
|
|
buildDomSelectionFromTarget,
|
|
resolveDomSelectionFromPreviewPoint,
|
|
resolveAllDomSelectionsFromPreviewPoint,
|
|
updateDomEditHoverSelection,
|
|
buildDomSelectionForTimelineElement,
|
|
handleTimelineElementSelect,
|
|
refreshDomEditSelectionFromPreview,
|
|
refreshDomEditGroupSelectionsFromPreview,
|
|
applyMarqueeSelection,
|
|
};
|
|
}
|