mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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).
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|