Files
hyperframes/packages/studio/src/components/editor/propertyPanelFlatTextSection.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

394 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Plus, X } from "../../icons/SystemIcons";
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
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,
VALUE_TIER_LABEL_CLASS,
VALUE_TIER_VALUE_CLASS,
} from "./propertyPanelValueTier";
import {
detectAvailableWeights,
formatTextFieldPreview,
getTextFieldColor,
getTextStyleValue,
TextAreaField,
WEIGHT_LABELS,
} from "./propertyPanelSections";
/* ------------------------------------------------------------------ */
/* Flat text section (design_handoff_studio_inspector, #10a) */
/* ------------------------------------------------------------------ */
const ALIGN_OPTIONS = [
{ key: "left", label: "left", node: "L" },
{ key: "center", label: "center", node: "C" },
{ key: "right", label: "right", node: "R" },
{ key: "justify", label: "justify", node: "J" },
];
const CASE_OPTIONS = [
{ key: "none", label: "none", node: "" },
{ key: "uppercase", label: "uppercase", node: "AG" },
{ key: "lowercase", label: "lowercase", node: "ag" },
{ key: "capitalize", label: "capitalize", node: "Ag" },
];
function FlatTextFieldEditor({
field,
styles,
fontAssets,
onImportFonts,
onSetText,
onSetTextFieldStyle,
autoFocus = false,
}: {
field: DomEditSelection["textFields"][number];
styles: Record<string, string>;
fontAssets: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
autoFocus?: boolean;
}) {
const weight = getTextStyleValue(field, styles, "font-weight", "400");
const weightOptions = detectAvailableWeights(
field.computedStyles["font-family"] || styles["font-family"] || "",
);
const align = getTextStyleValue(field, styles, "text-align", "start");
const textTransform = getTextStyleValue(field, styles, "text-transform", "none");
const fontStyle = getTextStyleValue(field, styles, "font-style", "normal");
return (
<>
<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"}
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
liveCommit
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
/>
<div className="flex min-h-[30px] items-center justify-between">
<span
className={
VALUE_TIER_LABEL_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
}
style={{ fontSize: 11 }}
>
Weight
</span>
<label className="flex items-center gap-1.5">
<select
value={weight}
onChange={(e) => onSetTextFieldStyle(field.key, "font-weight", e.target.value)}
className={`appearance-none bg-transparent text-right font-mono text-[11px] outline-none ${
VALUE_TIER_VALUE_CLASS[resolveValueTier(field.inlineStyles["font-weight"], "400")]
}`}
>
{(weightOptions.includes(weight) ? weightOptions : [weight, ...weightOptions]).map(
(option) => (
<option key={option} value={option}>
{WEIGHT_LABELS[option] ?? option}
</option>
),
)}
</select>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M2 3l3 4 3-4z" />
</svg>
</label>
</div>
<FlatRow
label="Letter spacing"
value={getTextStyleValue(field, styles, "letter-spacing", "0px")}
tier={resolveValueTier(field.inlineStyles["letter-spacing"], "0px")}
onCommit={(next) =>
onSetTextFieldStyle(
field.key,
"letter-spacing",
normalizeTextMetricValue("letter-spacing", next),
)
}
onReset={() => onSetTextFieldStyle(field.key, "letter-spacing", "")}
/>
<FlatRow
label="Line height"
value={getTextStyleValue(field, styles, "line-height", "normal")}
tier={resolveValueTier(field.inlineStyles["line-height"], "normal")}
onCommit={(next) =>
onSetTextFieldStyle(
field.key,
"line-height",
normalizeTextMetricValue("line-height", next),
)
}
onReset={() => onSetTextFieldStyle(field.key, "line-height", "")}
/>
<FlatSegmentedRow
label="Align"
options={ALIGN_OPTIONS.map((option) => ({
key: option.key,
node: option.node,
label: option.label,
active:
align === option.key ||
(option.key === "left" && align === "start") ||
(option.key === "right" && align === "end"),
}))}
onChange={(next) => {
// Re-clicking the option that's already visually active for a
// logical value (authored "start"/"end") must not rewrite it to
// the physical "left"/"right" — that destroys the logical
// semantics and is wrong for RTL content. Only write when the
// user actually picked a different alignment.
if ((next === "left" && align === "start") || (next === "right" && align === "end")) {
return;
}
onSetTextFieldStyle(field.key, "text-align", next);
}}
/>
<FlatSegmentedRow
label="Case · Style"
options={[
...CASE_OPTIONS.map((option) => ({
key: option.key,
node: option.node,
label: option.label,
active: textTransform === option.key,
})),
{ key: "normal", node: "A", label: "upright", active: fontStyle === "normal" },
{ key: "italic", node: "A", label: "italic", active: fontStyle === "italic" },
]}
spacerAfterIndex={2}
onChange={(next) => {
if (next === "normal" || next === "italic") {
onSetTextFieldStyle(field.key, "font-style", next);
} else {
onSetTextFieldStyle(field.key, "text-transform", 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>
</>
);
}
export function FlatTextSection({
element,
styles,
fontAssets,
onImportFonts,
onSetText,
onSetTextFieldStyle,
onAddTextField,
onRemoveTextField,
}: {
element: DomEditSelection;
styles: Record<string, string>;
fontAssets: ImportedFontAsset[];
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onAddTextField: (afterFieldKey?: string) => string | Promise<string | null> | null;
onRemoveTextField: (fieldKey: string) => void;
}) {
const [activeFieldKey, setActiveFieldKey] = useState<string | null>(
element.textFields[0]?.key ?? null,
);
useEffect(() => {
const nextFields = element.textFields;
setActiveFieldKey((current) => {
if (current && nextFields.some((field) => field.key === current)) return current;
return nextFields[0]?.key ?? null;
});
}, [element.id, element.selector, element.textFields]);
if (!isTextEditableSelection(element)) return null;
const textFields = element.textFields;
const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0];
if (!activeField) return null;
if (textFields.length > 1) {
return (
<div className="space-y-1.5">
<FlatTextLayerList
fields={textFields}
activeFieldKey={activeField.key}
styles={styles}
onSelect={setActiveFieldKey}
onAdd={() =>
void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => {
if (nextKey) setActiveFieldKey(nextKey);
})
}
onRemove={onRemoveTextField}
/>
<FlatTextFieldEditor
key={activeField.key}
field={activeField}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
autoFocus
/>
</div>
);
}
return (
<div className="space-y-1.5">
<FlatTextFieldEditor
field={activeField}
styles={styles}
fontAssets={fontAssets}
onImportFonts={onImportFonts}
onSetText={onSetText}
onSetTextFieldStyle={onSetTextFieldStyle}
/>
<button
type="button"
onClick={() => void onAddTextField(activeField.key)}
className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
>
<Plus size={10} />
Add text field
</button>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Multi-field layer list (design_handoff_studio_inspector, #10a — */
/* no mock exists for this row; layout originated by this plan, */
/* following the "left-rule nested content" convention established */
/* by Text's own content block, Motion's effect cards, and Media's */
/* cutout block. Flag for design review.) */
/* ------------------------------------------------------------------ */
export function FlatTextLayerList({
fields,
activeFieldKey,
styles,
onSelect,
onAdd,
onRemove,
}: {
fields: DomEditSelection["textFields"];
activeFieldKey: string;
styles: Record<string, string>;
onSelect: (fieldKey: string) => void;
onAdd: () => void;
onRemove: (fieldKey: string) => void;
}) {
return (
<div className="mb-2 border-l-2 border-panel-border-input py-0.5 pl-[10px]">
<div className="mb-1.5 text-[9px] font-semibold uppercase tracking-[0.12em] text-panel-text-5">
Text layers
</div>
<div className="space-y-1">
{fields.map((field, index) => {
const active = field.key === activeFieldKey;
return (
<div
key={field.key}
data-flat-text-layer-row="true"
data-active={active}
onClick={() => onSelect(field.key)}
className={`flex min-h-[26px] cursor-pointer items-center gap-2 rounded px-1 ${
active ? "bg-panel-accent/10" : "hover:bg-panel-hover"
}`}
>
<span
className="h-3 w-3 flex-shrink-0 rounded-sm"
style={{ backgroundColor: getTextFieldColor(field, styles) }}
/>
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
</span>
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
{field.tagName}
</span>
{fields.length > 1 && (
<button
type="button"
data-flat-text-layer-remove="true"
aria-label="Remove text field"
onClick={(e) => {
e.stopPropagation();
onRemove(field.key);
}}
className="flex-shrink-0 text-panel-text-4 hover:text-panel-text-1"
>
<X size={10} />
</button>
)}
</div>
);
})}
</div>
<button
type="button"
data-flat-text-layer-add="true"
onClick={onAdd}
className="mt-1 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2"
>
<Plus size={10} />
Add text field
</button>
</div>
);
}