Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -63,7 +64,7 @@ export interface StudioRightPanelProps {
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
onToggleElementHidden?: ToggleHiddenHandler;
}

// fallow-ignore-next-line complexity
Expand Down Expand Up @@ -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 = (
<DesignPanelPromoteProvider
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
Expand Down
10 changes: 3 additions & 7 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
readGsapRuntimeValuesForPanel,
readGsapBorderRadiusForPanel,
isSelectedElementHidden,
selectionIdentityKey,
} from "./propertyPanelHelpers";
import { MetricField, Section } from "./propertyPanelPrimitives";
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
Expand Down Expand Up @@ -269,7 +270,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
return (
<PropertyPanelFlat
{...props}
key={element.id ?? element.selector}
key={selectionIdentityKey(element)}
element={element}
styles={styles}
sections={sections}
Expand Down Expand Up @@ -372,12 +373,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
)}
{sections.colorGrading && (
<ColorGradingSection
key={[
element.id ?? "",
element.hfId ?? "",
element.selector ?? "",
String(element.selectorIndex ?? ""),
].join("|")}
key={selectionIdentityKey(element)}
projectId={projectId}
element={element}
assets={assets}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ function FlatMultiSelectState({
const { glyph, className } = elementKindGlyph(element);
return (
<span
key={element.id ?? element.selector}
key={`${element.id ?? element.selector ?? ""}:${element.selectorIndex ?? 0}`}
className="flex items-center gap-2 rounded-lg border border-panel-border bg-panel-bg px-2.5 py-[7px]"
>
<span
Expand Down
9 changes: 7 additions & 2 deletions packages/studio/src/components/editor/PropertyPanelFlat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,10 +363,14 @@ export function PropertyPanelFlat({
});
}
if (showEditableSections) {
// Number.isFinite guard (not `|| 1`): opacity 0 is a real value — an
// invisible element must summarize as 0%, not 100%.
const opacityValue = parseFloat(styles.opacity ?? "1");
const opacityPct = Math.round((Number.isFinite(opacityValue) ? opacityValue : 1) * 100);
groups.push({
id: "style",
title: "Style",
summary: `fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${Math.round((parseFloat(styles.opacity ?? "1") || 1) * 100)}%`,
summary: `fill ${styles["background-image"] && styles["background-image"] !== "none" ? "image/gradient" : styles["background-color"] ? "set" : "none"} · ${opacityPct}%`,
content: (
<FlatStyleSection
projectId={projectId}
Expand All @@ -383,7 +387,8 @@ export function PropertyPanelFlat({
groups.push({
id: "layout",
title: "Layout",
accessory: <span className="text-[9px] text-panel-text-5">drag values to scrub</span>,
// 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: (
<FlatLayoutSection
Expand Down
16 changes: 14 additions & 2 deletions packages/studio/src/components/editor/gsapLivePreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,27 @@ import type { DomEditSelection } from "./domEditingTypes";
* Extracted so the identical closure exists once — shared by the legacy
* PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat).
*/
// Resolve by id when unique, otherwise by selector + selectorIndex — a bare
// querySelector(selector) always hits the FIRST match, so dragging on the
// second of two same-selector siblings would animate the wrong element.
function resolvePreviewNode(
doc: Document | null | undefined,
el: DomEditSelection,
): Element | null {
if (!doc) return null;
if (el.id) return doc.querySelector(`#${el.id}`);
if (!el.selector) return null;
return doc.querySelectorAll(el.selector)[el.selectorIndex ?? 0] ?? null;
}

export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) {
return (el: DomEditSelection, props: Record<string, number>) => {
const iframe = iframeRef.current;
const win = iframe?.contentWindow as
| { gsap?: { set: (t: Element, v: Record<string, number>) => 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);
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<FlatSlider
label="Opacity"
Expand All @@ -501,6 +501,136 @@ describe("FlatSlider — Grade extensions", () => {
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(
<FlatSlider
label="Vignette"
value={18}
min={0}
max={100}
tier="explicitCustom"
displayValue="18%"
onReset={onReset}
onCommit={vi.fn()}
/>,
);
const resetButton = host.querySelector<HTMLButtonElement>('[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(
<FlatSlider
label="Opacity"
value={100}
min={0}
max={100}
tier="default"
displayValue="100%"
disabled
onCommit={onCommit}
/>,
);
const track = host.querySelector<HTMLElement>('[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(
<FlatSlider
label="Volume"
value={50}
min={0}
max={100}
tier="default"
displayValue="50%"
onCommit={onCommit}
/>,
);
const track = host.querySelector<HTMLElement>('[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 (
<FlatSlider
label="Opacity"
value={value}
min={0}
max={100}
tier="explicitCustom"
displayValue={`${value}%`}
onCommit={(next) => {
onCommit(next);
setValue(next);
}}
/>
);
}
const { host, root } = renderInto(<Harness />);
const track = host.querySelector<HTMLElement>('[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", () => {
Expand Down
Loading
Loading