Files
hyperframes/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx
T
Vance Ingalls 5dd9efe555 fix(studio): resolve 8 confirmed adversarial-review findings across the flat-inspector stack
Fixes issues raised in the Deepwork re-review of #2120-#2190 that weren't
covered by #2225's earlier fix pass:

- useColorGradingController: reset grading/compare/mediaMetadata state (and
  cancel pending persist/status timers) when selection changes to a
  different element — this hook is called unconditionally on every render
  (unlike legacy ColorGradingSection, remounted via a selectionIdentityKey
  React key), so switching selection reused the previous element's state.
- useColorGradingController: stop permanently caching a non-OK
  /media/metadata response as null — a transient server error poisoned the
  HDR banner for that asset for the whole page lifetime.
- FlatSelectRow: preserve a valid authored value outside the preset list
  (e.g. mix-blend-mode: difference, an arbitrary object-position) instead of
  silently misrepresenting it as the first preset — touching the control
  would overwrite real persisted state.
- FlatSlider: the throttled trailing commit now reads onCommit through a
  ref updated every render instead of closing over it at schedule time — a
  caller whose onCommit spreads other current state (Grade's per-detail
  commits) could otherwise have a delayed commit revert whatever the user
  changed on a different control in the same 40ms window.
- FlatSlider: flush a still-queued trailing commit on unmount instead of
  dropping it, and disable the reset button when the slider itself is
  disabled.
- FlatSlider: add touch-action: none to the track so touch drags don't
  compete with page scroll.
- FlatColorGradingAccessory: clean up the compare-hold's window listeners
  on unmount, not only on release — switching selection mid-hold used to
  leak them.
- Align (flat Text): re-clicking the option already visually active for a
  logical start/end value no longer rewrites it to the physical left/right,
  preserving RTL semantics.
- FlatSegmentedRow: give every option an accessible name and aria-pressed
  state — two visually-identical glyph buttons (upright/italic "A") had no
  way to be told apart by assistive tech.
- PropertyPanelFlat: the panel body falls back to its own scroll when the
  collapsed group headers alone exceed the available height, so groups
  can't become permanently unreachable in a short pane.

New regression tests for all of the above; full studio suite at the known
pre-existing baseline (55 failures unrelated to this stack).
2026-07-14 16:28:33 -07:00

372 lines
11 KiB
TypeScript

import { FlatRow, FlatSegmentedRow, FlatSelectRow } from "./propertyPanelFlatPrimitives";
import { KeyframeNavigation } from "./KeyframeNavigation";
import { formatPxMetricValue } from "./propertyPanelHelpers";
import { STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
import { resolveValueTier } from "./propertyPanelValueTier";
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
import type { DomEditSelection } from "./domEditingTypes";
type KeyframeEntry = Array<{
percentage: number;
tweenPercentage?: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
interface GeometryRowsProps {
element: DomEditSelection;
displayX: number;
displayY: number;
displayW: number;
displayH: number;
displayR: number;
manualOffsetEditingDisabled: boolean;
manualSizeEditingDisabled: boolean;
manualRotationEditingDisabled: boolean;
commitManualOffset: (axis: "x" | "y", value: string) => void;
commitManualSize: (dimension: "width" | "height", value: string) => void;
commitManualRotation: (value: string) => void;
gsapAnimId: string | null;
navKeyframes: KeyframeEntry;
currentPct: number;
seekFromKfPct: (pct: number) => void;
animIdForProp: (prop: string) => string;
onCommitAnimatedProperty?: (
element: DomEditSelection,
property: string,
value: number,
) => Promise<void>;
onRemoveKeyframe?: (animId: string, pct: number) => void;
onConvertToKeyframes?: (animId: string) => void;
}
function KeyframeGutter({
element,
property,
displayValue,
gsapAnimId,
navKeyframes,
currentPct,
seekFromKfPct,
animIdForProp,
onCommitAnimatedProperty,
onRemoveKeyframe,
onConvertToKeyframes,
}: {
property: string;
displayValue: number;
} & Pick<
GeometryRowsProps,
| "element"
| "gsapAnimId"
| "navKeyframes"
| "currentPct"
| "seekFromKfPct"
| "animIdForProp"
| "onCommitAnimatedProperty"
| "onRemoveKeyframe"
| "onConvertToKeyframes"
>) {
if (!STUDIO_KEYFRAMES_ENABLED || !gsapAnimId) return null;
const hasKeyframesOnProp = Boolean(navKeyframes?.some((kf) => property in kf.properties));
return (
<span data-flat-kf-gutter="true" style={{ opacity: hasKeyframesOnProp ? 1 : 0.3 }}>
<KeyframeNavigation
property={property}
keyframes={navKeyframes}
currentPercentage={currentPct}
onSeek={seekFromKfPct}
onAddKeyframe={() =>
onCommitAnimatedProperty && void onCommitAnimatedProperty(element, property, displayValue)
}
onRemoveKeyframe={(pct) => onRemoveKeyframe?.(animIdForProp(property), pct)}
onConvertToKeyframes={() => onConvertToKeyframes?.(animIdForProp(property))}
/>
</span>
);
}
export function LayoutGeometryRows({
element,
displayX,
displayY,
displayW,
displayH,
displayR,
manualOffsetEditingDisabled,
manualSizeEditingDisabled,
manualRotationEditingDisabled,
commitManualOffset,
commitManualSize,
commitManualRotation,
gsapAnimId,
navKeyframes,
currentPct,
seekFromKfPct,
animIdForProp,
onCommitAnimatedProperty,
onRemoveKeyframe,
onConvertToKeyframes,
}: GeometryRowsProps) {
const gutterProps = {
element,
gsapAnimId,
navKeyframes,
currentPct,
seekFromKfPct,
animIdForProp,
onCommitAnimatedProperty,
onRemoveKeyframe,
onConvertToKeyframes,
};
return (
<>
<FlatRow
label="X"
value={formatPxMetricValue(displayX)}
tier={displayX === 0 ? "default" : "explicitCustom"}
disabled={manualOffsetEditingDisabled}
onCommit={(next) => commitManualOffset("x", next)}
suffix={<KeyframeGutter property="x" displayValue={displayX} {...gutterProps} />}
/>
<FlatRow
label="Y"
value={formatPxMetricValue(displayY)}
tier={displayY === 0 ? "default" : "explicitCustom"}
disabled={manualOffsetEditingDisabled}
onCommit={(next) => commitManualOffset("y", next)}
suffix={<KeyframeGutter property="y" displayValue={displayY} {...gutterProps} />}
/>
<FlatRow
label="W"
value={formatPxMetricValue(displayW)}
tier="default"
disabled={manualSizeEditingDisabled}
onCommit={(next) => commitManualSize("width", next)}
suffix={<KeyframeGutter property="width" displayValue={displayW} {...gutterProps} />}
/>
<FlatRow
label="H"
value={formatPxMetricValue(displayH)}
tier="default"
disabled={manualSizeEditingDisabled}
onCommit={(next) => commitManualSize("height", next)}
suffix={<KeyframeGutter property="height" displayValue={displayH} {...gutterProps} />}
/>
<FlatRow
label="Angle"
value={`${displayR}°`}
tier="default"
disabled={manualRotationEditingDisabled}
onCommit={(next) => commitManualRotation(next.replace("°", ""))}
suffix={<KeyframeGutter property="rotation" displayValue={displayR} {...gutterProps} />}
/>
</>
);
}
export function LayoutZIndexRow({
styles,
onSetStyle,
}: {
styles: Record<string, string>;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
const zIndex = String(parseInt(styles["z-index"] || "auto", 10) || 0);
return (
<FlatRow
label="Z-index"
value={zIndex}
tier="default"
onCommit={(next) => void onSetStyle("z-index", next)}
/>
);
}
export function LayoutFlexBlock({
styles,
onSetStyle,
disabled,
}: {
styles: Record<string, string>;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
disabled: boolean;
}) {
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
if (!isFlex) return null;
const direction = styles["flex-direction"] || "row";
return (
<div className="border-l-2 border-panel-border-input py-0.5 pl-[10px]">
<div className="mb-[3px] text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Flex
</div>
<FlatSegmentedRow
label="Direction"
options={[
{ key: "row", node: "→ Row", label: "Row", active: direction === "row" },
{ key: "column", node: "Column", label: "Column", active: direction === "column" },
]}
disabled={disabled}
onChange={(next) => void onSetStyle("flex-direction", next)}
/>
<FlatSelectRow
label="Justify"
value={styles["justify-content"] || "flex-start"}
tier={resolveValueTier(styles["justify-content"], "flex-start")}
disabled={disabled}
options={[
"flex-start",
"center",
"space-between",
"space-around",
"space-evenly",
"flex-end",
]}
onChange={(next) => void onSetStyle("justify-content", next)}
/>
<FlatSelectRow
label="Align"
value={styles["align-items"] || "stretch"}
tier={resolveValueTier(styles["align-items"], "stretch")}
disabled={disabled}
options={["stretch", "flex-start", "center", "flex-end", "baseline"]}
onChange={(next) => void onSetStyle("align-items", next)}
/>
<FlatRow
label="Gap"
value={styles.gap ?? "0px"}
tier={resolveValueTier(styles.gap, "0px")}
disabled={disabled}
onCommit={(next) => void onSetStyle("gap", next.endsWith("px") ? next : `${next}px`)}
/>
</div>
);
}
export function LayoutTransform3DBlock({
gsapRuntimeValues,
gsapAnimId,
resolveAnimIdForProp,
gsapKeyframes,
currentPct,
elStart,
elDuration,
element,
onCommitAnimatedProperty,
onCommitAnimatedProperties,
onSeekToTime,
onRemoveKeyframe,
onConvertToKeyframes,
onLivePreviewProps,
}: {
gsapRuntimeValues: Record<string, number>;
gsapAnimId: string | null;
resolveAnimIdForProp?: (prop: string) => string | null;
gsapKeyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}> | null;
currentPct: number;
elStart: number;
elDuration: number;
element: DomEditSelection;
onCommitAnimatedProperty?: (
element: DomEditSelection,
property: string,
value: number,
) => Promise<void>;
onCommitAnimatedProperties?: (
element: DomEditSelection,
props: Record<string, number | string>,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
onRemoveKeyframe?: (animId: string, pct: number) => void;
onConvertToKeyframes?: (animId: string, duration?: number) => void;
onLivePreviewProps?: (element: DomEditSelection, props: Record<string, number>) => void;
}) {
return (
<div className="border-t border-panel-hairline pt-2.5">
<div className="mb-[3px] text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
3D Transform
</div>
<PropertyPanel3dTransform
gsapRuntimeValues={gsapRuntimeValues}
gsapAnimId={gsapAnimId}
resolveAnimIdForProp={resolveAnimIdForProp}
gsapKeyframes={gsapKeyframes}
currentPct={currentPct}
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={onRemoveKeyframe}
onConvertToKeyframes={onConvertToKeyframes}
onLivePreviewProps={onLivePreviewProps}
/>
</div>
);
}
interface FlatLayoutSectionProps
extends
Omit<GeometryRowsProps, never>,
Pick<
Parameters<typeof LayoutTransform3DBlock>[0],
| "gsapRuntimeValues"
| "resolveAnimIdForProp"
| "gsapKeyframes"
| "elStart"
| "elDuration"
| "onCommitAnimatedProperties"
| "onSeekToTime"
| "onLivePreviewProps"
> {
element: DomEditSelection;
styles: Record<string, string>;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
disabled: boolean;
}
export function FlatLayoutSection({
element,
styles,
onSetStyle,
disabled,
gsapRuntimeValues,
resolveAnimIdForProp,
gsapKeyframes,
elStart,
elDuration,
onCommitAnimatedProperties,
onSeekToTime,
onLivePreviewProps,
...geometry
}: FlatLayoutSectionProps) {
return (
<div className="space-y-1.5">
<LayoutGeometryRows element={element} {...geometry} />
<LayoutZIndexRow styles={styles} onSetStyle={onSetStyle} />
<LayoutFlexBlock styles={styles} onSetStyle={onSetStyle} disabled={disabled} />
<LayoutTransform3DBlock
gsapRuntimeValues={gsapRuntimeValues}
gsapAnimId={geometry.gsapAnimId}
resolveAnimIdForProp={resolveAnimIdForProp}
gsapKeyframes={gsapKeyframes}
currentPct={geometry.currentPct}
elStart={elStart}
elDuration={elDuration}
element={element}
onCommitAnimatedProperty={geometry.onCommitAnimatedProperty}
onCommitAnimatedProperties={onCommitAnimatedProperties}
onSeekToTime={onSeekToTime}
onRemoveKeyframe={geometry.onRemoveKeyframe}
onConvertToKeyframes={geometry.onConvertToKeyframes}
onLivePreviewProps={onLivePreviewProps}
/>
</div>
);
}