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,
|
||||
} 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<string, { before: string; after: string }>;
|
||||
}) => Promise<void>;
|
||||
onToggleElementHidden?: (elementKey: string, hidden: boolean) => Promise<void> | 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 = (
|
||||
<DesignPanelPromoteProvider
|
||||
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
|
||||
|
||||
@@ -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 (
|
||||
<PropertyPanelFlat
|
||||
{...props}
|
||||
key={element.id ?? element.selector}
|
||||
key={selectionIdentityKey(element)}
|
||||
element={element}
|
||||
styles={styles}
|
||||
sections={sections}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,19 @@ 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;
|
||||
@@ -15,8 +28,7 @@ export function createGsapLivePreview(iframeRef: { readonly current: HTMLIFrameE
|
||||
| { 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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<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({
|
||||
label,
|
||||
value,
|
||||
@@ -252,6 +275,11 @@ export function FlatSlider({
|
||||
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastCommitAtRef = useRef(0);
|
||||
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`
|
||||
// (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);
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-panel-hover">
|
||||
{centerTick && (
|
||||
@@ -372,7 +422,7 @@ export function FlatSlider({
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
{centerTick && (
|
||||
{(centerTick || onReset) && (
|
||||
<span data-flat-slider-reset-slot="true" className="w-3.5 flex-shrink-0">
|
||||
{tier === "explicitCustom" && onReset && (
|
||||
<button
|
||||
|
||||
@@ -411,11 +411,13 @@ function FlatOverflowMaskRows({
|
||||
/>
|
||||
<FlatSelectRow
|
||||
label="Mask"
|
||||
value={clipPathPreset === "custom" ? "none" : clipPathPreset}
|
||||
options={["none", "inset", "circle"]}
|
||||
value={clipPathPreset}
|
||||
// "custom" = authored clip-path; showing "none" invites destroying it.
|
||||
options={[...(clipPathPreset === "custom" ? ["custom"] : []), "none", "inset", "circle"]}
|
||||
tier={resolveValueTier(clipPathPreset === "none" ? undefined : clipPathPreset, "none")}
|
||||
disabled={disabled}
|
||||
onChange={(next) => {
|
||||
if (next === "custom") return;
|
||||
void onSetStyle(
|
||||
"clip-path",
|
||||
buildClipPathValue(next as "none" | "inset" | "circle", radiusValue, clipPathValue),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ImportedFontAsset } from "./fontAssets";
|
||||
import { normalizeTextMetricValue } from "./propertyPanelHelpers";
|
||||
import { ColorField } from "./propertyPanelColor";
|
||||
import { FontFamilyField } from "./propertyPanelFont";
|
||||
import { PromotableControl } from "./PromotableControl";
|
||||
import { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
|
||||
import {
|
||||
resolveValueTier,
|
||||
@@ -65,20 +66,33 @@ function FlatTextFieldEditor({
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextAreaField
|
||||
flat
|
||||
label="Content"
|
||||
value={field.value}
|
||||
autoFocus={autoFocus}
|
||||
onCommit={(next) => onSetText(next, field.key)}
|
||||
/>
|
||||
<FontFamilyField
|
||||
flat
|
||||
value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
|
||||
importedFonts={fontAssets}
|
||||
onImportFonts={onImportFonts}
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
|
||||
/>
|
||||
<PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
|
||||
{({ value, onCommit }) => (
|
||||
<TextAreaField
|
||||
flat
|
||||
label="Content"
|
||||
value={value ?? field.value}
|
||||
autoFocus={autoFocus}
|
||||
onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
|
||||
/>
|
||||
)}
|
||||
</PromotableControl>
|
||||
<PromotableControl
|
||||
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
|
||||
label="Size"
|
||||
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
||||
@@ -180,12 +194,19 @@ function FlatTextFieldEditor({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ColorField
|
||||
flat
|
||||
label="Color"
|
||||
value={getTextFieldColor(field, styles)}
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
|
||||
/>
|
||||
<PromotableControl
|
||||
channel={{ kind: "style", prop: "color" }}
|
||||
enabled={field.source === "self"}
|
||||
>
|
||||
{({ 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) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -233,7 +233,10 @@ export function useColorGradingController({
|
||||
setMediaMetadata(metadata);
|
||||
})
|
||||
.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();
|
||||
}, [projectId, selectedAssetPath]);
|
||||
|
||||
@@ -201,4 +201,56 @@ describe("toggleTimelineElementHidden", () => {
|
||||
usePlayerStore.getState().elements.find((el) => el.key === "index.html:#track-mate")?.hidden,
|
||||
).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"> {
|
||||
elementKey: string;
|
||||
/** One timeline key, or several to hide/show in a single atomic file write. */
|
||||
elementKey: string | readonly string[];
|
||||
}
|
||||
|
||||
interface SetElementsHiddenInput {
|
||||
@@ -233,13 +234,21 @@ export async function toggleTimelineElementHidden({
|
||||
domEditSaveTimestampRef,
|
||||
pendingTimelineEditPathRef,
|
||||
}: 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({
|
||||
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,
|
||||
@@ -323,11 +332,11 @@ export function useTimelineElementVisibilityEditing({
|
||||
isRecordingRef,
|
||||
forceReloadSdkSession,
|
||||
}: UseTimelineElementVisibilityEditingInput): (
|
||||
elementKey: string,
|
||||
elementKey: string | readonly string[],
|
||||
hidden: boolean,
|
||||
) => Promise<void> {
|
||||
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;
|
||||
|
||||
@@ -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(
|
||||
currentTime: number,
|
||||
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,
|
||||
|
||||
Reference in New Issue
Block a user