mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2225 from heygen-com/studio-flat-15-review-fixes
fix(studio): resolve flat-inspector review defects
This commit is contained in:
@@ -38,6 +38,7 @@ import {
|
|||||||
type ColorGradingScope,
|
type ColorGradingScope,
|
||||||
} from "./studioColorGradingScope";
|
} from "./studioColorGradingScope";
|
||||||
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
|
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
|
||||||
|
import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
|
||||||
|
|
||||||
const MIN_INSPECTOR_SPLIT_PERCENT = 20;
|
const MIN_INSPECTOR_SPLIT_PERCENT = 20;
|
||||||
const MAX_INSPECTOR_SPLIT_PERCENT = 75;
|
const MAX_INSPECTOR_SPLIT_PERCENT = 75;
|
||||||
@@ -63,7 +64,7 @@ export interface StudioRightPanelProps {
|
|||||||
kind: EditHistoryKind;
|
kind: EditHistoryKind;
|
||||||
files: Record<string, { before: string; after: string }>;
|
files: Record<string, { before: string; after: string }>;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | void;
|
onToggleElementHidden?: ToggleHiddenHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallow-ignore-next-line complexity
|
// fallow-ignore-next-line complexity
|
||||||
@@ -343,10 +344,11 @@ export function StudioRightPanel({
|
|||||||
[projectId, refreshFileTree, showToast],
|
[projectId, refreshFileTree, showToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHideAllSelected = () =>
|
const handleHideAllSelected = () => {
|
||||||
domEditGroupSelections
|
const { elements } = usePlayerStore.getState();
|
||||||
.map((el) => el.id ?? el.selector)
|
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
|
||||||
.forEach((key) => key && void onToggleElementHidden?.(key, true));
|
if (keys.length > 0) void onToggleElementHidden?.(keys, true);
|
||||||
|
};
|
||||||
const propertyPanel = (
|
const propertyPanel = (
|
||||||
<DesignPanelPromoteProvider
|
<DesignPanelPromoteProvider
|
||||||
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
readGsapRuntimeValuesForPanel,
|
readGsapRuntimeValuesForPanel,
|
||||||
readGsapBorderRadiusForPanel,
|
readGsapBorderRadiusForPanel,
|
||||||
isSelectedElementHidden,
|
isSelectedElementHidden,
|
||||||
|
selectionIdentityKey,
|
||||||
} from "./propertyPanelHelpers";
|
} from "./propertyPanelHelpers";
|
||||||
import { MetricField, Section } from "./propertyPanelPrimitives";
|
import { MetricField, Section } from "./propertyPanelPrimitives";
|
||||||
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
|
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
|
||||||
@@ -269,7 +270,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|||||||
return (
|
return (
|
||||||
<PropertyPanelFlat
|
<PropertyPanelFlat
|
||||||
{...props}
|
{...props}
|
||||||
key={element.id ?? element.selector}
|
key={selectionIdentityKey(element)}
|
||||||
element={element}
|
element={element}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
sections={sections}
|
sections={sections}
|
||||||
@@ -372,12 +373,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
|
|||||||
)}
|
)}
|
||||||
{sections.colorGrading && (
|
{sections.colorGrading && (
|
||||||
<ColorGradingSection
|
<ColorGradingSection
|
||||||
key={[
|
key={selectionIdentityKey(element)}
|
||||||
element.id ?? "",
|
|
||||||
element.hfId ?? "",
|
|
||||||
element.selector ?? "",
|
|
||||||
String(element.selectorIndex ?? ""),
|
|
||||||
].join("|")}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
element={element}
|
element={element}
|
||||||
assets={assets}
|
assets={assets}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ function FlatMultiSelectState({
|
|||||||
const { glyph, className } = elementKindGlyph(element);
|
const { glyph, className } = elementKindGlyph(element);
|
||||||
return (
|
return (
|
||||||
<span
|
<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]"
|
className="flex items-center gap-2 rounded-lg border border-panel-border bg-panel-bg px-2.5 py-[7px]"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -363,10 +363,14 @@ export function PropertyPanelFlat({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (showEditableSections) {
|
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({
|
groups.push({
|
||||||
id: "style",
|
id: "style",
|
||||||
title: "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: (
|
content: (
|
||||||
<FlatStyleSection
|
<FlatStyleSection
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
@@ -383,7 +387,8 @@ export function PropertyPanelFlat({
|
|||||||
groups.push({
|
groups.push({
|
||||||
id: "layout",
|
id: "layout",
|
||||||
title: "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)}`,
|
summary: `${formatPxMetricValue(displayX)},${formatPxMetricValue(displayY)} · ${Math.round(displayW)}×${Math.round(displayH)}`,
|
||||||
content: (
|
content: (
|
||||||
<FlatLayoutSection
|
<FlatLayoutSection
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ import type { DomEditSelection } from "./domEditingTypes";
|
|||||||
* Extracted so the identical closure exists once — shared by the legacy
|
* Extracted so the identical closure exists once — shared by the legacy
|
||||||
* PropertyPanel Layout section and the flat Layout group (PropertyPanelFlat).
|
* 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 }) {
|
export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameElement | null }) {
|
||||||
return (el: DomEditSelection, props: Record<string, number>) => {
|
return (el: DomEditSelection, props: Record<string, number>) => {
|
||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
@@ -15,8 +28,7 @@ export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameE
|
|||||||
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
|
| { gsap?: { set: (t: Element, v: Record<string, number>) => void } }
|
||||||
| null
|
| null
|
||||||
| undefined;
|
| undefined;
|
||||||
const sel = el.id ? `#${el.id}` : el.selector;
|
const node = resolvePreviewNode(iframe?.contentDocument, el);
|
||||||
const node = sel ? iframe?.contentDocument?.querySelector(sel) : null;
|
|
||||||
if (win?.gsap && node) win.gsap.set(node, props);
|
if (win?.gsap && node) win.gsap.set(node, props);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,21 +21,43 @@ export function FlatTimingRow({
|
|||||||
const { start, duration, inferred: derived } = deriveElementTiming(element, animations);
|
const { start, duration, inferred: derived } = deriveElementTiming(element, animations);
|
||||||
const end = start + duration;
|
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 commitStart = (nextValue: string) => {
|
||||||
const parsed = parseTimingValue(nextValue);
|
const parsed = parseTimingValue(nextValue);
|
||||||
if (parsed == null) return;
|
if (parsed == null) return;
|
||||||
|
if (derived) {
|
||||||
|
void pinRange(parsed, duration);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void onSetAttribute("start", parsed.toFixed(2));
|
void onSetAttribute("start", parsed.toFixed(2));
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitDuration = (nextValue: string) => {
|
const commitDuration = (nextValue: string) => {
|
||||||
const parsed = parseTimingValue(nextValue);
|
const parsed = parseTimingValue(nextValue);
|
||||||
if (parsed == null || parsed <= 0) return;
|
if (parsed == null || parsed <= 0) return;
|
||||||
|
if (derived) {
|
||||||
|
void pinRange(start, parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void onSetAttribute("duration", parsed.toFixed(2));
|
void onSetAttribute("duration", parsed.toFixed(2));
|
||||||
};
|
};
|
||||||
|
|
||||||
const commitEnd = (nextValue: string) => {
|
const commitEnd = (nextValue: string) => {
|
||||||
const parsed = parseTimingValue(nextValue);
|
const parsed = parseTimingValue(nextValue);
|
||||||
if (parsed == null || parsed <= start) return;
|
if (parsed == null || parsed <= start) return;
|
||||||
|
if (derived) {
|
||||||
|
void pinRange(start, parsed - start);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void onSetAttribute("duration", (parsed - start).toFixed(2));
|
void onSetAttribute("duration", (parsed - start).toFixed(2));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ describe("FlatSlider — Grade extensions", () => {
|
|||||||
act(() => rootB.unmount());
|
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(
|
const { host, root } = renderInto(
|
||||||
<FlatSlider
|
<FlatSlider
|
||||||
label="Opacity"
|
label="Opacity"
|
||||||
@@ -501,6 +501,136 @@ describe("FlatSlider — Grade extensions", () => {
|
|||||||
expect(host.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
|
expect(host.querySelector('[data-flat-slider-reset="true"]')).toBeNull();
|
||||||
act(() => root.unmount());
|
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", () => {
|
describe("FlatSelectRow", () => {
|
||||||
|
|||||||
@@ -216,6 +216,29 @@ export function FlatGroupHeader({
|
|||||||
/* FlatSlider — full-width label/track/value row */
|
/* 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<string, number> = {
|
||||||
|
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({
|
export function FlatSlider({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -252,6 +275,11 @@ export function FlatSlider({
|
|||||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const lastCommitAtRef = useRef(0);
|
const lastCommitAtRef = useRef(0);
|
||||||
const pendingRef = useRef<number | null>(null);
|
const pendingRef = useRef<number | null>(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`
|
// Tracks the last value actually sent to onCommit — separate from `value`
|
||||||
// (the committed prop) because in a single pointerdown+pointerup click the
|
// (the committed prop) because in a single pointerdown+pointerup click the
|
||||||
// leading-edge commit fires before the parent has re-rendered with the new
|
// leading-edge commit fires before the parent has re-rendered with the new
|
||||||
@@ -260,6 +288,7 @@ export function FlatSlider({
|
|||||||
const lastCommittedRef = useRef(value);
|
const lastCommittedRef = useRef(value);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (draggingRef.current) return;
|
||||||
setDraft(value);
|
setDraft(value);
|
||||||
lastCommittedRef.current = value;
|
lastCommittedRef.current = value;
|
||||||
}, [value]);
|
}, [value]);
|
||||||
@@ -313,10 +342,14 @@ export function FlatSlider({
|
|||||||
role="slider"
|
role="slider"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
aria-valuenow={draft}
|
aria-valuenow={draft}
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
className={`relative h-5 flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
className={`relative h-5 flex-1 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
|
draggingRef.current = true;
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
const stepped = stepFromClientX(e.clientX, e.currentTarget.getBoundingClientRect());
|
||||||
setDraft(stepped);
|
setDraft(stepped);
|
||||||
@@ -332,6 +365,9 @@ export function FlatSlider({
|
|||||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
e.currentTarget.releasePointerCapture(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`
|
// Recompute from the event itself rather than reading the `draft`
|
||||||
// closure — if pointerdown+pointerup land in the same React batch
|
// closure — if pointerdown+pointerup land in the same React batch
|
||||||
// (e.g. a very fast click), the onPointerUp handler can still be
|
// (e.g. a very fast click), the onPointerUp handler can still be
|
||||||
@@ -340,6 +376,20 @@ export function FlatSlider({
|
|||||||
setDraft(stepped);
|
setDraft(stepped);
|
||||||
commitDraft(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);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-panel-hover">
|
<div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-panel-hover">
|
||||||
{centerTick && (
|
{centerTick && (
|
||||||
@@ -372,7 +422,7 @@ export function FlatSlider({
|
|||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
{centerTick && (
|
{(centerTick || onReset) && (
|
||||||
<span data-flat-slider-reset-slot="true" className="w-3.5 flex-shrink-0">
|
<span data-flat-slider-reset-slot="true" className="w-3.5 flex-shrink-0">
|
||||||
{tier === "explicitCustom" && onReset && (
|
{tier === "explicitCustom" && onReset && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -411,11 +411,13 @@ function FlatOverflowMaskRows({
|
|||||||
/>
|
/>
|
||||||
<FlatSelectRow
|
<FlatSelectRow
|
||||||
label="Mask"
|
label="Mask"
|
||||||
value={clipPathPreset === "custom" ? "none" : clipPathPreset}
|
value={clipPathPreset}
|
||||||
options={["none", "inset", "circle"]}
|
// "custom" = authored clip-path; showing "none" invites destroying it.
|
||||||
|
options={[...(clipPathPreset === "custom" ? ["custom"] : []), "none", "inset", "circle"]}
|
||||||
tier={resolveValueTier(clipPathPreset === "none" ? undefined : clipPathPreset, "none")}
|
tier={resolveValueTier(clipPathPreset === "none" ? undefined : clipPathPreset, "none")}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
|
if (next === "custom") return;
|
||||||
void onSetStyle(
|
void onSetStyle(
|
||||||
"clip-path",
|
"clip-path",
|
||||||
buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue),
|
buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { ImportedFontAsset } from "./fontAssets";
|
|||||||
import { normalizeTextMetricValue } from "./propertyPanelHelpers";
|
import { normalizeTextMetricValue } from "./propertyPanelHelpers";
|
||||||
import { ColorField } from "./propertyPanelColor";
|
import { ColorField } from "./propertyPanelColor";
|
||||||
import { FontFamilyField } from "./propertyPanelFont";
|
import { FontFamilyField } from "./propertyPanelFont";
|
||||||
|
import { PromotableControl } from "./PromotableControl";
|
||||||
import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
|
import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
|
||||||
import {
|
import {
|
||||||
resolveValueTier,
|
resolveValueTier,
|
||||||
@@ -65,20 +66,33 @@ function FlatTextFieldEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TextAreaField
|
<PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
|
||||||
flat
|
{({ value, onCommit }) => (
|
||||||
label="Content"
|
<TextAreaField
|
||||||
value={field.value}
|
flat
|
||||||
autoFocus={autoFocus}
|
label="Content"
|
||||||
onCommit={(next) => onSetText(next, field.key)}
|
value={value ?? field.value}
|
||||||
/>
|
autoFocus={autoFocus}
|
||||||
<FontFamilyField
|
onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
|
||||||
flat
|
/>
|
||||||
value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
|
)}
|
||||||
importedFonts={fontAssets}
|
</PromotableControl>
|
||||||
onImportFonts={onImportFonts}
|
<PromotableControl
|
||||||
onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
|
channel={{ kind: "style", prop: "font-family" }}
|
||||||
/>
|
enabled={field.source === "self"}
|
||||||
|
>
|
||||||
|
{({ value, onCommit }) => (
|
||||||
|
<FontFamilyField
|
||||||
|
flat
|
||||||
|
value={
|
||||||
|
value ?? (field.computedStyles["font-family"] || styles["font-family"] || "inherit")
|
||||||
|
}
|
||||||
|
importedFonts={fontAssets}
|
||||||
|
onImportFonts={onImportFonts}
|
||||||
|
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "font-family", next))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PromotableControl>
|
||||||
<FlatRow
|
<FlatRow
|
||||||
label="Size"
|
label="Size"
|
||||||
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
||||||
@@ -180,12 +194,19 @@ function FlatTextFieldEditor({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ColorField
|
<PromotableControl
|
||||||
flat
|
channel={{ kind: "style", prop: "color" }}
|
||||||
label="Color"
|
enabled={field.source === "self"}
|
||||||
value={getTextFieldColor(field, styles)}
|
>
|
||||||
onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
|
{({ value, onCommit }) => (
|
||||||
/>
|
<ColorField
|
||||||
|
flat
|
||||||
|
label="Color"
|
||||||
|
value={value ?? getTextFieldColor(field, styles)}
|
||||||
|
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PromotableControl>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ export function isSelectedElementHidden(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4-part element identity for keying panel remounts on selection change —
|
||||||
|
* id or selector alone collides for id-less same-selector siblings, leaving
|
||||||
|
* mount-initialized state pointed at the previous element.
|
||||||
|
*/
|
||||||
|
export function selectionIdentityKey(
|
||||||
|
element: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex">,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
element.id ?? "",
|
||||||
|
element.hfId ?? "",
|
||||||
|
element.selector ?? "",
|
||||||
|
String(element.selectorIndex ?? ""),
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Font types & constants (shared by font and section modules) */
|
/* Font types & constants (shared by font and section modules) */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
@@ -233,7 +233,10 @@ export function useColorGradingController({
|
|||||||
setMediaMetadata(metadata);
|
setMediaMetadata(metadata);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!controller.signal.aborted) MEDIA_METADATA_CACHE.set(cacheKey, null);
|
// Don't cache a transient fetch failure — a cached null would suppress
|
||||||
|
// the HDR banner for this asset for the page's whole lifetime. Leave the
|
||||||
|
// key absent so the next selection retries. (A successful response with
|
||||||
|
// no metadata still caches null above, which IS a stable answer.)
|
||||||
});
|
});
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [projectId, selectedAssetPath]);
|
}, [projectId, selectedAssetPath]);
|
||||||
|
|||||||
@@ -201,4 +201,56 @@ describe("toggleTimelineElementHidden", () => {
|
|||||||
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
|
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides several elements in ONE atomic write when given an array of keys", async () => {
|
||||||
|
const files = new Map([
|
||||||
|
[
|
||||||
|
"index.html",
|
||||||
|
`<div id="hero" data-start="0" data-duration="2"></div>
|
||||||
|
<div id="caption" data-start="1" data-duration="2"></div>
|
||||||
|
<div id="badge" data-start="2" data-duration="2"></div>`,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
stubProjectFiles(files);
|
||||||
|
|
||||||
|
const hero = element({ id: "hero", key: "index.html:#hero", domId: "hero", track: 0 });
|
||||||
|
const caption = element({
|
||||||
|
id: "caption",
|
||||||
|
key: "index.html:#caption",
|
||||||
|
domId: "caption",
|
||||||
|
track: 1,
|
||||||
|
});
|
||||||
|
const badge = element({ id: "badge", key: "index.html:#badge", domId: "badge", track: 2 });
|
||||||
|
|
||||||
|
const writes: Array<{ path: string; content: string }> = [];
|
||||||
|
const recordEdit = vi.fn();
|
||||||
|
|
||||||
|
await toggleTimelineElementHidden({
|
||||||
|
projectId: "project-1",
|
||||||
|
activeCompPath: "index.html",
|
||||||
|
timelineElements: [hero, caption, badge],
|
||||||
|
elementKey: ["index.html:#hero", "index.html:#caption"],
|
||||||
|
hidden: true,
|
||||||
|
previewIframe: null,
|
||||||
|
writeProjectFile: async (path, content) => {
|
||||||
|
writes.push({ path, content });
|
||||||
|
},
|
||||||
|
recordEdit,
|
||||||
|
domEditSaveTimestampRef: { current: 0 },
|
||||||
|
pendingTimelineEditPathRef: { current: new Set() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// One write carrying BOTH hides — per-element writes would clobber each
|
||||||
|
// other (each starts from the original file content).
|
||||||
|
expect(writes).toHaveLength(1);
|
||||||
|
expect(writes[0]?.content).toContain(
|
||||||
|
'id="hero" data-start="0" data-duration="2" data-hidden=""',
|
||||||
|
);
|
||||||
|
expect(writes[0]?.content).toContain(
|
||||||
|
'id="caption" data-start="1" data-duration="2" data-hidden=""',
|
||||||
|
);
|
||||||
|
expect(writes[0]?.content).toContain('id="badge" data-start="2" data-duration="2"></div>');
|
||||||
|
expect(recordEdit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recordEdit.mock.calls[0]?.[0]?.label).toBe("Hide 2 elements");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ interface ToggleTimelineTrackHiddenInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ToggleTimelineElementHiddenInput extends Omit<ToggleTimelineTrackHiddenInput, "track"> {
|
interface ToggleTimelineElementHiddenInput extends Omit<ToggleTimelineTrackHiddenInput, "track"> {
|
||||||
elementKey: string;
|
/** One timeline key, or several to hide/show in a single atomic file write. */
|
||||||
|
elementKey: string | readonly string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetElementsHiddenInput {
|
interface SetElementsHiddenInput {
|
||||||
@@ -233,13 +234,21 @@ export async function toggleTimelineElementHidden({
|
|||||||
domEditSaveTimestampRef,
|
domEditSaveTimestampRef,
|
||||||
pendingTimelineEditPathRef,
|
pendingTimelineEditPathRef,
|
||||||
}: ToggleTimelineElementHiddenInput): Promise<string[]> {
|
}: ToggleTimelineElementHiddenInput): Promise<string[]> {
|
||||||
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({
|
return setElementsHidden({
|
||||||
projectId,
|
projectId,
|
||||||
activeCompPath,
|
activeCompPath,
|
||||||
elements: element ? [element] : [],
|
elements,
|
||||||
hidden,
|
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,
|
previewIframe,
|
||||||
writeProjectFile,
|
writeProjectFile,
|
||||||
recordEdit,
|
recordEdit,
|
||||||
@@ -323,11 +332,11 @@ export function useTimelineElementVisibilityEditing({
|
|||||||
isRecordingRef,
|
isRecordingRef,
|
||||||
forceReloadSdkSession,
|
forceReloadSdkSession,
|
||||||
}: UseTimelineElementVisibilityEditingInput): (
|
}: UseTimelineElementVisibilityEditingInput): (
|
||||||
elementKey: string,
|
elementKey: string | readonly string[],
|
||||||
hidden: boolean,
|
hidden: boolean,
|
||||||
) => Promise<void> {
|
) => Promise<void> {
|
||||||
return useCallback(
|
return useCallback(
|
||||||
async (elementKey: string, hidden: boolean) => {
|
async (elementKey: string | readonly string[], hidden: boolean) => {
|
||||||
if (isRecordingRef?.current) {
|
if (isRecordingRef?.current) {
|
||||||
showToast("Cannot edit timeline while recording", "error");
|
showToast("Cannot edit timeline while recording", "error");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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> | void;
|
||||||
|
|
||||||
export function resolveTimelineSelectionSeekTime(
|
export function resolveTimelineSelectionSeekTime(
|
||||||
currentTime: number,
|
currentTime: number,
|
||||||
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,
|
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user