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

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,
};
}