mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
+63
-127
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user