Files
hyperframes/packages/studio/src/components/editor/propertyPanelFlatStyleSections.tsx
T
Miguel Ángel 2b65b4efce fix(studio): harden composition timeline reliability (#2615)
* fix(studio): preserve composition playback continuity

* feat(studio): drag compositions into the timeline

* fix(studio): collapse expanded composition move aliases

* fix(studio): make timeline cuts atomic

* fix(studio): group inspector gesture history

* test(studio): cover masked text selection

* fix(studio): harden composition timeline reliability

* fix(studio): satisfy CI source gates

* fix(studio): harden composition mutation requests
2026-07-17 14:15:30 -04:00

506 lines
16 KiB
TypeScript

// fallow-ignore-file code-duplication
import { useEffect, useState } from "react";
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
import { BorderRadiusEditor } from "./BorderRadiusEditor";
import { STROKE_STYLE_OPTIONS } from "./propertyPanelFlatStyleHelpers";
import {
buildBoxShadowPresetValue,
buildClipPathValue,
buildStrokeStyleUpdates,
buildStrokeWidthStyleUpdates,
extractBackgroundImageUrl,
formatNumericValue,
formatPxMetricValue,
getCssFilterFunctionPx,
inferBoxShadowPreset,
inferClipPathPreset,
normalizePanelPxValue,
parseNumericValue,
parsePxMetricValue,
setCssFilterFunctionPx,
type BoxShadowPreset,
} from "./propertyPanelHelpers";
import {
FlatRow,
FlatSegmentedRow,
FlatSelectRow,
FlatSlider,
} from "./propertyPanelFlatPrimitives";
import { FlatMaskInsetRows } from "./propertyPanelFlatMaskInsetRows";
import { resolveValueTier } from "./propertyPanelValueTier";
import { ColorField } from "./propertyPanelColor";
import { GradientField, ImageFillField } from "./propertyPanelFill";
/* ------------------------------------------------------------------ */
/* Flat Fill sub-block (design_handoff_studio_inspector, #11a) */
/* ------------------------------------------------------------------ */
// fallow-ignore-next-line complexity
function FlatFillFields({
projectId,
element,
styles,
assets,
onSetStyle,
onPreviewStyle,
onImportAssets,
}: {
projectId: string;
element: DomEditSelection;
styles: Record<string, string>;
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onPreviewStyle?: (prop: string, value: string) => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
}) {
const styleEditingDisabled = !element.capabilities.canEditStyles;
const backgroundImage = styles["background-image"] ?? "none";
const hasTextControls = isTextEditableSelection(element);
const fillMode =
backgroundImage && backgroundImage !== "none"
? backgroundImage.includes("gradient")
? "Gradient"
: "Image"
: "Solid";
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
const imageUrl = extractBackgroundImageUrl(backgroundImage);
useEffect(() => {
setPreferredFillMode(fillMode);
}, [fillMode, element.id, element.selector, backgroundImage]);
const handleFillModeChange = (nextMode: string) => {
setPreferredFillMode(nextMode);
if (nextMode === "Solid") {
onSetStyle("background-image", "none");
return;
}
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
onSetStyle(
"background-image",
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
);
}
};
return (
<>
<FlatSegmentedRow
label="Fill"
options={[
{ key: "Solid", node: "Solid", label: "Solid", active: preferredFillMode === "Solid" },
{
key: "Gradient",
node: "Gradient",
label: "Gradient",
active: preferredFillMode === "Gradient",
},
{ key: "Image", node: "Image", label: "Image", active: preferredFillMode === "Image" },
]}
disabled={styleEditingDisabled}
onChange={handleFillModeChange}
/>
{preferredFillMode === "Solid" ? (
<ColorField
flat
label="Color"
value={styles["background-color"] ?? "transparent"}
disabled={styleEditingDisabled}
onPreview={(next) => onPreviewStyle?.("background-color", next)}
onCommit={(next) => onSetStyle("background-color", next)}
/>
) : preferredFillMode === "Gradient" ? (
<GradientField
value={
backgroundImage !== "none"
? backgroundImage
: serializeGradient(buildDefaultGradientModel(styles["background-color"]))
}
fallbackColor={styles["background-color"]}
disabled={styleEditingDisabled}
onCommit={(next) => onSetStyle("background-image", next)}
/>
) : (
<ImageFillField
projectId={projectId}
sourceFile={element.sourceFile}
value={imageUrl}
assets={assets}
disabled={styleEditingDisabled}
onCommit={(next) => onSetStyle("background-image", next)}
onImportAssets={onImportAssets}
/>
)}
{!hasTextControls && (
<ColorField
flat
label="Text color"
value={styles.color ?? "rgb(0, 0, 0)"}
disabled={styleEditingDisabled}
onCommit={(next) => onSetStyle("color", next)}
/>
)}
</>
);
}
/* ------------------------------------------------------------------ */
/* Flat Stroke row — width (numeric), style (select), color */
/* ------------------------------------------------------------------ */
function FlatStrokeRow({
styles,
disabled,
onSetStyle,
}: {
styles: Record<string, string>;
disabled: boolean;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
const borderWidthValue =
parsePxMetricValue(styles["border-width"] ?? "") ??
parsePxMetricValue(styles["border-top-width"] ?? "") ??
0;
const borderStyleValue = styles["border-style"] || styles["border-top-style"] || "none";
const borderColorValue =
styles["border-color"] || styles["border-top-color"] || "rgba(255, 255, 255, 0.18)";
const widthDisplay = formatPxMetricValue(borderWidthValue);
return (
<>
<FlatRow
label="Stroke width"
value={widthDisplay}
tier={resolveValueTier(styles["border-width"], "0px")}
disabled={disabled}
onCommit={async (next) => {
const normalizedWidth = normalizePanelPxValue(next, {
min: 0,
max: 200,
fallback: borderWidthValue,
});
if (!normalizedWidth) return;
// buildStrokeWidthStyleUpdates already covers "a width typed in
// from 0 needs a style to actually render a visible border" —
// it only defaults to solid when the current style is none/hidden,
// never clobbering an already-chosen style (dashed, dotted, …).
for (const [property, value] of buildStrokeWidthStyleUpdates(
normalizedWidth,
borderStyleValue,
)) {
await onSetStyle(property, value);
}
}}
/>
<FlatSelectRow
label="Stroke style"
value={borderStyleValue}
// Valid border-style keywords — the ONLY way to set this, since a
// free-text field here would require typing an exact CSS keyword
// (e.g. "dashed") with no indication of which ones are valid.
options={STROKE_STYLE_OPTIONS}
tier={resolveValueTier(styles["border-style"], "none")}
disabled={disabled}
onChange={async (next) => {
for (const [property, value] of buildStrokeStyleUpdates(
next,
formatPxMetricValue(borderWidthValue),
)) {
await onSetStyle(property, value);
}
}}
/>
<ColorField
flat
label="Stroke color"
value={borderColorValue}
disabled={disabled}
onCommit={(next) => onSetStyle("border-color", next)}
/>
</>
);
}
/* ------------------------------------------------------------------ */
/* Flat Radius row — always delegates to BorderRadiusEditor */
/* ------------------------------------------------------------------ */
// fallow-ignore-next-line complexity
function FlatRadiusRow({
styles,
gsapBorderRadius,
disabled,
onSetStyle,
}: {
styles: Record<string, string>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
disabled: boolean;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
const radiusTL =
gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue;
const radiusTR =
gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue;
const radiusBR =
gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue;
const radiusBL =
gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue;
const commit = (corner: "all" | "tl" | "tr" | "br" | "bl", value: number) => {
const px = `${formatNumericValue(value)}px`;
if (corner === "all") {
void onSetStyle("border-radius", px);
return;
}
const prop = {
tl: "border-top-left-radius",
tr: "border-top-right-radius",
br: "border-bottom-right-radius",
bl: "border-bottom-left-radius",
}[corner];
void onSetStyle(prop, px);
};
return (
<BorderRadiusEditor
tl={radiusTL}
tr={radiusTR}
br={radiusBR}
bl={radiusBL}
disabled={disabled}
onCommit={commit}
/>
);
}
/* ------------------------------------------------------------------ */
/* Flat Shadow + Blend rows */
/* ------------------------------------------------------------------ */
function FlatShadowBlendRows({
styles,
disabled,
onSetStyle,
}: {
styles: Record<string, string>;
disabled: boolean;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
const boxShadowPreset = inferBoxShadowPreset(styles["box-shadow"]);
const blendValue = styles["mix-blend-mode"] || "normal";
return (
<>
<FlatSelectRow
label="Shadow"
value={boxShadowPreset}
options={["none", "soft", "lift", "glow", "custom"]}
tier={resolveValueTier(boxShadowPreset === "none" ? undefined : boxShadowPreset, "none")}
disabled={disabled}
onChange={(next) => {
if (next === "custom") return;
void onSetStyle(
"box-shadow",
buildBoxShadowPresetValue(next as BoxShadowPreset, styles["box-shadow"]),
);
}}
onReset={() => void onSetStyle("box-shadow", "none")}
/>
<FlatSelectRow
label="Blend"
value={blendValue}
options={["normal", "multiply", "screen", "overlay", "darken", "lighten"]}
tier={resolveValueTier(styles["mix-blend-mode"], "normal")}
disabled={disabled}
onChange={(next) => void onSetStyle("mix-blend-mode", next)}
onReset={() => void onSetStyle("mix-blend-mode", "normal")}
/>
</>
);
}
/* ------------------------------------------------------------------ */
/* Flat Layer blur + Backdrop sliders */
/* ------------------------------------------------------------------ */
function FlatBlurSliders({
styles,
disabled,
onSetStyle,
}: {
styles: Record<string, string>;
disabled: boolean;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
const filterBlurValue = getCssFilterFunctionPx(styles.filter, "blur");
const backdropBlurValue = getCssFilterFunctionPx(styles["backdrop-filter"], "blur");
return (
<>
<FlatSlider
label="Layer blur"
value={filterBlurValue}
min={0}
max={Math.max(40, Math.ceil(filterBlurValue))}
tier={filterBlurValue > 0 ? "explicitCustom" : "default"}
displayValue={`${formatNumericValue(filterBlurValue)}px`}
disabled={disabled}
onCommit={(next) =>
void onSetStyle("filter", setCssFilterFunctionPx(styles.filter, "blur", next))
}
/>
<FlatSlider
label="Backdrop"
value={backdropBlurValue}
min={0}
max={Math.max(60, Math.ceil(backdropBlurValue))}
tier={backdropBlurValue > 0 ? "explicitCustom" : "default"}
displayValue={`${formatNumericValue(backdropBlurValue)}px`}
disabled={disabled}
onCommit={(next) =>
void onSetStyle(
"backdrop-filter",
setCssFilterFunctionPx(styles["backdrop-filter"], "blur", next),
)
}
/>
</>
);
}
// Flat Overflow + Mask rows (+ inset sides).
function FlatOverflowMaskRows({
styles,
disabled,
onSetStyle,
}: {
styles: Record<string, string>;
disabled: boolean;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
const clipPathValue = styles["clip-path"] || "none";
const clipPathPreset = inferClipPathPreset(clipPathValue);
return (
<>
<FlatSelectRow
label="Overflow"
value={styles.overflow || "visible"}
options={["visible", "hidden", "clip", "auto", "scroll"]}
tier={resolveValueTier(styles.overflow, "visible")}
disabled={disabled}
onChange={(next) => void onSetStyle("overflow", next)}
onReset={() => void onSetStyle("overflow", "visible")}
/>
<FlatSelectRow
label="Mask"
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),
);
}}
onReset={() => void onSetStyle("clip-path", "none")}
/>
<FlatMaskInsetRows
clipPathValue={clipPathValue}
radiusValue={radiusValue}
disabled={disabled}
onSetStyle={onSetStyle}
/>
</>
);
}
/* ------------------------------------------------------------------ */
/* Flat Opacity slider */
/* ------------------------------------------------------------------ */
function FlatOpacitySlider({
styles,
disabled,
onSetStyle,
}: {
styles: Record<string, string>;
disabled: boolean;
onSetStyle: (prop: string, value: string) => void | Promise<void>;
}) {
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
return (
<FlatSlider
label="Opacity"
value={opacityValue}
min={0}
max={100}
tier="explicitCustom"
displayValue={`${opacityValue}%`}
disabled={disabled}
onCommit={(next) => void onSetStyle("opacity", formatNumericValue(next / 100))}
/>
);
}
export function FlatStyleSection({
projectId,
element,
styles,
assets,
onSetStyle,
onPreviewStyle,
onImportAssets,
gsapBorderRadius,
}: {
projectId: string;
element: DomEditSelection;
styles: Record<string, string>;
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise<void>;
onPreviewStyle?: (prop: string, value: string) => void;
onImportAssets?: (files: FileList) => Promise<string[]>;
gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
}) {
const styleEditingDisabled = !element.capabilities.canEditStyles;
return (
<div className="space-y-1.5">
<FlatFillFields
projectId={projectId}
element={element}
styles={styles}
assets={assets}
onSetStyle={onSetStyle}
onPreviewStyle={onPreviewStyle}
onImportAssets={onImportAssets}
/>
<FlatStrokeRow styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
<FlatRadiusRow
styles={styles}
gsapBorderRadius={gsapBorderRadius}
disabled={styleEditingDisabled}
onSetStyle={onSetStyle}
/>
<FlatShadowBlendRows
styles={styles}
disabled={styleEditingDisabled}
onSetStyle={onSetStyle}
/>
<FlatBlurSliders styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
<FlatOverflowMaskRows
styles={styles}
disabled={styleEditingDisabled}
onSetStyle={onSetStyle}
/>
<FlatOpacitySlider styles={styles} disabled={styleEditingDisabled} onSetStyle={onSetStyle} />
</div>
);
}