diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index c8f0bf8282..048df4c0be 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -38,6 +38,7 @@ import { type ColorGradingScope, } from "./studioColorGradingScope"; import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes"; +import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers"; const MIN_INSPECTOR_SPLIT_PERCENT = 20; const MAX_INSPECTOR_SPLIT_PERCENT = 75; @@ -63,7 +64,7 @@ export interface StudioRightPanelProps { kind: EditHistoryKind; files: Record; }) => Promise; - onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise | void; + onToggleElementHidden?: ToggleHiddenHandler; } // fallow-ignore-next-line complexity @@ -343,10 +344,11 @@ export function StudioRightPanel({ [projectId, refreshFileTree, showToast], ); - const handleHideAllSelected = () => - domEditGroupSelections - .map((el) => el.id ?? el.selector) - .forEach((key) => key && void onToggleElementHidden?.(key, true)); + const handleHideAllSelected = () => { + const { elements } = usePlayerStore.getState(); + const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath); + if (keys.length > 0) void onToggleElementHidden?.(keys, true); + }; const propertyPanel = ( 1 ? null : domEditSelection} diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 4babca2348..9a29d4abe2 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -13,6 +13,7 @@ import { readGsapRuntimeValuesForPanel, readGsapBorderRadiusForPanel, isSelectedElementHidden, + selectionIdentityKey, } from "./propertyPanelHelpers"; import { MetricField, Section } from "./propertyPanelPrimitives"; import { createTransformCommitHandlers } from "./propertyPanelTransformCommit"; @@ -269,7 +270,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro return ( drag values to scrub, + // No scrub accessory: FlatRow/CommitField has no pointer-drag scrubbing + // (wheel/arrow keys only) — advertising "drag values to scrub" here lies. summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`, content: ( ) => { const iframe = iframeRef.current; @@ -15,8 +28,7 @@ export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameE | { gsap?: { set: (t: Element, v: Record) => void } } | null | undefined; - const sel = el.id ? `#${el.id}` : el.selector; - const node = sel ? iframe?.contentDocument?.querySelector(sel) : null; + const node = resolvePreviewNode(iframe?.contentDocument, el); if (win?.gsap && node) win.gsap.set(node, props); }; } diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx index 0eb9147609..83c17cf6b4 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -21,21 +21,43 @@ export function FlatTimingRow({ const { start, duration, inferred: derived } = deriveElementTiming(element, animations); const end = start + duration; + // While the range is inferred from animations, editing ONE field must pin the + // WHOLE displayed range: writing only data-duration flips inference off and + // drops start to data-start-or-0 (the clip silently shifts), and writing only + // data-start is ignored while duration is still inferred (the edit looks + // dead). Pin both attributes, sequentially, so the display never jumps. + const pinRange = async (nextStart: number, nextDuration: number) => { + await onSetAttribute("start", nextStart.toFixed(2)); + await onSetAttribute("duration", nextDuration.toFixed(2)); + }; + const commitStart = (nextValue: string) => { const parsed = parseTimingValue(nextValue); if (parsed == null) return; + if (derived) { + void pinRange(parsed, duration); + return; + } void onSetAttribute("start", parsed.toFixed(2)); }; const commitDuration = (nextValue: string) => { const parsed = parseTimingValue(nextValue); if (parsed == null || parsed <= 0) return; + if (derived) { + void pinRange(start, parsed); + return; + } void onSetAttribute("duration", parsed.toFixed(2)); }; const commitEnd = (nextValue: string) => { const parsed = parseTimingValue(nextValue); if (parsed == null || parsed <= start) return; + if (derived) { + void pinRange(start, parsed - start); + return; + } void onSetAttribute("duration", (parsed - start).toFixed(2)); }; diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx index b06a8135a6..6fb1dad09d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.test.tsx @@ -485,7 +485,7 @@ describe("FlatSlider — Grade extensions", () => { act(() => rootB.unmount()); }); - it("renders no reset slot at all when centerTick is omitted, matching existing Style/Media callers", () => { + it("renders no reset slot at all when neither centerTick nor onReset is provided", () => { const { host, root } = renderInto( { expect(host.querySelector('[data-flat-slider-reset="true"]')).toBeNull(); act(() => root.unmount()); }); + + it("shows a reachable reset button on a non-centerTick slider that passes onReset (Grade Vignette/Effects)", () => { + const onReset = vi.fn(); + const { host, root } = renderInto( + , + ); + const resetButton = host.querySelector('[data-flat-slider-reset="true"]'); + expect(resetButton).not.toBeNull(); + act(() => resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onReset).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + it("never commits from a click released on a disabled slider", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 200, top: 0, height: 20, right: 200, bottom: 20 }), + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 50, pointerId: 1 }), + ); + track.dispatchEvent( + new PointerEvent("pointerup", { bubbles: true, clientX: 50, pointerId: 1 }), + ); + }); + expect(onCommit).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("supports keyboard operation: focusable, arrow keys step, Home/End clamp to range", () => { + const onCommit = vi.fn(); + const { host, root } = renderInto( + , + ); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + expect(track.getAttribute("tabindex")).toBe("0"); + expect(track.getAttribute("aria-valuemin")).toBe("0"); + expect(track.getAttribute("aria-valuemax")).toBe("100"); + act(() => { + track.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); + }); + expect(onCommit).toHaveBeenLastCalledWith(51); + act(() => { + track.dispatchEvent(new KeyboardEvent("keydown", { key: "Home", bubbles: true })); + }); + expect(onCommit).toHaveBeenLastCalledWith(0); + act(() => root.unmount()); + }); + + it("ignores the committed prop echoing back mid-drag (no knob snap-back)", () => { + const onCommit = vi.fn(); + function Harness() { + const [value, setValue] = React.useState(10); + return ( + { + onCommit(next); + setValue(next); + }} + /> + ); + } + const { host, root } = renderInto(); + const track = host.querySelector('[data-flat-slider-track="true"]'); + if (!track) throw new Error("expected a track element"); + Object.defineProperty(track, "getBoundingClientRect", { + value: () => ({ left: 0, width: 100, top: 0, height: 20, right: 100, bottom: 20 }), + }); + act(() => { + // Leading-edge commit fires at 30 and echoes back through the parent's + // state — mid-drag, that echo must NOT reset the draft. + track.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, clientX: 30, pointerId: 1 }), + ); + }); + act(() => { + track.dispatchEvent( + new PointerEvent("pointermove", { bubbles: true, clientX: 80, pointerId: 1 }), + ); + }); + // Draft tracks the pointer (80), not the stale committed echo (30). + expect(track.getAttribute("aria-valuenow")).toBe("80"); + act(() => { + track.dispatchEvent( + new PointerEvent("pointerup", { bubbles: true, clientX: 80, pointerId: 1 }), + ); + }); + expect(onCommit).toHaveBeenLastCalledWith(80); + expect(track.getAttribute("aria-valuenow")).toBe("80"); + act(() => root.unmount()); + }); }); describe("FlatSelectRow", () => { diff --git a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx index 8c119198f0..49faeebaa6 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatPrimitives.tsx @@ -216,6 +216,29 @@ export function FlatGroupHeader({ /* FlatSlider — full-width label/track/value row */ /* ------------------------------------------------------------------ */ +/** Keyboard target for a slider keydown, or null for keys we don't handle. */ +function sliderKeyTarget( + key: string, + current: number, + min: number, + max: number, + step: number, +): number | null { + if (key === "Home") return min; + if (key === "End") return max; + const deltas: Record = { + ArrowLeft: -step, + ArrowDown: -step, + ArrowRight: step, + ArrowUp: step, + PageDown: -step * 10, + PageUp: step * 10, + }; + const delta = deltas[key]; + if (delta === undefined) return null; + return Math.max(min, Math.min(max, current + delta)); +} + export function FlatSlider({ label, value, @@ -252,6 +275,11 @@ export function FlatSlider({ const commitTimerRef = useRef | null>(null); const lastCommitAtRef = useRef(0); const pendingRef = useRef(null); + // True from pointerdown to pointerup/cancel. While dragging, the committed + // prop echoing back through the parent must NOT reset `draft` — the echo is + // up to 40ms stale (throttled commit), and syncing it mid-drag snaps the + // knob backwards under the user's pointer. + const draggingRef = useRef(false); // Tracks the last value actually sent to onCommit — separate from `value` // (the committed prop) because in a single pointerdown+pointerup click the // leading-edge commit fires before the parent has re-rendered with the new @@ -260,6 +288,7 @@ export function FlatSlider({ const lastCommittedRef = useRef(value); useEffect(() => { + if (draggingRef.current) return; setDraft(value); lastCommittedRef.current = value; }, [value]); @@ -313,10 +342,14 @@ export function FlatSlider({ role="slider" aria-label={label} aria-valuenow={draft} + aria-valuemin={min} + aria-valuemax={max} aria-disabled={disabled} + tabIndex={disabled ? -1 : 0} className={`relative h-5 flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`} onPointerDown={(e) => { if (disabled) return; + draggingRef.current = true; e.currentTarget.setPointerCapture(e.pointerId); const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect()); setDraft(stepped); @@ -332,6 +365,9 @@ export function FlatSlider({ if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); } + if (disabled) return; + if (!draggingRef.current) return; + draggingRef.current = false; // Recompute from the event itself rather than reading the `draft` // closure — if pointerdown+pointerup land in the same React batch // (e.g. a very fast click), the onPointerUp handler can still be @@ -340,6 +376,20 @@ export function FlatSlider({ setDraft(stepped); commitDraft(stepped); }} + onPointerCancel={(e) => { + draggingRef.current = false; + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId); + } + }} + onKeyDown={(e) => { + if (disabled) return; + const next = sliderKeyTarget(e.key, draft, min, max, step); + if (next === null) return; + e.preventDefault(); + setDraft(next); + commitDraft(next); + }} >
{centerTick && ( @@ -372,7 +422,7 @@ export function FlatSlider({ > {displayValue} - {centerTick && ( + {(centerTick || onReset) && ( {tier === "explicitCustom" && onReset && (
'); + expect(recordEdit).toHaveBeenCalledTimes(1); + expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide 2 elements"); + }); }); diff --git a/packages/studio/src/hooks/timelineTrackVisibility.ts b/packages/studio/src/hooks/timelineTrackVisibility.ts index db30f55ca9..a60b88a747 100644 --- a/packages/studio/src/hooks/timelineTrackVisibility.ts +++ b/packages/studio/src/hooks/timelineTrackVisibility.ts @@ -33,7 +33,8 @@ interface ToggleTimelineTrackHiddenInput { } interface ToggleTimelineElementHiddenInput extends Omit { - elementKey: string; + /** One timeline key, or several to hide/show in a single atomic file write. */ + elementKey: string | readonly string[]; } interface SetElementsHiddenInput { @@ -232,13 +233,21 @@ export async function toggleTimelineElementHidden({ domEditSaveTimestampRef, pendingTimelineEditPathRef, }: ToggleTimelineElementHiddenInput): Promise { - const element = timelineElements.find((item) => (item.key ?? item.id) === elementKey); + const keys = new Set(typeof elementKey === "string" ? [elementKey] : elementKey); + const elements = timelineElements.filter((item) => keys.has(item.key ?? item.id)); return setElementsHidden({ projectId, activeCompPath, - elements: element ? [element] : [], + elements, hidden, - label: hidden ? "Hide element" : "Show element", + label: + elements.length > 1 + ? hidden + ? `Hide ${elements.length} elements` + : `Show ${elements.length} elements` + : hidden + ? "Hide element" + : "Show element", previewIframe, writeProjectFile, recordEdit, @@ -322,11 +331,11 @@ export function useTimelineElementVisibilityEditing({ isRecordingRef, forceReloadSdkSession, }: UseTimelineElementVisibilityEditingInput): ( - elementKey: string, + elementKey: string | readonly string[], hidden: boolean, ) => Promise { return useCallback( - async (elementKey: string, hidden: boolean) => { + async (elementKey: string | readonly string[], hidden: boolean) => { if (isRecordingRef?.current) { showToast("Cannot edit timeline while recording", "error"); return; diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index 0565eea2ca..448e868c15 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -237,6 +237,28 @@ export function resolveTimelineIdForSelection( ); } +/** + * Resolve every multi-selected element to its scope-qualified timeline key + * (dropping unresolvable ones). Selections carry bare DOM ids/selectors, but + * the visibility toggle matches keys like "index.html#hero" — and callers must + * hide all keys in ONE call so the file is patched in a single atomic write + * (per-element calls would clobber each other's reads). + */ +export function timelineKeysForSelections( + selections: readonly DomEditSelection[], + elements: TimelineElement[], + activeCompPath: string | null, +): string[] { + return selections + .map((selection) => resolveTimelineIdForSelection(selection, elements, activeCompPath)) + .filter((key): key is string => key !== null); +} + +export type ToggleHiddenHandler = ( + elementKey: string | readonly string[], + hidden: boolean, +) => Promise | void; + export function resolveTimelineSelectionSeekTime( currentTime: number, element: Pick | null | undefined,