mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): restore capitalize, end-align, live-commit size, and autofocus in flat Text
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8a25f21cd6
commit
f6fa9b4ec3
@@ -46,6 +46,26 @@ const FIELDS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe("FlatTextLayerList", () => {
|
describe("FlatTextLayerList", () => {
|
||||||
|
it("falls back to a numbered label per index for empty fields, not a bare 'Text'", () => {
|
||||||
|
const emptyFields = [
|
||||||
|
{ ...FIELDS[0], value: "" },
|
||||||
|
{ ...FIELDS[1], value: "" },
|
||||||
|
];
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatTextLayerList
|
||||||
|
fields={emptyFields as never}
|
||||||
|
activeFieldKey="a"
|
||||||
|
styles={{}}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onAdd={vi.fn()}
|
||||||
|
onRemove={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(host.textContent).toContain("Text 1");
|
||||||
|
expect(host.textContent).toContain("Text 2");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => {
|
it("lists every field, highlights the active one, and fires onSelect/onAdd/onRemove", () => {
|
||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
const onAdd = vi.fn();
|
const onAdd = vi.fn();
|
||||||
@@ -139,6 +159,114 @@ function makeMultiFieldElement(): DomEditSelection {
|
|||||||
} as DomEditSelection;
|
} as DomEditSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeSingleFieldElement(overrides: Partial<DomEditTextField> = {}): DomEditSelection {
|
||||||
|
const base = makeMultiFieldElement();
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
textFields: [
|
||||||
|
{
|
||||||
|
key: "a",
|
||||||
|
label: "Text",
|
||||||
|
value: "Headline",
|
||||||
|
tagName: "div",
|
||||||
|
attributes: [],
|
||||||
|
inlineStyles: {},
|
||||||
|
computedStyles: {},
|
||||||
|
source: "self",
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as DomEditSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentedRowButtons(host: HTMLElement, label: string): HTMLButtonElement[] {
|
||||||
|
const labelSpan = Array.from(host.querySelectorAll("span")).find(
|
||||||
|
(el) => el.textContent === label,
|
||||||
|
);
|
||||||
|
const row = labelSpan?.parentElement;
|
||||||
|
return Array.from(row?.querySelectorAll<HTMLButtonElement>('[data-flat-segment="true"]') ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FlatTextFieldEditor controls", () => {
|
||||||
|
it("commits text-transform: capitalize when the new 'Ag' case button is clicked", () => {
|
||||||
|
const onSetTextFieldStyle = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatTextSection
|
||||||
|
element={makeSingleFieldElement()}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||||
|
onAddTextField={vi.fn()}
|
||||||
|
onRemoveTextField={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const capitalizeButton = segmentedRowButtons(host, "Case · Style").find(
|
||||||
|
(button) => button.textContent === "Ag",
|
||||||
|
);
|
||||||
|
expect(capitalizeButton).not.toBeUndefined();
|
||||||
|
act(() => capitalizeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-transform", "capitalize");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lights up 'right' for text-align: end and commits the concrete 'right' value on click", () => {
|
||||||
|
const onSetTextFieldStyle = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatTextSection
|
||||||
|
element={makeSingleFieldElement({ computedStyles: { "text-align": "end" } })}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||||
|
onAddTextField={vi.fn()}
|
||||||
|
onRemoveTextField={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const alignButtons = segmentedRowButtons(host, "Align");
|
||||||
|
const rightButton = alignButtons.find((button) => button.textContent === "R");
|
||||||
|
expect(rightButton).not.toBeUndefined();
|
||||||
|
expect(rightButton?.className).toContain("border-panel-accent");
|
||||||
|
act(() => rightButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||||
|
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-align", "right");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("live-commits the Size field on input, without requiring blur/Enter", async () => {
|
||||||
|
const onSetTextFieldStyle = vi.fn();
|
||||||
|
const { host, root } = renderInto(
|
||||||
|
<FlatTextSection
|
||||||
|
element={makeSingleFieldElement()}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||||
|
onAddTextField={vi.fn()}
|
||||||
|
onRemoveTextField={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const sizeLabel = Array.from(host.querySelectorAll("span")).find(
|
||||||
|
(el) => el.textContent === "Size",
|
||||||
|
);
|
||||||
|
const input = sizeLabel?.parentElement?.querySelector<HTMLInputElement>("input");
|
||||||
|
if (!input) throw new Error("expected the Size row's input");
|
||||||
|
act(() => {
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
nativeInputValueSetter?.call(input, "24px");
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
});
|
||||||
|
// liveCommit debounces on a 120ms timer — no blur/Enter dispatched here.
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 160));
|
||||||
|
});
|
||||||
|
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "font-size", "24px");
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("FlatTextSection — multi-field", () => {
|
describe("FlatTextSection — multi-field", () => {
|
||||||
it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => {
|
it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => {
|
||||||
const host = document.createElement("div");
|
const host = document.createElement("div");
|
||||||
@@ -249,4 +377,65 @@ describe("FlatTextSection — multi-field", () => {
|
|||||||
|
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-focuses the Content textarea when a new text field is added", async () => {
|
||||||
|
let addResolved = false;
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const [fields, setFields] = useState<DomEditTextField[]>(makeMultiFieldElement().textFields);
|
||||||
|
const element: DomEditSelection = { ...makeMultiFieldElement(), textFields: fields };
|
||||||
|
return (
|
||||||
|
<FlatTextSection
|
||||||
|
element={element}
|
||||||
|
styles={{}}
|
||||||
|
fontAssets={[]}
|
||||||
|
onSetText={vi.fn()}
|
||||||
|
onSetTextFieldStyle={vi.fn()}
|
||||||
|
onAddTextField={() =>
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
addResolved = true;
|
||||||
|
setFields((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
key: "c",
|
||||||
|
label: "Text",
|
||||||
|
value: "",
|
||||||
|
tagName: "div",
|
||||||
|
attributes: [],
|
||||||
|
inlineStyles: {},
|
||||||
|
computedStyles: {},
|
||||||
|
source: "self",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return "c";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onRemoveTextField={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = document.createElement("div");
|
||||||
|
document.body.append(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
act(() => {
|
||||||
|
root.render(<Harness />);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addButton = host.querySelector<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
|
||||||
|
// Wait for onAddTextField's promise to resolve (adds field "c" and makes it
|
||||||
|
// active) before checking focus, mirroring the async add-field pattern above.
|
||||||
|
await act(async () => {
|
||||||
|
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(addResolved).toBe(true);
|
||||||
|
|
||||||
|
const contentTextarea = host.querySelector("textarea");
|
||||||
|
expect(contentTextarea).not.toBeNull();
|
||||||
|
expect(document.activeElement).toBe(contentTextarea);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const CASE_OPTIONS = [
|
|||||||
{ key: "none", node: "–" },
|
{ key: "none", node: "–" },
|
||||||
{ key: "uppercase", node: "AG" },
|
{ key: "uppercase", node: "AG" },
|
||||||
{ key: "lowercase", node: "ag" },
|
{ key: "lowercase", node: "ag" },
|
||||||
|
{ key: "capitalize", node: "Ag" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function FlatTextFieldEditor({
|
function FlatTextFieldEditor({
|
||||||
@@ -44,6 +45,7 @@ function FlatTextFieldEditor({
|
|||||||
onImportFonts,
|
onImportFonts,
|
||||||
onSetText,
|
onSetText,
|
||||||
onSetTextFieldStyle,
|
onSetTextFieldStyle,
|
||||||
|
autoFocus = false,
|
||||||
}: {
|
}: {
|
||||||
field: DomEditSelection["textFields"][number];
|
field: DomEditSelection["textFields"][number];
|
||||||
styles: Record<string, string>;
|
styles: Record<string, string>;
|
||||||
@@ -51,6 +53,7 @@ function FlatTextFieldEditor({
|
|||||||
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
|
||||||
onSetText: (value: string, fieldKey?: string) => void;
|
onSetText: (value: string, fieldKey?: string) => void;
|
||||||
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
|
||||||
|
autoFocus?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const weight = getTextStyleValue(field, styles, "font-weight", "400");
|
const weight = getTextStyleValue(field, styles, "font-weight", "400");
|
||||||
const weightOptions = detectAvailableWeights(
|
const weightOptions = detectAvailableWeights(
|
||||||
@@ -66,6 +69,7 @@ function FlatTextFieldEditor({
|
|||||||
flat
|
flat
|
||||||
label="Content"
|
label="Content"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
|
autoFocus={autoFocus}
|
||||||
onCommit={(next) => onSetText(next, field.key)}
|
onCommit={(next) => onSetText(next, field.key)}
|
||||||
/>
|
/>
|
||||||
<FontFamilyField
|
<FontFamilyField
|
||||||
@@ -79,6 +83,7 @@ function FlatTextFieldEditor({
|
|||||||
label="Size"
|
label="Size"
|
||||||
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
||||||
tier={resolveValueTier(field.inlineStyles["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)}
|
onCommit={(next) => onSetTextFieldStyle(field.key, "font-size", next)}
|
||||||
/>
|
/>
|
||||||
<div className="flex min-h-[30px] items-center justify-between">
|
<div className="flex min-h-[30px] items-center justify-between">
|
||||||
@@ -148,7 +153,10 @@ function FlatTextFieldEditor({
|
|||||||
options={ALIGN_OPTIONS.map((option) => ({
|
options={ALIGN_OPTIONS.map((option) => ({
|
||||||
key: option.key,
|
key: option.key,
|
||||||
node: option.node,
|
node: option.node,
|
||||||
active: align === option.key || (option.key === "left" && align === "start"),
|
active:
|
||||||
|
align === option.key ||
|
||||||
|
(option.key === "left" && align === "start") ||
|
||||||
|
(option.key === "right" && align === "end"),
|
||||||
}))}
|
}))}
|
||||||
onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)}
|
onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)}
|
||||||
/>
|
/>
|
||||||
@@ -234,12 +242,14 @@ export function FlatTextSection({
|
|||||||
onRemove={onRemoveTextField}
|
onRemove={onRemoveTextField}
|
||||||
/>
|
/>
|
||||||
<FlatTextFieldEditor
|
<FlatTextFieldEditor
|
||||||
|
key={activeField.key}
|
||||||
field={activeField}
|
field={activeField}
|
||||||
styles={styles}
|
styles={styles}
|
||||||
fontAssets={fontAssets}
|
fontAssets={fontAssets}
|
||||||
onImportFonts={onImportFonts}
|
onImportFonts={onImportFonts}
|
||||||
onSetText={onSetText}
|
onSetText={onSetText}
|
||||||
onSetTextFieldStyle={onSetTextFieldStyle}
|
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -296,7 +306,7 @@ export function FlatTextLayerList({
|
|||||||
Text layers
|
Text layers
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{fields.map((field) => {
|
{fields.map((field, index) => {
|
||||||
const active = field.key === activeFieldKey;
|
const active = field.key === activeFieldKey;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -313,7 +323,7 @@ export function FlatTextLayerList({
|
|||||||
style={{ backgroundColor: getTextFieldColor(field, styles) }}
|
style={{ backgroundColor: getTextFieldColor(field, styles) }}
|
||||||
/>
|
/>
|
||||||
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
|
<span className="min-w-0 flex-1 truncate text-[11px] text-panel-text-1">
|
||||||
{formatTextFieldPreview(field.value) || "Text"}
|
{formatTextFieldPreview(field.value) || `Text ${index + 1}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
|
<span className="flex-shrink-0 font-mono text-[9px] text-panel-text-4">
|
||||||
{field.tagName}
|
{field.tagName}
|
||||||
|
|||||||
Reference in New Issue
Block a user