diff --git a/packages/studio/index.html b/packages/studio/index.html index 7e8dc88ae..b3659e076 100644 --- a/packages/studio/index.html +++ b/packages/studio/index.html @@ -7,7 +7,7 @@ HyperFrames Studio -
+
diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 8b7b5a803..d62cfb4f3 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -428,6 +428,7 @@ export function StudioApp() { domEditSelection: domEditSession.domEditSelection, buildDomSelectionFromTarget: domEditSession.buildDomSelectionFromTarget, applyDomSelection: domEditSession.applyDomSelection, + setRightPanelTab: panelLayout.setRightPanelTab, initialState: initialUrlStateRef.current, }); const studioCtxValue = buildStudioContextValue({ diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index b6b60ec3d..f998a8db2 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -8,7 +8,6 @@ import { import { getHistoryShortcutLabel } from "../utils/studioHelpers"; import { useStudioShellContext } from "../contexts/StudioContext"; import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; -import { useDomEditActionsContext } from "../contexts/DomEditContext"; import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext"; import { trackStudioEvent } from "../utils/studioTelemetry"; @@ -194,7 +193,6 @@ export function StudioHeader({ }: StudioHeaderProps) { const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext(); const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext(); - const { clearDomSelection } = useDomEditActionsContext(); return (
@@ -279,7 +277,8 @@ export function StudioHeader({ return; } trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true }); - clearDomSelection(); + // Keep the current selection when collapsing the Inspector — closing + // the panel shouldn't deselect the element. setRightCollapsed(true); }} disabled={!STUDIO_INSPECTOR_PANELS_ENABLED} diff --git a/packages/studio/src/components/editor/KeyframeNavigation.test.ts b/packages/studio/src/components/editor/KeyframeNavigation.test.ts new file mode 100644 index 000000000..0ce9a694d --- /dev/null +++ b/packages/studio/src/components/editor/KeyframeNavigation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { clipToTweenPercentage } from "./KeyframeNavigation"; + +/** + * Regression: keyframe add/remove are keyed by TWEEN-relative percentage (what the + * GSAP writer + runtime use), NOT the clip-relative playhead used for display/seek. + * The Layout-panel diamond used to emit clip-relative %, so the mutation missed + * every keyframe (off by the tween's offset/scale) → a silent no-op on disk that + * the optimistic cache hid, so the motion path never refreshed. + */ + +// A tween that starts partway through the element's lifetime and is shorter than +// it: the clip→tween map is linear with tween% = (clip% - 20) * 2.5 over [20, 60]. +const KEYFRAMES = [ + { percentage: 20, tweenPercentage: 0, properties: { x: 0 } }, + { percentage: 30, tweenPercentage: 25, properties: { x: -180 } }, + { percentage: 50, tweenPercentage: 75, properties: { x: -320 } }, + { percentage: 60, tweenPercentage: 100, properties: { x: -460 } }, +]; + +describe("clipToTweenPercentage", () => { + it("maps anchor keyframes to their tween-relative percentages", () => { + expect(clipToTweenPercentage(KEYFRAMES, 20)).toBeCloseTo(0, 5); + expect(clipToTweenPercentage(KEYFRAMES, 60)).toBeCloseTo(100, 5); + }); + + it("linearly interpolates a clip-relative playhead into tween space", () => { + // clip 40% is the midpoint of the tween's clip span [20, 60] → tween 50%. + expect(clipToTweenPercentage(KEYFRAMES, 40)).toBeCloseTo(50, 5); + }); + + it("falls back to the input when there's no usable mapping", () => { + expect(clipToTweenPercentage([], 40)).toBe(40); + expect(clipToTweenPercentage([{ percentage: 10 }], 40)).toBe(40); + }); +}); diff --git a/packages/studio/src/components/editor/KeyframeNavigation.tsx b/packages/studio/src/components/editor/KeyframeNavigation.tsx index 48f2f5177..c54047c30 100644 --- a/packages/studio/src/components/editor/KeyframeNavigation.tsx +++ b/packages/studio/src/components/editor/KeyframeNavigation.tsx @@ -3,9 +3,12 @@ import { KeyframeDiamond, type DiamondState } from "./KeyframeDiamond"; interface KeyframeNavigationProps { property: string; - /** All keyframes for this element's tween, or null if no keyframes exist */ + /** All keyframes for this element's tween, or null if no keyframes exist. + * `percentage` is clip-relative (element lifetime) for display/seek; + * `tweenPercentage` is the tween-relative value the writer/runtime key on. */ keyframes: Array<{ percentage: number; + tweenPercentage?: number; properties: Record; ease?: string; }> | null; @@ -19,6 +22,26 @@ interface KeyframeNavigationProps { const TOLERANCE = 0.5; +/** + * Convert a clip-relative percentage (element lifetime, used for display/seek) to + * the TWEEN-relative percentage the GSAP writer/runtime key on. The clip→tween + * map is linear, recovered from the keyframes' own (percentage, tweenPercentage) + * pairs. Falls back to the input when there's no usable mapping (e.g. parser + * keyframes that are already tween-relative, or fewer than two anchors). + */ +export function clipToTweenPercentage( + keyframes: ReadonlyArray<{ percentage: number; tweenPercentage?: number }>, + clipPct: number, +): number { + const mapped = keyframes.filter((kf) => typeof kf.tweenPercentage === "number"); + if (mapped.length < 2) return clipPct; + const a = mapped[0]!; + const b = mapped[mapped.length - 1]!; + if (b.percentage === a.percentage) return a.tweenPercentage!; + const slope = (b.tweenPercentage! - a.tweenPercentage!) / (b.percentage - a.percentage); + return a.tweenPercentage! + (clipPct - a.percentage) * slope; +} + function ArrowLeft({ disabled }: { disabled: boolean }) { return ( { if (diamondState === "ghost") { onConvertToKeyframes(); - } else if (diamondState === "active") { - onRemoveKeyframe(currentPercentage); + } else if (diamondState === "active" && atCurrent) { + onRemoveKeyframe(atCurrent.tweenPercentage ?? atCurrent.percentage); } else { - onAddKeyframe(currentPercentage); + onAddKeyframe(clipToTweenPercentage(propertyKeyframes, currentPercentage)); } }; diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 8f67172b6..234f3d22f 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -427,7 +427,7 @@ export const NLELayout = memo(function NLELayout({ {/* Preview + player controls */}
{ const el = iframeRef.current?.parentElement ?? iframeRef.current; diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index f3362843e..e1de4f21c 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -119,7 +119,7 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string mp4: { label: "MP4", desc: "Best for general use. Smallest file, universal playback." }, mov: { label: "MOV (ProRes 4444)", - desc: "Transparent video. Works in CapCut, Final Cut Pro, Premiere, DaVinci Resolve, After Effects. Large files.", + desc: "Transparent video. Works in Final Cut Pro, DaVinci Resolve, and most video editors. Large files.", }, webm: { label: "WebM (VP9)", diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index c6b7b78ba..898cede6e 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -4,7 +4,11 @@ import { getAllPreviewTargetsFromPointer, getPreviewTargetFromPointer, } from "../utils/studioPreviewHelpers"; -import { findMatchingTimelineElementId, type RightPanelTab } from "../utils/studioHelpers"; +import { + findMatchingTimelineElementId, + findTimelineIdByAncestor, + type RightPanelTab, +} from "../utils/studioHelpers"; import { domEditSelectionsTargetSame, domEditSelectionInGroup, @@ -178,10 +182,13 @@ export function useDomSelection({ setRightCollapsed(false); setRightPanelTab("design"); } - const nextSelectedTimelineId = findMatchingTimelineElementId( - nextSelection, - timelineElements, - ); + const nextSelectedTimelineId = + findMatchingTimelineElementId(nextSelection, timelineElements) ?? + findTimelineIdByAncestor( + nextSelection.element, + timelineElements, + nextSelection.sourceFile || "index.html", + ); setSelectedTimelineElementId(nextSelectedTimelineId); return; } diff --git a/packages/studio/src/hooks/useEnableKeyframes.test.ts b/packages/studio/src/hooks/useEnableKeyframes.test.ts new file mode 100644 index 000000000..f62b1c12f --- /dev/null +++ b/packages/studio/src/hooks/useEnableKeyframes.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { resolveNewTweenRange } from "./useEnableKeyframes"; + +describe("resolveNewTweenRange", () => { + // Regression: "add a keyframe" must land at the PLAYHEAD. The runtime auto-stamps + // data-start="0" + data-duration= on every GSAP element, so honoring + // data-start as authored timing put the keyframe at 0. Clamping the playhead into + // the element's range fixes it (auto-stamp's full range passes the playhead through). + it("anchors at the playhead through the auto-stamped full-composition range", () => { + // data-start="0", data-duration="14" (the auto-stamp), playhead 4.9 → 4.9 + expect(resolveNewTweenRange("0", "14", 4.9)).toEqual({ start: 4.9, duration: 9.1 }); + }); + + it("anchors at the playhead when the element has no authored range", () => { + expect(resolveNewTweenRange(undefined, undefined, 4)).toEqual({ start: 4, duration: 1 }); + expect(resolveNewTweenRange(undefined, undefined, 6.123456).start).toBe(6.123); + }); + + it("never returns a negative start", () => { + expect(resolveNewTweenRange(undefined, undefined, -2).start).toBe(0); + }); + + it("clamps the playhead into a genuinely narrow authored clip", () => { + // clip [2.5, 8]: inside → playhead; before → start; after → end + expect(resolveNewTweenRange("2.5", "5.5", 4)).toEqual({ start: 4, duration: 4 }); + expect(resolveNewTweenRange("2.5", "5.5", 1).start).toBe(2.5); + expect(resolveNewTweenRange("2.5", "5.5", 99).start).toBe(8); + }); +}); diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 4a7858fa8..cc632adeb 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -55,7 +55,9 @@ function readElementPosition( const element = sel.element; if (!element?.isConnected || !gsap?.getProperty) return result; - const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"]; + // ponytail: a brand-new tween captures position only — bundling opacity made it + // a mixed group that the position-only drag intercept couldn't resolve. + const props = anim ? Object.keys(anim.properties) : ["x", "y"]; for (const prop of props) { const val = Number(gsap.getProperty(element, prop)); if (!Number.isFinite(val)) continue; @@ -65,6 +67,32 @@ function readElementPosition( return result; } +/** + * Range for a brand-new keyframe tween created via "Enable keyframes" on an element + * with no existing animation. "Add a keyframe" must land at the PLAYHEAD. + * + * The runtime auto-stamps `data-start="0"` + `data-duration=` on every + * timeline element, so we can't treat `data-start` as authored timing (doing so put + * the keyframe at 0). Instead, clamp the playhead into the element's [start, end] + * range: the auto-stamp's full-composition range passes the playhead through + * unchanged, while a genuinely narrow authored clip still clamps sensibly. + */ +export function resolveNewTweenRange( + authoredStart: string | undefined, + authoredDuration: string | undefined, + currentTime: number, +): { start: number; duration: number } { + const t = Math.max(0, roundTo3(currentTime)); + const start = authoredStart != null ? Number.parseFloat(authoredStart) : Number.NaN; + const duration = authoredDuration != null ? Number.parseFloat(authoredDuration) : Number.NaN; + if (!Number.isFinite(start) || !Number.isFinite(duration) || duration <= 0) { + return { start: t, duration: 1 }; + } + const end = start + duration; + const clampedStart = Math.min(Math.max(t, start), end); + return { start: clampedStart, duration: Math.max(0.5, roundTo3(end - clampedStart)) }; +} + async function fetchAnimationsForElement(sel: DomEditSelection): Promise { const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1]; if (!projectId) return []; @@ -122,9 +150,11 @@ export function useEnableKeyframes( } } else { const position = readElementPosition(iframe, sel, null); - const pct = computeElementPercentage(t, sel); - const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; - const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const { start: elStart, duration: elDuration } = resolveNewTweenRange( + sel.dataAttributes?.start, + sel.dataAttributes?.duration, + t, + ); const selector = selectorFromSelection(sel); if (!selector) { @@ -135,19 +165,13 @@ export function useEnableKeyframes( if (Object.keys(position).length === 0) { position.x = 0; position.y = 0; - position.opacity = 1; } + // One keyframe at the playhead — a single diamond capturing the current + // value. Motion comes from the user adding/dragging more keyframes later; + // creating 0%+100% up front showed two diamonds for a single "add keyframe". const keyframes: Array<{ percentage: number; properties: Record }> = [{ percentage: 0, properties: { ...position } }]; - if (pct > 1 && pct < 99) { - keyframes.push({ percentage: pct, properties: { ...position } }); - } - keyframes.push({ - percentage: 100, - properties: { ...position }, - auto: true, - } as (typeof keyframes)[number]); if (session.commitMutation) { await session.commitMutation( diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 8dabae8e0..a16c94e0f 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -29,7 +29,7 @@ interface BasePosition { interface GsapRuntime { seek: (t: number) => void; - set: (target: string, vars: Record) => void; + set: (target: string, vars: Record) => void; selector: string; element: HTMLElement; startTime: number; @@ -85,11 +85,18 @@ function connectGsapRuntime( ): GsapRuntime | null { try { const win = iframeEl.contentWindow as Window & { - gsap?: { set: (t: string, v: Record) => void }; + gsap?: { set: (t: string, v: Record) => void }; __timelines?: Record void; duration: () => number }>; __player?: { getTime: () => number }; }; - const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null; + // Pick the first REAL timeline. `__timelines` also carries the studio's + // `__proxied` marker (a boolean, no `.seek`); `Object.values(...)[0]` would grab + // it and fail the connect — the cause of the no-live-preview gesture bug. + const tl = win?.__timelines + ? (Object.entries(win.__timelines).find( + ([key, value]) => key !== "__proxied" && typeof value?.seek === "function", + )?.[1] ?? null) + : null; if (win?.gsap?.set && tl?.seek && selector) { const tlDuration = tl.duration(); return { @@ -105,7 +112,7 @@ function connectGsapRuntime( }; } } catch { - /* cross-origin or missing runtime */ + /* connect failed */ } return null; } @@ -125,14 +132,14 @@ function applyRuntimePreview( } function recordSample(r: RecordingRefs, time: number, properties: Record): void { - const sampleProps = { ...properties }; - // Subtract both the CSS var offset AND the pointer-element snap offset - // so the first sample doesn't include the snap-to-cursor jump. - if ("x" in sampleProps) - sampleProps.x -= r.cssVarOffset.x + r.pointerElementOffset.x / (r.scale || 1); - if ("y" in sampleProps) - sampleProps.y -= r.cssVarOffset.y + r.pointerElementOffset.y / (r.scale || 1); - r.samples.push({ time, properties: sampleProps }); + // Record the FULL position the live preview shows (element centered on the + // pointer, with any manual path offset folded into basePosition). Do NOT + // subtract the path offset: when this gesture commits as a position tween the + // server strips the element's --hf-studio-offset (the tween owns position — see + // stripStudioEditsFromTarget in studio-api), so the keyframes must already + // include it. Subtracting it made the committed gesture play shoved off by the + // offset (the offset was removed twice). + r.samples.push({ time, properties: { ...properties } }); r.trail.push({ x: r.pointer.x, y: r.pointer.y }); } @@ -280,30 +287,34 @@ export function useGestureRecording() { r.accumulated = { opacity: base.baseOpacity, scale: base.baseScale, z: 0 }; r.basePosition = { x: base.baseX, y: base.baseY }; - if (base.cssOffX || base.cssOffY) { - element.style.setProperty("--hf-studio-offset-x", "0px"); - element.style.setProperty("--hf-studio-offset-y", "0px"); - } - - // --- Phase 2: Connect to the iframe GSAP runtime --- - const selector = element.id ? `#${element.id}` : null; - r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime); - - // --- Phase 3: Compute iframe viewport → composition scale --- + // --- Phase 2: scale + element center, measured BEFORE clearing the path offset --- + // baseX/baseY fold in the CSS path offset (`--hf-studio-offset`, see + // readBasePosition), so the element's on-screen center must be read while that + // offset is still applied — otherwise the pointer-centering offset is wrong by + // exactly the path offset and the element doesn't sit under the pointer (it + // looked correct only for elements that had no path offset). + // element.getBoundingClientRect() is in the iframe's viewport; convert to the + // studio (parent) viewport using the iframe's position and scale. r.scale = computeIframeScale(iframeEl); - - // --- Phase 4: Element center for pointer-element offset --- - // element.getBoundingClientRect() is in the iframe's viewport. - // Convert to the studio (parent) viewport using the iframe's position and scale. + const iframeScale = r.scale || 1; const iframeRect = iframeEl.getBoundingClientRect(); const elRect = element.getBoundingClientRect(); - const iframeScale = r.scale || 1; const elCenterViewport = { x: iframeRect.left + (elRect.left + elRect.width / 2) * iframeScale, y: iframeRect.top + (elRect.top + elRect.height / 2) * iframeScale, }; r.pointerElementOffset = { x: 0, y: 0 }; + // Now clear the optimistic path offset (already folded into baseX/baseY). + if (base.cssOffX || base.cssOffY) { + element.style.setProperty("--hf-studio-offset-x", "0px"); + element.style.setProperty("--hf-studio-offset-y", "0px"); + } + + // --- Phase 3: Connect to the iframe GSAP runtime --- + const selector = element.id ? `#${element.id}` : null; + r.runtime = connectGsapRuntime(element, iframeEl, selector, elementEndTime); + // --- Phase 5: Attach event listeners --- const handlePointerMove = (e: PointerEvent) => { r.pointer = { x: e.clientX, y: e.clientY }; @@ -391,6 +402,7 @@ export function useGestureRecording() { } recordSample(r, time, properties); + setRecordingDuration(time); r.rafId = requestAnimationFrame(tick); }; @@ -418,6 +430,18 @@ export function useGestureRecording() { const { element: el, savedVisibility, savedTranslate } = r.runtime; el.style.visibility = savedVisibility; el.style.setProperty("translate", savedTranslate || ""); + // Drop the gesture's inline gsap transform before re-applying the path + // offset below, so the two don't briefly stack (the recorded keyframes + // already encode the full position, offset included). On commit the + // re-seek lands on the gesture's first keyframe; on cancel this leaves the + // element at its pre-recording position. + try { + r.runtime.set(r.runtime.selector, { + clearProps: "x,y,scale,scaleX,scaleY,rotation,rotationX,rotationY,opacity,z", + }); + } catch { + /* runtime gone */ + } } if (r.cssVarOffset.x || r.cssVarOffset.y) { const el = r.runtime?.element; diff --git a/packages/studio/src/hooks/useRazorSplit.ts b/packages/studio/src/hooks/useRazorSplit.ts index 292d33232..7fd8a72a6 100644 --- a/packages/studio/src/hooks/useRazorSplit.ts +++ b/packages/studio/src/hooks/useRazorSplit.ts @@ -38,13 +38,21 @@ async function splitHtmlElement( patchTarget: NonNullable>, splitTime: number, newId: string, + elementStart: number, + elementDuration: number, ): Promise<{ ok: boolean; changed?: boolean; content?: string }> { const response = await fetch( `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ target: patchTarget, splitTime, newId }), + body: JSON.stringify({ + target: patchTarget, + splitTime, + newId, + elementStart, + elementDuration, + }), }, ); if (!response.ok) throw new Error("Split request failed"); @@ -114,7 +122,15 @@ async function executeSplit( const originalContent = await readFileContent(pid, targetPath); const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip"); - const splitResult = await splitHtmlElement(pid, targetPath, patchTarget, splitTime, newId); + const splitResult = await splitHtmlElement( + pid, + targetPath, + patchTarget, + splitTime, + newId, + element.start, + element.duration, + ); if (!splitResult.ok) throw new Error("Failed to split clip."); if (!splitResult.changed) { return { targetPath, originalContent, patchedContent: originalContent, changed: false }; diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index aa360586c..177bd21a3 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -90,8 +90,9 @@ export function useInspectorState( inspectorPanelActive, inspectorButtonActive: STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, - shouldShowSelectedDomBounds: - inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording, + // Keep the selection box + motion path drawn even when the Inspector is + // collapsed — closing the panel shouldn't visually deselect the element. + shouldShowSelectedDomBounds: inspectorPanelActive && !isPlaying && !isGestureRecording, }; }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]); } diff --git a/packages/studio/src/hooks/useStudioUrlState.ts b/packages/studio/src/hooks/useStudioUrlState.ts index 6ab0b2f80..2c73c3ec3 100644 --- a/packages/studio/src/hooks/useStudioUrlState.ts +++ b/packages/studio/src/hooks/useStudioUrlState.ts @@ -2,8 +2,10 @@ import { useCallback, useEffect, useRef } from "react"; import { usePlayerStore } from "../player"; import { findElementForSelection, type DomEditSelection } from "../components/editor/domEditing"; import { clampNumber, type RightPanelTab } from "../utils/studioHelpers"; +import { parseProjectIdFromHash } from "../utils/projectRouting"; import { buildStudioHash, + parseStudioUrlStateFromHash, type StudioUrlSelectionState, type StudioUrlState, } from "../utils/studioUrlState"; @@ -33,6 +35,7 @@ interface UseStudioUrlStateParams { preserveGroup?: boolean; }, ) => void; + setRightPanelTab: (tab: RightPanelTab) => void; initialState: StudioUrlState; } @@ -68,6 +71,7 @@ export function useStudioUrlState({ domEditSelection, buildDomSelectionFromTarget, applyDomSelection, + setRightPanelTab, initialState, }: UseStudioUrlStateParams) { const currentTime = usePlayerStore((s) => s.currentTime); @@ -91,6 +95,45 @@ export function useStudioUrlState({ [activeCompPath, domEditSelection, rightCollapsed, rightPanelTab, timelineVisible], ); + // Resolve a URL selection to a live element and apply it. Shared by the initial + // hydration effect and the external-navigation (hashchange) handler. Returns + // false ONLY when the iframe document isn't ready yet (caller should retry); + // a missing element or null selection clears the selection and returns true. + const applyUrlSelection = useCallback( + (selection: StudioUrlSelectionState | null): boolean => { + if (!selection) { + applyDomSelection(null, { revealPanel: false }); + return true; + } + let doc: Document | null = null; + try { + doc = previewIframeRef.current?.contentDocument ?? null; + } catch { + return false; + } + if (!doc) return false; + const element = findElementForSelection( + doc, + { + sourceFile: selection.sourceFile ?? "", + id: selection.id, + selector: selection.selector, + selectorIndex: selection.selectorIndex, + }, + activeCompPath, + ); + if (!element) { + applyDomSelection(null, { revealPanel: false }); + return true; + } + void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((resolved) => { + applyDomSelection(resolved, { revealPanel: false }); + }); + return true; + }, + [activeCompPath, applyDomSelection, buildDomSelectionFromTarget, previewIframeRef], + ); + useEffect(() => { if (!projectId || hydratedSeekRef.current || compositionLoading) return; const nextTime = @@ -113,45 +156,15 @@ export function useStudioUrlState({ hydratedSelectionRef.current = true; return; } - - let doc: Document | null = null; - try { - doc = previewIframeRef.current?.contentDocument ?? null; - } catch { - return; - } - if (!doc) return; - - const element = findElementForSelection( - doc, - { - sourceFile: pendingSelection.sourceFile ?? "", - id: pendingSelection.id, - selector: pendingSelection.selector, - selectorIndex: pendingSelection.selectorIndex, - }, - activeCompPath, - ); - if (!element) { - applyDomSelection(null, { revealPanel: false }); - hydratedSelectionRef.current = true; - pendingSelectionRef.current = null; - return; - } - + // Doc not ready yet → leave hydration pending so a later tick retries. + if (!applyUrlSelection(pendingSelection)) return; hydratedSelectionRef.current = true; pendingSelectionRef.current = null; - void buildDomSelectionFromTarget(element, { preferClipAncestor: false }).then((selection) => { - applyDomSelection(selection, { revealPanel: false }); - }); }, [ - activeCompPath, - applyDomSelection, - buildDomSelectionFromTarget, + applyUrlSelection, compositionLoading, currentTime, initialState.currentTime, - previewIframeRef, projectId, refreshKey, ]); @@ -185,4 +198,32 @@ export function useStudioUrlState({ if (!projectId) return; replaceHash(buildStudioHash(projectId, buildUrlState())); }, [activeCompPathHydrated, buildUrlState, projectId]); + + // Re-apply URL state when the hash changes externally (pasting a new link, + // back/forward) AFTER initial load. The app only reads the URL once on mount + // and otherwise WRITES the hash via replaceState (which never fires + // `hashchange`), so this listener sees only genuine external navigations — + // without it, opening a same-project deep link (different `t`, element, or + // tab) is silently ignored and then overwritten by the next hash-sync. + useEffect(() => { + if (!projectId) return; + const onHashChange = () => { + if (parseProjectIdFromHash(window.location.hash) !== projectId) return; // different project → remount handles it + const parsed = parseStudioUrlStateFromHash(window.location.hash); + if (parsed.currentTime != null) { + const clamped = + duration > 0 + ? clampNumber(parsed.currentTime, 0, duration) + : Math.max(0, parsed.currentTime); + if (Math.abs(usePlayerStore.getState().currentTime - clamped) > 0.05) { + usePlayerStore.getState().requestSeek(clamped); + stableTimeRef.current = clamped; + } + } + applyUrlSelection(parsed.selection); + if (parsed.rightPanelTab) setRightPanelTab(parsed.rightPanelTab); + }; + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, [projectId, duration, applyUrlSelection, setRightPanelTab]); } diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts index 51d5980b3..a8399dee8 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildExpandedElements } from "./useExpandedTimelineElements"; +import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; import type { TimelineElement } from "../store/playerStore"; import type { ClipManifestClip } from "../lib/playbackTypes"; @@ -88,4 +89,37 @@ describe("buildExpandedElements", () => { expect(child.expandedParentStart).toBe(13); // C's start, not B's 12 or A's 10 expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html }); + + // Regression: an expanded child must share one identity (`key`) with the flat + // store element for the same DOM id. Before the fix the child key fell back to + // the colon form (`index.html:eyebrow:N`) while the store/selection used the + // hash form (`index.html#eyebrow`), so clicking an expanded child never + // highlighted it (isSelected compares the two keys). + it("keys expanded children in hash form, matching the flat store element", () => { + // Single composition (no sub-comps): scene `s1` with same-file children. + const elements = [el({ id: "s1", domId: "s1", start: 0, duration: 14 })]; + const manifest = [ + clip({ id: "s1", start: 0, duration: 14 }), + clip({ id: "eyebrow", start: 0, duration: 14 }), + clip({ id: "title", start: 0, duration: 14 }), + ]; + const parentMap = new Map([ + ["eyebrow", "s1"], + ["title", "s1"], + ]); + + const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1"); + const child = out.find((e) => e.domId === "eyebrow")!; + + const expectedStoreKey = buildTimelineElementKey({ + id: "eyebrow", + fallbackIndex: 0, + domId: "eyebrow", + selector: "#eyebrow", + sourceFile: undefined, + }); + expect(expectedStoreKey).toBe("index.html#eyebrow"); + expect(child.key).toBe("index.html#eyebrow"); + expect(child.key).toBe(expectedStoreKey); + }); }); diff --git a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts index 6903402cc..9649b2af7 100644 --- a/packages/studio/src/player/hooks/useExpandedTimelineElements.ts +++ b/packages/studio/src/player/hooks/useExpandedTimelineElements.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { ClipManifestClip } from "../lib/playbackTypes"; import { createTimelineElementFromManifestClip } from "../lib/timelineDOM"; +import { buildTimelineElementKey } from "../lib/timelineElementHelpers"; function findTopLevelAncestor(id: string, parentMap: Map): string | null { let current = parentMap.get(id); @@ -78,14 +79,31 @@ function buildChildElements( clip: child, fallbackIndex: result.length, }); + const domId = child.id ?? undefined; + const selector = child.id ? `#${child.id}` : undefined; + // `base.key` was built without a hostEl, so it fell back to the colon form + // (`index.html::`) even though we set domId below. Recompute it from + // the same inputs the store uses (`#`) so an expanded + // child shares one identity with its flat store element — otherwise selecting + // it sets `selectedElementId` to the store's hash key while the rendered row + // is keyed by the colon form, and `isSelected` never matches (no highlight). + const key = buildTimelineElementKey({ + id: base.id, + fallbackIndex: result.length, + domId, + selector, + selectorIndex: base.selectorIndex, + sourceFile: editBasis.sourceFile, + }); result.push({ ...base, + key, start: clamped.start, duration: clamped.duration, track: display.track + result.length, expandedParentStart: editBasis.start, - domId: child.id ?? undefined, - selector: child.id ? `#${child.id}` : undefined, + domId, + selector, sourceFile: editBasis.sourceFile, timingSource: "authored" as const, }); diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 05b378d11..1ce0b1a67 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -327,7 +327,16 @@ export const usePlayerStore = create((set, get) => ({ setTimelineReady: (ready) => set({ timelineReady: ready }), setBeatDragging: (dragging) => set({ beatDragging: dragging }), setElements: (elements) => set({ elements }), - setSelectedElementId: (id) => set({ selectedElementId: id }), + setSelectedElementId: (id) => + set((s) => + // Selecting a different element drops any active keyframe selection — otherwise + // a stale activeKeyframePct from a prior diamond click would force the next drag + // to "modify" a keyframe on the new element. A diamond click sets the pct AFTER + // calling setSelectedElementId, so this never clobbers a genuine keyframe select. + id !== s.selectedElementId + ? { selectedElementId: id, activeKeyframePct: null } + : { selectedElementId: id }, + ), updateElement: (elementId, updates) => set((state) => ({ elements: state.elements.map((el) => @@ -361,3 +370,10 @@ export const usePlayerStore = create((set, get) => ({ clipParentMap: new Map(), }), })); + +// Bug-bash aid: expose the store so a reproduction can dump live state from the +// console, e.g. `__playerStore.getState().selectedElementId`. Harmless read +// handle; no behavioural effect. +if (typeof window !== "undefined") { + (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore; +} diff --git a/packages/studio/src/utils/studioHelpers.test.ts b/packages/studio/src/utils/studioHelpers.test.ts index 406585d6a..c50abc548 100644 --- a/packages/studio/src/utils/studioHelpers.test.ts +++ b/packages/studio/src/utils/studioHelpers.test.ts @@ -1,5 +1,11 @@ +// @vitest-environment happy-dom + import { describe, expect, it } from "vitest"; -import { findMatchingTimelineElementId, resolveTimelineSelectionSeekTime } from "./studioHelpers"; +import { + findMatchingTimelineElementId, + findTimelineIdByAncestor, + resolveTimelineSelectionSeekTime, +} from "./studioHelpers"; describe("resolveTimelineSelectionSeekTime", () => { it("keeps the current time when it is already inside the clip range", () => { @@ -42,3 +48,27 @@ describe("findMatchingTimelineElementId", () => { expect(findMatchingTimelineElementId({ id: "ghost", sourceFile: "index.html" }, [])).toBe(null); }); }); + +describe("findTimelineIdByAncestor", () => { + const el = (over: Record) => + ({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never; + + it("resolves a static descendant (.num) to its nearest clip ancestor", () => { + // #stat1 (a clip) > .num (selected, not a clip) + const stat1 = document.createElement("div"); + stat1.id = "stat1"; + const num = document.createElement("div"); + num.className = "num"; + stat1.appendChild(num); + + const els = [el({ id: "stat1", domId: "stat1", key: "index.html#stat1" })]; + expect(findTimelineIdByAncestor(num, els, "index.html")).toBe("index.html#stat1"); + }); + + it("returns null when no ancestor is a clip", () => { + const wrap = document.createElement("div"); + const child = document.createElement("span"); + wrap.appendChild(child); + expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null); + }); +}); diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index 2dbaf77d9..5677f7f70 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -185,6 +185,30 @@ export function findMatchingTimelineElementId( return null; } +/** + * A selected DOM node may be a static descendant of a clip (e.g. the `.num` text + * inside a `#stat1` card) — not a timeline element itself. Walk up to the nearest + * ancestor that IS a clip so the timeline still selects + inline-expands around it. + */ +export function findTimelineIdByAncestor( + element: Element | null | undefined, + elements: TimelineElement[], + sourceFile: string, +): string | null { + let ancestor = element?.parentElement ?? null; + while (ancestor) { + const id = ancestor.id; + if (id) { + const match = elements.find( + (el) => el.domId === id && (el.sourceFile ?? "index.html") === sourceFile, + ); + if (match) return match.key ?? match.id; + } + ancestor = ancestor.parentElement; + } + return null; +} + export function resolveTimelineSelectionSeekTime( currentTime: number, element: Pick | null | undefined, @@ -204,8 +228,6 @@ export function clampNumber(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } -export { COMPOSITION_ROOT_OPEN_TAG_RE } from "./compositionPatterns"; - export function collectHtmlIds(source: string): string[] { return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? ""); } diff --git a/packages/studio/src/utils/studioPreviewHelpers.test.ts b/packages/studio/src/utils/studioPreviewHelpers.test.ts index fce01d7b3..c673142e6 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.test.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.test.ts @@ -1,5 +1,30 @@ import { describe, expect, it, vi } from "vitest"; -import { pauseStudioPreviewPlayback } from "./studioPreviewHelpers"; +import { coversComposition, pauseStudioPreviewPlayback } from "./studioPreviewHelpers"; + +describe("coversComposition (full-bleed canvas-pick exclusion)", () => { + const viewport = { width: 1920, height: 1080 }; + + it("treats a full-bleed scene wrapper as covering the composition", () => { + expect(coversComposition({ width: 1920, height: 1080 }, viewport)).toBe(true); + expect(coversComposition({ width: 1900, height: 1040 }, viewport)).toBe(true); // ~99%/96% + }); + + it("does NOT exclude inner content (a stat card, a heading)", () => { + expect(coversComposition({ width: 320, height: 180 }, viewport)).toBe(false); + expect(coversComposition({ width: 1900, height: 200 }, viewport)).toBe(false); // wide but short + expect(coversComposition({ width: 200, height: 1040 }, viewport)).toBe(false); // tall but narrow + }); + + it("needs BOTH axes near full-bleed (>=95%)", () => { + expect(coversComposition({ width: 1800, height: 1080 }, viewport)).toBe(false); // 93.75% wide + expect(coversComposition({ width: 1920, height: 1000 }, viewport)).toBe(false); // 92.6% tall + }); + + it("guards against a degenerate viewport", () => { + expect(coversComposition({ width: 100, height: 100 }, { width: 0, height: 0 })).toBe(false); + expect(coversComposition({ width: 100, height: 100 }, { width: 1, height: 1 })).toBe(false); + }); +}); describe("pauseStudioPreviewPlayback", () => { it("pauses through __player without pausing sibling timelines directly", () => { diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 6bb8cb8a5..2ec9911bc 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -1,5 +1,4 @@ import type { DomEditViewport } from "../components/editor/domEditing"; -import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing"; import { getDomLayerPatchTarget, isElementComputedVisible, @@ -13,6 +12,29 @@ interface PreviewLocalPointer { viewport: DomEditViewport; } +// An element is "full-bleed" when its box spans nearly the whole composition on +// BOTH axes. Such elements (scene wrappers, backdrops) are excluded from canvas +// click-picking so a click lands on inner content — or deselects on empty area — +// instead of grabbing the giant container. The Layers panel still selects them. +// ponytail: pure size heuristic; tighten the ratio if decorative full-bleed art +// should remain canvas-selectable. +const FULL_BLEED_RATIO = 0.95; + +export function coversComposition( + elRect: { width: number; height: number }, + viewport: DomEditViewport, +): boolean { + if (viewport.width <= 1 || viewport.height <= 1) return false; + return ( + elRect.width / viewport.width >= FULL_BLEED_RATIO && + elRect.height / viewport.height >= FULL_BLEED_RATIO + ); +} + +function isFullBleedTarget(el: HTMLElement, viewport: DomEditViewport): boolean { + return coversComposition(el.getBoundingClientRect(), viewport); +} + function resolvePreviewLocalPointer( iframe: HTMLIFrameElement, doc: Document, @@ -82,18 +104,19 @@ export function getPreviewTargetFromPointer( const overrideStyle = forcePointerEventsAuto(doc); try { if (typeof doc.elementsFromPoint === "function") { - const visualTarget = resolveVisualDomEditSelectionTarget( + const candidates = resolveAllVisualDomEditTargets( doc.elementsFromPoint(localPointer.x, localPointer.y), - { - activeCompositionPath, - }, + { activeCompositionPath }, ); + const visualTarget = + candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null; if (visualTarget) return visualTarget; } const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null; if (!isElementComputedVisible(fallback)) return null; + if (isFullBleedTarget(fallback, localPointer.viewport)) return null; return fallback; } finally { removePointerEventsOverride(overrideStyle); @@ -125,11 +148,12 @@ export function getAllPreviewTargetsFromPointer( if (typeof doc.elementsFromPoint === "function") { return resolveAllVisualDomEditTargets(doc.elementsFromPoint(localPointer.x, localPointer.y), { activeCompositionPath, - }); + }).filter((el) => !isFullBleedTarget(el, localPointer.viewport)); } const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return []; if (!isElementComputedVisible(fallback)) return []; + if (isFullBleedTarget(fallback, localPointer.viewport)) return []; return [fallback]; } finally { removePointerEventsOverride(overrideStyle);