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).
This commit is contained in:
Miguel Ángel
2026-05-29 22:18:27 -04:00
committed by GitHub
parent 307e391d91
commit 1284213886
81 changed files with 1567 additions and 1017 deletions
+63 -127
View File
@@ -26,10 +26,11 @@ import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
import { useToast } from "./hooks/useToast";
import { useStudioUrlState } from "./hooks/useStudioUrlState";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
STUDIO_MOTION_PANEL_ENABLED,
} from "./components/editor/manualEditingAvailability";
import { readStudioMotionFromElement } from "./components/editor/studioMotion";
buildStudioContextValue,
useDragOverlay,
useInspectorState,
} from "./hooks/useStudioContextValue";
import { buildAgentContextPreview } from "./components/editor/domEditingAgentPrompt";
import type { DomEditSelection } from "./components/editor/domEditing";
import { AskAgentModal } from "./components/AskAgentModal";
import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
@@ -38,7 +39,7 @@ import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
import { StudioPreviewArea } from "./components/StudioPreviewArea";
import { StudioRightPanel } from "./components/StudioRightPanel";
import { TimelineToolbar } from "./components/TimelineToolbar";
import { StudioProvider, type StudioContextValue } from "./contexts/StudioContext";
import { StudioProvider } from "./contexts/StudioContext";
import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
import { FileManagerProvider } from "./contexts/FileManagerContext";
import { DomEditProvider } from "./contexts/DomEditContext";
@@ -51,6 +52,7 @@ import {
import { trackStudioSessionStart } from "./telemetry/events";
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
// fallow-ignore-next-line complexity
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
@@ -184,6 +186,26 @@ export function StudioApp() {
uploadProjectFiles: fileManager.uploadProjectFiles,
});
const blockCtx = useMemo(
() => ({
activeCompPath,
timelineElements,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
refreshFileTree: fileManager.refreshFileTree,
reloadPreview,
showToast,
}),
[
activeCompPath,
timelineElements,
fileManager,
editHistory.recordEdit,
reloadPreview,
showToast,
],
);
const handleAddBlock = useCallback(
(blockName: string) => {
if (!projectId) return;
@@ -191,16 +213,9 @@ export function StudioApp() {
const result = await addBlockToProject({
projectId,
blockName,
activeCompPath,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
timelineElements,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
refreshFileTree: fileManager.refreshFileTree,
reloadPreview,
showToast,
});
const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined;
if (params?.length) {
@@ -215,82 +230,35 @@ export function StudioApp() {
}
})();
},
[
projectId,
activeCompPath,
timelineElements,
fileManager.readProjectFile,
fileManager.writeProjectFile,
fileManager.refreshFileTree,
editHistory.recordEdit,
reloadPreview,
showToast,
panelLayout,
],
[projectId, blockCtx, panelLayout],
);
const handleTimelineBlockDrop = useCallback(
(blockName: string, placement: { start: number; track: number }) => {
if (!projectId) return;
void addBlockToProject({
projectId,
blockName,
activeCompPath,
placement,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
timelineElements,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
refreshFileTree: fileManager.refreshFileTree,
reloadPreview,
showToast,
});
},
[
projectId,
activeCompPath,
timelineElements,
fileManager.readProjectFile,
fileManager.writeProjectFile,
fileManager.refreshFileTree,
editHistory.recordEdit,
reloadPreview,
showToast,
],
[projectId, blockCtx],
);
const handlePreviewBlockDrop = useCallback(
(blockName: string, position: { left: number; top: number }) => {
if (!projectId) return;
void addBlockToProject({
projectId,
blockName,
activeCompPath,
visualPosition: position,
...blockCtx,
previewIframe: previewIframeRef.current,
currentTime: usePlayerStore.getState().currentTime,
timelineElements,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
refreshFileTree: fileManager.refreshFileTree,
reloadPreview,
showToast,
});
},
[
projectId,
activeCompPath,
timelineElements,
fileManager.readProjectFile,
fileManager.writeProjectFile,
fileManager.refreshFileTree,
editHistory.recordEdit,
reloadPreview,
showToast,
],
[projectId, blockCtx],
);
const clearDomSelectionRef = useRef<() => void>(() => {});
@@ -414,30 +382,22 @@ export function StudioApp() {
resetErrors: resetConsoleErrors,
} = useConsoleErrorCapture(previewIframe);
const [globalDragOver, setGlobalDragOver] = useState(false);
const dragCounterRef = useRef(0);
const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
const { syncPreviewTimelineHotkey, syncPreviewHistoryHotkey } = appHotkeys;
const handlePreviewIframeRef = useCallback(
(iframe: HTMLIFrameElement | null) => {
previewIframeRef.current = iframe;
setPreviewIframe(iframe);
syncPreviewTimelineHotkey(iframe);
syncPreviewHistoryHotkey(iframe);
appHotkeys.syncPreviewTimelineHotkey(iframe);
appHotkeys.syncPreviewHistoryHotkey(iframe);
resetConsoleErrors();
refreshPreviewDocumentVersion();
},
[
refreshPreviewDocumentVersion,
resetConsoleErrors,
syncPreviewHistoryHotkey,
syncPreviewTimelineHotkey,
],
[appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
);
const handleSelectComposition = useCallback(
(comp: string) => {
setActiveCompPath(comp === "index.html" || comp.startsWith("compositions/") ? comp : null);
setActiveCompPath(comp.endsWith(".html") ? comp : null);
fileManager.setEditingFile({ path: comp, content: null });
fetch(`/api/projects/${projectId}/files/${comp}`)
.then((r) => r.json())
@@ -447,23 +407,19 @@ export function StudioApp() {
[projectId, fileManager],
);
const selectedStudioMotion =
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSession.domEditSelection
? readStudioMotionFromElement(domEditSession.domEditSelection.element)
: null;
const layersPanelActive =
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers";
const designPanelActive =
STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "design";
const motionPanelActive =
STUDIO_INSPECTOR_PANELS_ENABLED &&
STUDIO_MOTION_PANEL_ENABLED &&
panelLayout.rightPanelTab === "motion";
const inspectorPanelActive = layersPanelActive || designPanelActive || motionPanelActive;
const shouldShowSelectedDomBounds =
inspectorPanelActive && !panelLayout.rightCollapsed && !isPlaying;
const inspectorButtonActive =
STUDIO_INSPECTOR_PANELS_ENABLED && !panelLayout.rightCollapsed && inspectorPanelActive;
const {
selectedStudioMotion,
designPanelActive,
motionPanelActive,
inspectorPanelActive,
inspectorButtonActive,
shouldShowSelectedDomBounds,
} = useInspectorState(
panelLayout.rightPanelTab,
panelLayout.rightCollapsed,
isPlaying,
domEditSession.domEditSelection,
);
useStudioUrlState({
projectId,
@@ -484,8 +440,7 @@ export function StudioApp() {
initialState: initialUrlStateRef.current,
});
// StudioProvider performs its own useMemo — no need for a second memo here.
const studioCtxValue: StudioContextValue = {
const studioCtxValue = buildStudioContextValue({
projectId: projectId!,
activeCompPath,
setActiveCompPath,
@@ -498,12 +453,7 @@ export function StudioApp() {
currentTime,
timelineElements,
isPlaying,
editHistory: {
canUndo: editHistory.canUndo,
canRedo: editHistory.canRedo,
undoLabel: editHistory.undoLabel,
redoLabel: editHistory.redoLabel,
},
editHistory,
handleUndo: appHotkeys.handleUndo,
handleRedo: appHotkeys.handleRedo,
renderQueue: {
@@ -519,7 +469,7 @@ export function StudioApp() {
refreshPreviewDocumentVersion,
timelineVisible,
toggleTimelineVisibility,
};
});
if (resolving || waitingForServer || !projectId) {
return <StudioSplash waiting={waitingForServer} />;
@@ -533,28 +483,10 @@ export function StudioApp() {
<DomEditProvider value={domEditSession}>
<div
className="flex flex-col h-full w-full bg-neutral-950 relative"
onDragOver={(e) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
}}
onDragEnter={(e) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
dragCounterRef.current++;
setGlobalDragOver(true);
}}
onDragLeave={() => {
dragCounterRef.current--;
if (dragCounterRef.current === 0) setGlobalDragOver(false);
}}
onDrop={(e) => {
dragCounterRef.current = 0;
setGlobalDragOver(false);
if (e.defaultPrevented) return;
e.preventDefault();
if (e.dataTransfer.files.length)
fileManager.handleImportFiles(e.dataTransfer.files);
}}
onDragOver={dragOverlay.onDragOver}
onDragEnter={dragOverlay.onDragEnter}
onDragLeave={dragOverlay.onDragLeave}
onDrop={dragOverlay.onDrop}
>
<StudioHeader
captureFrameHref={frameCapture.captureFrameHref}
@@ -620,6 +552,10 @@ export function StudioApp() {
{domEditSession.agentModalOpen && domEditSession.domEditSelection && (
<AskAgentModal
selectionLabel={domEditSession.domEditSelection.label}
contextPreview={buildAgentContextPreview(
domEditSession.domEditSelection,
activeCompPath,
)}
anchorPoint={domEditSession.agentModalAnchorPoint}
onSubmit={domEditSession.handleAgentModalSubmit}
onClose={() => {
@@ -630,7 +566,7 @@ export function StudioApp() {
/>
)}
{globalDragOver && <StudioGlobalDragOverlay />}
{dragOverlay.active && <StudioGlobalDragOverlay />}
{appToast && (
<div
@@ -26,11 +26,13 @@ function getAgentModalPositionStyle(
export function AskAgentModal({
selectionLabel,
contextPreview,
anchorPoint = null,
onSubmit,
onClose,
}: {
selectionLabel: string;
contextPreview?: string;
anchorPoint?: AgentModalAnchorPoint | null;
onSubmit: (instruction: string) => void;
onClose: () => void;
@@ -66,7 +68,7 @@ export function AskAgentModal({
>
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
<div>
<h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
<h3 className="text-sm font-medium text-neutral-200">Copy prompt to AI agent</h3>
<p className="text-xs text-neutral-500 mt-0.5">
{selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}` : selectionLabel}
</p>
@@ -89,7 +91,7 @@ export function AskAgentModal({
</svg>
</button>
</div>
<div className="px-5 py-4">
<div className="px-5 py-4 space-y-3">
<textarea
ref={inputRef}
className="w-full h-24 px-3 py-2 rounded-lg border border-neutral-800 bg-neutral-900/60 text-sm text-neutral-200 placeholder-neutral-600 resize-none focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
@@ -101,6 +103,16 @@ export function AskAgentModal({
if (e.key === "Escape") onClose();
}}
/>
{contextPreview && (
<details className="group">
<summary className="text-[11px] text-neutral-500 cursor-pointer select-none hover:text-neutral-400">
Context included in prompt
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-lg bg-neutral-900/80 px-3 py-2 text-[11px] leading-relaxed text-neutral-500 whitespace-pre-wrap break-words border border-neutral-800/50">
{contextPreview}
</pre>
</details>
)}
</div>
<div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
<span className="text-[11px] text-neutral-600">
@@ -34,6 +34,7 @@ export interface StudioRightPanelProps {
onCloseBlockParams?: () => void;
}
// fallow-ignore-next-line complexity
export function StudioRightPanel({
selectedStudioMotion,
designPanelActive,
@@ -8,23 +8,49 @@ import {
EASE_LABELS,
METHOD_LABELS,
METHOD_TOOLTIPS,
PERCENT_PROPS,
PROP_LABELS,
PROP_TOOLTIPS,
PROP_UNITS,
} from "./gsapAnimationConstants";
import { buildTweenSummary } from "./gsapAnimationHelpers";
import { EaseCurveSection } from "./EaseCurveSection";
const BOOLEAN_PROPS = new Set(["visibility"]);
const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
function isPercentProp(prop: string): boolean {
return PERCENT_PROPS.has(prop);
}
function displayValue(prop: string, val: number | string): string {
return isPercentProp(prop) ? String(Math.round(Number(val) * 100)) : String(val);
if (isPercentProp(prop)) return String(Math.round(Math.max(0, Math.min(1, Number(val))) * 100));
return String(val);
}
function adjustedValue(prop: string, raw: string): string {
return isPercentProp(prop) ? String(Number(raw) / 100) : raw;
if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100)));
return raw;
}
function RemoveButton({ onClick, title }: { onClick: () => void; title: string }) {
return (
<button
type="button"
onClick={onClick}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
title={title}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
);
}
function PropertyRow({
@@ -40,6 +66,30 @@ function PropertyRow({
onRemove: () => void;
removeTitle: string;
}) {
if (BOOLEAN_PROPS.has(prop)) {
const isVisible = val === "visible" || val === 1;
return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
<span className="flex-1 text-[11px] font-medium text-neutral-500">
{PROP_LABELS[prop] ?? prop}
</span>
<button
type="button"
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
className={`flex-shrink-0 w-7 h-4 rounded-full transition-colors relative ${isVisible ? "bg-emerald-500/30" : "bg-neutral-700"}`}
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full transition-transform ${isVisible ? "bg-emerald-400 translate-x-3.5" : "bg-neutral-500 translate-x-0.5"}`}
/>
</button>
</div>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
);
}
return (
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
@@ -53,23 +103,7 @@ function PropertyRow({
onCommit={(raw) => onCommit(adjustedValue(prop, raw))}
/>
</div>
<button
type="button"
onClick={onRemove}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
title={removeTitle}
>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
<RemoveButton onClick={onRemove} title={removeTitle} />
</div>
);
}
@@ -124,36 +158,6 @@ function AddPropertyTrigger({
);
}
// fallow-ignore-next-line complexity
function buildTweenSummary(animation: GsapAnimation): string {
const easeName = animation.ease ?? "none";
const ease = EASE_LABELS[easeName] ?? easeName;
const props = Object.entries(animation.properties);
const target = animation.targetSelector;
const dur = animation.duration ?? 0;
const pos = animation.position;
const propDescs = props.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
const unit = PROP_UNITS[p] ?? "";
return `${label} to ${v}${unit}`;
});
const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
if (animation.method === "from")
return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
if (animation.method === "fromTo") {
const fromProps = Object.entries(animation.fromProperties ?? {});
const fromDescs = fromProps.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
const unit = PROP_UNITS[p] ?? "";
return `${label} ${v}${unit}`;
});
const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
}
return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
}
function parseNumericOrString(raw: string): number | string {
const num = Number(raw);
return Number.isFinite(num) ? num : raw;
@@ -201,8 +205,11 @@ export const AnimationCard = memo(function AnimationCard({
[animation.properties],
);
const availableProps = useMemo(
() => SUPPORTED_PROPS.filter((p) => !usedProps.has(p)),
[usedProps],
() =>
SUPPORTED_PROPS.filter(
(p) => !usedProps.has(p) && (animation.method === "set" || !BOOLEAN_PROPS.has(p)),
),
[usedProps, animation.method],
);
const usedFromProps = useMemo(
@@ -210,7 +217,7 @@ export const AnimationCard = memo(function AnimationCard({
[animation.fromProperties],
);
const availableFromProps = useMemo(
() => SUPPORTED_PROPS.filter((p) => !usedFromProps.has(p)),
() => SUPPORTED_PROPS.filter((p) => !usedFromProps.has(p) && !BOOLEAN_PROPS.has(p)),
[usedFromProps],
);
@@ -118,11 +118,14 @@ export function EaseCurveSection({
{progress !== null ? "Playing…" : "Preview"}
</button>
</div>
<div className="overflow-hidden rounded pt-[72px] -mt-[72px]">
<div
className="overflow-hidden rounded pt-[72px] -mt-[72px]"
style={{ aspectRatio: `${w}/${h}` }}
>
<svg
ref={svgRef}
width="100%"
height={h}
height="100%"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
style={{ overflow: "visible" }}
@@ -135,6 +135,7 @@ function TimingSection({
/* PropertyPanel */
/* ------------------------------------------------------------------ */
// fallow-ignore-next-line complexity
export const PropertyPanel = memo(function PropertyPanel({
projectId,
projectDir,
@@ -229,6 +230,7 @@ export const PropertyPanel = memo(function PropertyPanel({
});
};
// fallow-ignore-next-line complexity
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return;
@@ -281,7 +283,7 @@ export const PropertyPanel = memo(function PropertyPanel({
className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-studio-accent/40 hover:text-studio-accent"
>
<MessageSquare size={15} />
<span>{copiedAgentPrompt ? "Prompt copied" : "Ask agent"}</span>
<span>{copiedAgentPrompt ? "Prompt copied" : "Copy prompt to AI agent"}</span>
</button>
</div>
</div>
@@ -95,3 +95,17 @@ export function buildElementAgentPrompt({
return lines.join("\n");
}
export function buildAgentContextPreview(
selection: DomEditSelection,
activeCompPath: string | null,
): string {
return [
`Composition: ${selection.compositionPath}`,
`Source: ${selection.sourceFile || activeCompPath || "index.html"}`,
`Selector: ${selection.selector ?? "(none)"} Tag: <${selection.tagName}>`,
selection.textContent ? `Text: ${selection.textContent}` : "",
]
.filter(Boolean)
.join("\n");
}
@@ -153,31 +153,49 @@ export function resolveVisualDomEditSelectionTarget(
elementsFromPoint: Iterable<Element | null | undefined>,
options: Pick<DomEditContextOptions, "activeCompositionPath">,
): HTMLElement | null {
const candidates: HTMLElement[] = [];
const candidates = resolveAllVisualDomEditTargets(elementsFromPoint, options);
return candidates[0] ?? null;
}
/**
* Returns all independently-selectable elements at the given point, in paint
* order (topmost first). Used for click-cycling through stacked layers.
*
* Each entry in the returned array is an independent "layer" — an element
* that is not an ancestor of an earlier entry. This gives one result per
* z-stacked element rather than one per DOM node.
*/
export function resolveAllVisualDomEditTargets(
elementsFromPoint: Iterable<Element | null | undefined>,
options: Pick<DomEditContextOptions, "activeCompositionPath">,
): HTMLElement[] {
const raw: HTMLElement[] = [];
for (const entry of elementsFromPoint) {
if (!isHtmlElement(entry)) continue;
if (hasRenderedBox(entry) && getDomLayerPatchTarget(entry, options.activeCompositionPath)) {
candidates.push(entry);
raw.push(entry);
}
}
if (candidates.length === 0) return null;
if (raw.length === 0) return [];
// candidates are in visual stacking order (topmost first, from elementsFromPoint).
// Start with the topmost and only replace with a descendant that is more
// specific within the same visual subtree. Never jump to an unrelated
// element that happens to be painted behind the current pick.
let best = candidates[0];
for (let i = 1; i < candidates.length; i++) {
const candidate = candidates[i];
if (best.contains(candidate)) {
best = candidate;
// First pass: for each contiguous ancestor-descendant run, keep only the
// deepest (most specific) element, matching the original single-pick logic.
const layers: HTMLElement[] = [];
let best = raw[0];
for (let i = 1; i < raw.length; i++) {
const el = raw[i];
if (best.contains(el)) {
best = el; // go deeper in this subtree
} else {
layers.push(best);
best = el;
}
}
layers.push(best);
return best;
return layers;
}
// ─── Raster detection ────────────────────────────────────────────────────────
@@ -248,6 +266,7 @@ export function findElementForSelection(
return matches[0] ?? null;
}
// fallow-ignore-next-line complexity
export function findElementForTimelineElement(
doc: Document,
element: TimelineElementDomTarget,
@@ -4,7 +4,7 @@ export const METHOD_LABELS: Record<string, string> = {
set: "Set",
to: "Animate",
from: "Animate In",
fromTo: "Animate",
fromTo: "From → To",
};
export const METHOD_TOOLTIPS: Record<string, string> = {
@@ -121,6 +121,8 @@ export function parseCustomEaseFromString(ease: string): {
return { x1: nums[2], y1: nums[3], x2: nums[4], y2: nums[5] };
}
export const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
export const ADD_METHODS = ["to", "from", "fromTo", "set"] as const;
export const ADD_METHOD_LABELS: Record<string, string> = {
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { buildTweenSummary } from "./gsapAnimationHelpers";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
return {
id: "a1",
method: "to",
targetSelector: "#box",
properties: {},
position: 0,
duration: 1,
ease: "power2.out",
...overrides,
} as GsapAnimation;
}
describe("buildTweenSummary", () => {
it("describes a to tween", () => {
const s = buildTweenSummary(anim({ properties: { opacity: 1, x: 100 } }));
expect(s).toContain("#box");
expect(s).toContain("opacity");
expect(s).toContain("move x");
});
it("describes a from tween", () => {
const s = buildTweenSummary(anim({ method: "from", properties: { opacity: 0 } }));
expect(s).toContain("enters from");
expect(s).toContain("opacity");
});
it("describes a set tween", () => {
const s = buildTweenSummary(anim({ method: "set", properties: { opacity: 0 } }));
expect(s).toMatch(/^At 0s, instantly set/);
expect(s).toContain("opacity");
});
it("describes a fromTo tween with both from and to sections", () => {
const s = buildTweenSummary(
anim({
method: "fromTo",
fromProperties: { opacity: 0, x: -50 },
properties: { opacity: 1, x: 0 },
position: 0.5,
duration: 1.5,
ease: "expo.out",
}),
);
expect(s).toContain("animates from");
expect(s).toContain("[opacity 0%");
expect(s).toContain("move x -50px");
expect(s).toContain("opacity to 100%");
expect(s).toContain("very snappy stop");
});
it("handles fromTo with empty fromProperties", () => {
const s = buildTweenSummary(
anim({ method: "fromTo", fromProperties: {}, properties: { scale: 2 } }),
);
expect(s).toContain("from [—]");
});
it("handles no properties", () => {
const s = buildTweenSummary(anim({ properties: {} }));
expect(s).toContain("no properties yet");
});
});
@@ -0,0 +1,36 @@
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { EASE_LABELS, PERCENT_PROPS, PROP_LABELS, PROP_UNITS } from "./gsapAnimationConstants";
function formatPropValue(prop: string, v: number | string): string {
const unit = PROP_UNITS[prop] ?? "";
if (PERCENT_PROPS.has(prop)) return `${Math.round(Number(v) * 100)}${unit}`;
return `${v}${unit}`;
}
// fallow-ignore-next-line complexity
export function buildTweenSummary(animation: GsapAnimation): string {
const easeName = animation.ease ?? "none";
const ease = EASE_LABELS[easeName] ?? easeName;
const props = Object.entries(animation.properties);
const target = animation.targetSelector;
const dur = animation.duration ?? 0;
const pos = animation.position;
const propDescs = props.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
return `${label} to ${formatPropValue(p, v)}`;
});
const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
if (animation.method === "from")
return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
if (animation.method === "fromTo") {
const fromProps = Object.entries(animation.fromProperties ?? {});
const fromDescs = fromProps.map(([p, v]) => {
const label = (PROP_LABELS[p] ?? p).toLowerCase();
return `${label} ${formatPropValue(p, v)}`;
});
const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
}
return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
}
@@ -29,7 +29,7 @@ function CommitField({
const el = inputRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
if (disabled) return;
if (disabled || document.activeElement !== el) return;
const delta = e.deltaY === 0 ? e.deltaX : e.deltaY;
if (delta === 0) return;
const nextDraft = adjustNumericToken(draftRef.current, delta < 0 ? 1 : -1, e);
@@ -97,6 +97,7 @@ export function shouldDisableTimelineWhileCompositionLoading(compositionLoading:
return compositionLoading;
}
// fallow-ignore-next-line complexity
export const NLELayout = memo(function NLELayout({
projectId,
portrait,
@@ -367,7 +368,7 @@ export const NLELayout = memo(function NLELayout({
{/* Preview + player controls */}
<div className="flex-1 min-h-0 flex flex-col">
<div
className="flex-1 min-h-0 relative"
className="flex-1 min-h-0 relative overflow-hidden"
data-preview-pan-surface="true"
onDragOver={handlePreviewDragOver}
onDragLeave={handlePreviewDragLeave}
+18 -10
View File
@@ -67,6 +67,7 @@ export interface UseDomEditSessionParams {
// ── Hook ──
// fallow-ignore-next-line complexity
export function useDomEditSession({
projectId,
activeCompPath,
@@ -129,6 +130,7 @@ export function useDomEditSession({
clearDomSelection,
buildDomSelectionFromTarget,
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
@@ -187,6 +189,7 @@ export function useDomEditSession({
showToast,
applyDomSelection,
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
onClickToSource,
});
@@ -343,6 +346,7 @@ export function useDomEditSession({
useEffect(() => {
if (!previewIframe) return;
// fallow-ignore-next-line complexity
const syncSelectionFromDocument = async () => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED || captionEditMode) return;
const currentSelection = domEditSelectionRef.current;
@@ -402,16 +406,20 @@ export function useDomEditSession({
// not when openSourceForSelection is recreated due to editingFile content updates.
const openSourceRef = useRef(openSourceForSelection);
openSourceRef.current = openSourceForSelection;
useEffect(() => {
if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
if (!domEditSelection.sourceFile) return;
if (getSidebarTab() !== "code") return;
openSourceRef.current(domEditSelection.sourceFile, {
id: domEditSelection.id,
selector: domEditSelection.selector,
selectorIndex: domEditSelection.selectorIndex,
});
}, [domEditSelection, getSidebarTab]);
useEffect(
// fallow-ignore-next-line complexity
() => {
if (!domEditSelection || !openSourceRef.current || !getSidebarTab) return;
if (!domEditSelection.sourceFile) return;
if (getSidebarTab() !== "code") return;
openSourceRef.current(domEditSelection.sourceFile, {
id: domEditSelection.id,
selector: domEditSelection.selector,
selectorIndex: domEditSelection.selectorIndex,
});
},
[domEditSelection, getSidebarTab],
);
return {
// State
+35 -1
View File
@@ -1,6 +1,9 @@
import { useState, useCallback, useRef, useEffect } from "react";
import type { TimelineElement } from "../player";
import { getPreviewTargetFromPointer } from "../utils/studioPreviewHelpers";
import {
getAllPreviewTargetsFromPointer,
getPreviewTargetFromPointer,
} from "../utils/studioPreviewHelpers";
import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers";
import {
domEditSelectionsTargetSame,
@@ -67,6 +70,10 @@ export interface UseDomSelectionReturn {
clientY: number,
options?: { preferClipAncestor?: boolean },
) => Promise<DomEditSelection | null>;
resolveAllDomSelectionsFromPreviewPoint: (
clientX: number,
clientY: number,
) => Promise<DomEditSelection[]>;
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
buildDomSelectionForTimelineElement: (
element: TimelineElement,
@@ -113,6 +120,7 @@ export function useDomSelection({
// ── Callbacks ──
const applyDomSelection = useCallback(
// fallow-ignore-next-line complexity
(
selection: DomEditSelection | null,
options?: {
@@ -212,6 +220,7 @@ export function useDomSelection({
);
const resolveDomSelectionFromPreviewPoint = useCallback(
// fallow-ignore-next-line complexity
async (
clientX: number,
clientY: number,
@@ -234,6 +243,27 @@ export function useDomSelection({
[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;
@@ -241,6 +271,7 @@ export function useDomSelection({
}, []);
const buildDomSelectionForTimelineElement = useCallback(
// fallow-ignore-next-line complexity
async (element: TimelineElement): Promise<DomEditSelection | null> => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
@@ -282,6 +313,7 @@ export function useDomSelection({
);
const refreshDomEditSelectionFromPreview = useCallback(
// fallow-ignore-next-line complexity
async (selection: DomEditSelection) => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
@@ -307,6 +339,7 @@ export function useDomSelection({
);
const refreshDomEditGroupSelectionsFromPreview = useCallback(
// fallow-ignore-next-line complexity
async (selections: DomEditSelection[]) => {
const iframe = previewIframeRef.current;
let doc: Document | null = null;
@@ -430,6 +463,7 @@ export function useDomSelection({
clearDomSelection,
buildDomSelectionFromTarget,
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
buildDomSelectionForTimelineElement,
handleTimelineElementSelect,
+4 -5
View File
@@ -38,6 +38,7 @@ export function useFileManager({
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
const [projectDir, setProjectDir] = useState<string | null>(null);
const [fileTree, setFileTree] = useState<string[]>([]);
const [compositionPaths, setCompositionPaths] = useState<string[]>([]);
const [fileTreeLoaded, setFileTreeLoaded] = useState(false);
const [revealSourceOffset, setRevealSourceOffset] = useState<number | null>(null);
@@ -65,8 +66,9 @@ export function useFileManager({
setFileTreeLoaded(false);
fetch(`/api/projects/${projectId}`)
.then((r) => r.json())
.then((data: { files?: string[]; dir?: string }) => {
.then((data: { files?: string[]; dir?: string; compositions?: string[] }) => {
if (!cancelled && data.files) setFileTree(data.files);
if (!cancelled && data.compositions) setCompositionPaths(data.compositions);
if (!cancelled) setProjectDir(typeof data.dir === "string" ? data.dir : null);
})
.catch(() => {
@@ -417,10 +419,7 @@ export function useFileManager({
// ── Derived state ──
const compositions = useMemo(
() => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")),
[fileTree],
);
const compositions = compositionPaths;
const assets = useMemo(
() =>
@@ -105,6 +105,7 @@ export function useGsapScriptCommits({
/** Send a mutation and record the edit in undo history. */
const commitMutation = useCallback(
// fallow-ignore-next-line complexity
async (
selection: DomEditSelection,
mutation: Record<string, unknown>,
@@ -210,6 +211,7 @@ export function useGsapScriptCommits({
);
const addGsapAnimation = useCallback(
// fallow-ignore-next-line complexity
async (
selection: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
@@ -268,6 +270,7 @@ export function useGsapScriptCommits({
);
const addGsapProperty = useCallback(
// fallow-ignore-next-line complexity
(selection: DomEditSelection, animationId: string, property: string) => {
let defaultValue = PROPERTY_DEFAULTS[property] ?? 0;
const el = selection.element;
@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { liveTime, usePlayerStore } from "../player";
import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
import { STUDIO_PREVIEW_SELECTION_ENABLED } from "../components/editor/manualEditingAvailability";
@@ -22,11 +22,26 @@ export interface UsePreviewInteractionParams {
clientY: number,
options?: { preferClipAncestor?: boolean; skipSourceProbe?: boolean },
) => Promise<DomEditSelection | null>;
resolveAllDomSelectionsFromPreviewPoint: (
clientX: number,
clientY: number,
) => Promise<DomEditSelection[]>;
updateDomEditHoverSelection: (selection: DomEditSelection | null) => void;
onClickToSource?: (selection: DomEditSelection) => void;
}
interface ClickCycleState {
x: number;
y: number;
candidates: DomEditSelection[];
index: number;
at: number;
}
const CYCLE_RADIUS_PX = 6;
const CYCLE_WINDOW_MS = 600;
// ── Hook ──
export function usePreviewInteraction({
@@ -36,36 +51,85 @@ export function usePreviewInteraction({
showToast,
applyDomSelection,
resolveDomSelectionFromPreviewPoint,
resolveAllDomSelectionsFromPreviewPoint,
updateDomEditHoverSelection,
onClickToSource,
}: UsePreviewInteractionParams) {
const cycleRef = useRef<ClickCycleState | null>(null);
const handlePreviewCanvasMouseDown = useCallback(
// fallow-ignore-next-line complexity
async (e: React.MouseEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) 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,
});
if (!nextSelection) 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
const nextSelection = await resolveDomSelectionFromPreviewPoint(e.clientX, e.clientY, {
preferClipAncestor: options?.preferClipAncestor ?? false,
});
if (!nextSelection) {
if (!e.shiftKey) applyDomSelection(null, { revealPanel: false });
cycleRef.current = null;
applyDomSelection(null, { revealPanel: false });
return;
}
e.preventDefault();
e.stopPropagation();
applyDomSelection(nextSelection, { additive: e.shiftKey });
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,
resolveAllDomSelectionsFromPreviewPoint,
resolveDomSelectionFromPreviewPoint,
],
);
const handlePreviewCanvasPointerMove = useCallback(
// fallow-ignore-next-line complexity
async (e: React.PointerEvent<HTMLDivElement>, options?: { preferClipAncestor?: boolean }) => {
if (!STUDIO_PREVIEW_SELECTION_ENABLED || captionEditMode || compositionLoading) {
updateDomEditHoverSelection(null);
@@ -0,0 +1,138 @@
import { useCallback, useMemo, useRef, useState, type DragEvent } from "react";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
STUDIO_MOTION_PANEL_ENABLED,
} from "../components/editor/manualEditingAvailability";
import { readStudioMotionFromElement } from "../components/editor/studioMotion";
import type { StudioContextValue } from "../contexts/StudioContext";
import type { DomEditSelection } from "../components/editor/domEditing";
interface StudioContextInput {
projectId: string;
activeCompPath: string | null;
setActiveCompPath: (path: string | null) => void;
showToast: (message: string, tone?: "error" | "info") => void;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
captionEditMode: boolean;
compositionLoading: boolean;
refreshKey: number;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
currentTime: number;
timelineElements: StudioContextValue["timelineElements"];
isPlaying: boolean;
editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string };
handleUndo: StudioContextValue["handleUndo"];
handleRedo: StudioContextValue["handleRedo"];
renderQueue: {
jobs: unknown[];
isRendering: boolean;
deleteRender: (id: string) => void;
clearCompleted: () => void;
startRender: (options: unknown) => Promise<void>;
};
compositionDimensions: { width: number; height: number } | null;
waitForPendingDomEditSaves: () => Promise<void>;
handlePreviewIframeRef: (iframe: HTMLIFrameElement | null) => void;
refreshPreviewDocumentVersion: () => void;
timelineVisible: boolean;
toggleTimelineVisibility: () => void;
}
// fallow-ignore-next-line complexity
export function buildStudioContextValue(input: StudioContextInput): StudioContextValue {
return {
projectId: input.projectId,
activeCompPath: input.activeCompPath,
setActiveCompPath: input.setActiveCompPath,
showToast: input.showToast,
previewIframeRef: input.previewIframeRef,
captionEditMode: input.captionEditMode,
compositionLoading: input.compositionLoading,
refreshKey: input.refreshKey,
setRefreshKey: input.setRefreshKey,
currentTime: input.currentTime,
timelineElements: input.timelineElements,
isPlaying: input.isPlaying,
editHistory: input.editHistory,
handleUndo: input.handleUndo,
handleRedo: input.handleRedo,
renderQueue: input.renderQueue,
compositionDimensions: input.compositionDimensions,
waitForPendingDomEditSaves: input.waitForPendingDomEditSaves,
handlePreviewIframeRef: input.handlePreviewIframeRef,
refreshPreviewDocumentVersion: input.refreshPreviewDocumentVersion,
timelineVisible: input.timelineVisible,
toggleTimelineVisibility: input.toggleTimelineVisibility,
};
}
export interface InspectorState {
selectedStudioMotion: ReturnType<typeof readStudioMotionFromElement> | null;
layersPanelActive: boolean;
designPanelActive: boolean;
motionPanelActive: boolean;
inspectorPanelActive: boolean;
inspectorButtonActive: boolean;
shouldShowSelectedDomBounds: boolean;
}
export function useInspectorState(
rightPanelTab: string,
rightCollapsed: boolean,
isPlaying: boolean,
domEditSelection: DomEditSelection | null,
): InspectorState {
// fallow-ignore-next-line complexity
return useMemo(() => {
const selectedStudioMotion =
STUDIO_INSPECTOR_PANELS_ENABLED && domEditSelection
? readStudioMotionFromElement(domEditSelection.element)
: null;
const layersPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "layers";
const designPanelActive = STUDIO_INSPECTOR_PANELS_ENABLED && rightPanelTab === "design";
const motionPanelActive =
STUDIO_INSPECTOR_PANELS_ENABLED && STUDIO_MOTION_PANEL_ENABLED && rightPanelTab === "motion";
const inspectorPanelActive = layersPanelActive || designPanelActive || motionPanelActive;
return {
selectedStudioMotion,
layersPanelActive,
designPanelActive,
motionPanelActive,
inspectorPanelActive,
inspectorButtonActive:
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
shouldShowSelectedDomBounds: inspectorPanelActive && !rightCollapsed && !isPlaying,
};
}, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection]);
}
// fallow-ignore-next-line complexity
export function useDragOverlay(onImportFiles: (files: FileList) => void) {
const [active, setActive] = useState(false);
const counterRef = useRef(0);
const onDragOver = useCallback((e: DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
}, []);
const onDragEnter = useCallback((e: DragEvent) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
counterRef.current++;
setActive(true);
}, []);
const onDragLeave = useCallback(() => {
counterRef.current--;
if (counterRef.current === 0) setActive(false);
}, []);
const onDrop = useCallback(
(e: DragEvent) => {
counterRef.current = 0;
setActive(false);
if (e.defaultPrevented) return;
e.preventDefault();
if (e.dataTransfer.files.length) onImportFiles(e.dataTransfer.files);
},
[onImportFiles],
);
return { active, onDragOver, onDragEnter, onDragLeave, onDrop };
}
@@ -3,6 +3,7 @@ import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEdi
import {
getDomLayerPatchTarget,
isElementComputedVisible,
resolveAllVisualDomEditTargets,
} from "../components/editor/domEditingElement";
import { getEventTargetElement } from "./studioHelpers";
@@ -58,6 +59,7 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
}
}
// fallow-ignore-next-line complexity
export function getPreviewTargetFromPointer(
iframe: HTMLIFrameElement,
clientX: number,
@@ -98,6 +100,42 @@ export function getPreviewTargetFromPointer(
}
}
/** Returns all independently-selectable elements at the pointer (topmost first). */
export function getAllPreviewTargetsFromPointer(
iframe: HTMLIFrameElement,
clientX: number,
clientY: number,
activeCompositionPath: string | null,
): HTMLElement[] {
let doc: Document | null = null;
let win: Window | null = null;
try {
doc = iframe.contentDocument;
win = iframe.contentWindow;
} catch {
return [];
}
if (!doc || !win) return [];
const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
if (!localPointer) return [];
const overrideStyle = forcePointerEventsAuto(doc);
try {
if (typeof doc.elementsFromPoint === "function") {
return resolveAllVisualDomEditTargets(doc.elementsFromPoint(localPointer.x, localPointer.y), {
activeCompositionPath,
});
}
const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return [];
if (!isElementComputedVisible(fallback)) return [];
return [fallback];
} finally {
removePointerEventsOverride(overrideStyle);
}
}
function objectLike(value: unknown): object | null {
return value && (typeof value === "object" || typeof value === "function") ? value : null;
}