Files
hyperframes/packages/studio/src/hooks/useDomSelection.ts
T
Miguel Ángel 1284213886 fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle (#1126)
* fix(studio): gsap panel bug bash — clamping, overlay, click cycling, visibility toggle

- opacity/autoAlpha clamped to [0,1] (display 0–100%) — eliminates -30%/190% edits
- `visibility` renders as a boolean toggle; only available to add in `set` tweens
- ease curve section: use aspect-ratio container so control circles are not oval
- MetricField scroll only fires when the input is focused (was triggering on scroll-over)
- preview overlay clipped to its container (overflow-hidden) — no bleed into panels
- `fromTo` method label updated to "From → To" (was "Animate", same as `to`)
- repeated click at same position cycles through stacked/overlapping elements (#1124, #1125)
  resolveAllVisualDomEditTargets returns the full z-stack; subsequent same-spot clicks
  advance through all selectable layers at that coordinate
- fallow-ignore-next-line complexity on pre-existing complex functions surfaced by
  branching from fix/gsap-fromto-panel rather than main

Closes #1124, #1125

* fix(studio): address Vai+Rames follow-up notes on hf#1122

- extract buildTweenSummary to gsapAnimationHelpers.ts (now testable)
- add tests for all buildTweenSummary branches including fromTo
- extract requireAnimation/requireFromToAnimation helpers in files.ts,
  eliminating the parse→find→guard pattern repeated across three switch
  cases and removing the fallow-ignore-next-line complexity bypass
- add 400 guard: add mutation with fromProperties on non-fromTo method
  now returns 400 instead of silently dropping fromProperties
- add test for the 400 guard

* fix(studio): buildTweenSummary formats percent props as 0-100% not 0-1

* fix(studio): show all .html files as compositions in sidebar

The Comps sidebar only listed index.html and files under a compositions/
subdirectory. Any other .html file in the project root was invisible and
could not be loaded as a composition preview.

Broadened the filter in useFileManager and the activeCompPath guard in
App.tsx to treat every .html file as a selectable composition.

Also excluded App.tsx from the filesize pre-commit check — the file is
already 652 lines (decomposition tracked in PR #724).

* fix(studio): detect compositions by data-composition-id, not path convention

The previous approach filtered compositions by path convention (index.html
or compositions/ subdirectory). Any .html file outside that convention was
invisible in the Comps sidebar.

The server now scans each .html file for data-composition-id and returns
a compositions[] field in the project API response. The client uses this
server-provided list instead of filtering locally. This means any .html
file that is a real HyperFrames composition shows up regardless of where
it lives in the project tree.

* fix(studio): rename Ask agent to Copy prompt to AI agent, show context preview

Updated the property panel button label from "Ask agent" to "Copy prompt
to AI agent". Updated the modal title to match. Added a collapsible
"Context included in prompt" details section to the modal that shows the
element metadata that will be included when copying.

* fix(studio): wire contextPreview to agent modal

Passes composition path, source file, selector, tag, and text content
to the AskAgentModal so the context preview section is visible.

* fix(core): seek timeline to current time after initial bind

When bindRootTimelineIfAvailable captured a GSAP timeline for the first
time, it paused it but never seeked to state.currentTime. This left
fromTo tweens stuck at their immediateRender "from" state (e.g. opacity 0)
even after the user scrubbed past the tween's end. The polling rebind
path already seeked to previousTime — the initial bind was the only path
that skipped it.

* feat(core): add gsap_timeline_not_registered lint rule

Warns when a composition creates gsap.timeline() but never registers it
in window.__timelines. Without registration, the runtime cannot discover
the timeline, and animations will not play during preview or render.

Skips the warning for sub-compositions (template-based) which inherit
the parent's timeline context.

* fix(studio): address hf#1126 review feedback

- Extract buildAgentContextPreview into domEditingAgentPrompt.ts and
  import it in App.tsx, removing the inline computation that pushed
  App.tsx past the 600-line CI gate
- Switch isCompositionFile from sync readFileSync to async readFile with
  Promise.all, and use a regex test instead of string includes
- Move PERCENT_PROPS from AnimationCard.tsx and gsapAnimationHelpers.ts
  into gsapAnimationConstants.ts (single source of truth)
- Add regression test for the totalTime initial-bind seek fix in
  init.test.ts — verifies the captured timeline receives a totalTime
  call on initial bind

* refactor(studio): extract App.tsx below 600 LOC, remove lefthook exemption

Extracted inspector state, studio context construction, and drag overlay
into useStudioContextValue.ts. Deduplicated block handler args via a
shared blockCtx memo. App.tsx drops from 657 to 588 lines.

Removed the App.tsx exemption from lefthook.yml — the file now passes
the 600-line gate without special-casing. Added domEditing.ts barrel to
fallowrc ignoreExports (re-exports not traceable by static analysis).
2026-05-29 22:18:27 -04:00

474 lines
16 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from "react";
import type { TimelineElement } from "../player";
import {
getAllPreviewTargetsFromPointer,
getPreviewTargetFromPointer,
} from "../utils/studioPreviewHelpers";
import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers";
import {
domEditSelectionsTargetSame,
domEditSelectionInGroup,
toggleDomEditGroupSelection,
replaceDomEditGroupSelection,
seedDomEditGroupWithSelection,
} from "../utils/domEditHelpers";
import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability";
import {
findElementForSelection,
findElementForTimelineElement,
resolveDomEditSelection,
type DomEditSelection,
} from "../components/editor/domEditing";
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
// ── Types ──
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) => 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;
// Refs
domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
domEditGroupSelectionsRef: React.MutableRefObject<DomEditSelection[]>;
domEditHoverSelectionRef: React.MutableRefObject<DomEditSelection | null>;
// State setters (needed by useDomEditSession for agent-prompt reset flows)
setDomEditSelection: React.Dispatch<React.SetStateAction<DomEditSelection | null>>;
setDomEditGroupSelections: React.Dispatch<React.SetStateAction<DomEditSelection[]>>;
// Callbacks
applyDomSelection: (
selection: DomEditSelection | null,
options?: {
revealPanel?: boolean;
additive?: boolean;
preserveGroup?: boolean;
},
) => void;
clearDomSelection: () => void;
buildDomSelectionFromTarget: (
target: HTMLElement,
options?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>;
resolveDomSelectionFromPreviewPoint: (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean },
) => 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>;
}
// ── 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);
// ── Refs ──
const domEditSelectionRef = useRef<DomEditSelection | null>(domEditSelection);
const domEditGroupSelectionsRef = useRef<DomEditSelection[]>(domEditGroupSelections);
const domEditHoverSelectionRef = useRef<DomEditSelection | null>(domEditHoverSelection);
// Keep refs in sync with state
domEditSelectionRef.current = domEditSelection;
domEditGroupSelectionsRef.current = domEditGroupSelections;
domEditHoverSelectionRef.current = domEditHoverSelection;
// ── 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;
}
if (!STUDIO_INSPECTOR_PANELS_ENABLED) {
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);
if (nextSelection) {
if (options?.revealPanel !== false) {
setRightCollapsed(false);
if (rightPanelTab !== "layers") {
setRightPanelTab("design");
}
}
const nextSelectedTimelineId = findMatchingTimelineElementId(
nextSelection,
timelineElements,
);
setSelectedTimelineElementId(nextSelectedTimelineId);
return;
}
setSelectedTimelineElementId(null);
},
[
setSelectedTimelineElementId,
timelineElements,
setRightCollapsed,
setRightPanelTab,
rightPanelTab,
],
);
const clearDomSelection = useCallback(() => {
applyDomSelection(null, { revealPanel: false });
}, [applyDomSelection]);
const buildDomSelectionFromTarget = useCallback(
(
target: HTMLElement,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => {
return resolveDomEditSelection(target, {
activeCompositionPath: activeCompPath,
isMasterView,
preferClipAncestor: options?.preferClipAncestor,
skipSourceProbe: options?.skipSourceProbe,
projectId,
});
},
[activeCompPath, isMasterView, projectId],
);
const resolveDomSelectionFromPreviewPoint = useCallback(
// fallow-ignore-next-line complexity
async (
clientX: number,
clientY: number,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => {
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, {
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) => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
if (!element) {
applyDomSelection(null, { revealPanel: false });
return;
}
const selection = await buildDomSelectionForTimelineElement(element);
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) 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 on caption mode change
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (captionEditMode) updateDomEditHoverSelection(null);
}, [captionEditMode, updateDomEditHoverSelection]);
// Clear hover on composition/project/preview change
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
updateDomEditHoverSelection(null);
}, [activeCompPath, projectId, previewIframe, refreshKey, updateDomEditHoverSelection]);
// Clear hover when matching selection
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!domEditHoverSelection) return;
const hoverMatchesSelection = domEditSelectionsTargetSame(
domEditHoverSelection,
domEditSelection,
);
const hoverMatchesGroup = domEditSelectionInGroup(
domEditGroupSelections,
domEditHoverSelection,
);
if (!hoverMatchesSelection && !hoverMatchesGroup) return;
updateDomEditHoverSelection(null);
}, [
domEditGroupSelections,
domEditHoverSelection,
domEditSelection,
updateDomEditHoverSelection,
]);
// Clear hover when element disconnected
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!domEditHoverSelection) return;
if (domEditHoverSelection.element.isConnected) return;
updateDomEditHoverSelection(null);
}, [domEditHoverSelection, updateDomEditHoverSelection]);
// Clear selection on caption mode change
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!captionEditMode) return;
applyDomSelection(null, { revealPanel: false });
}, [applyDomSelection, captionEditMode]);
// Disabled inspector effect
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (STUDIO_INSPECTOR_PANELS_ENABLED) return;
updateDomEditHoverSelection(null);
applyDomSelection(null, { revealPanel: false });
if (rightPanelTab !== "renders") setRightPanelTab("renders");
}, [applyDomSelection, rightPanelTab, updateDomEditHoverSelection, setRightPanelTab]);
return {
// State
domEditSelection,
domEditGroupSelections,
domEditHoverSelection,
// Refs
domEditSelectionRef,
domEditGroupSelectionsRef,
domEditHoverSelectionRef,
// State setters
setDomEditSelection,
setDomEditGroupSelections,
// Callbacks
applyDomSelection,
clearDomSelection,
buildDomSelectionFromTarget,
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
refreshDomEditSelectionFromPreview,
refreshDomEditGroupSelectionsFromPreview,
};
}