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).
466 lines
16 KiB
TypeScript
466 lines
16 KiB
TypeScript
// @vitest-environment happy-dom
|
|
|
|
import React, { act, useState } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { FlatTextLayerList, FlatTextSection } from "./propertyPanelFlatTextSection";
|
|
import type { DomEditSelection, DomEditTextField } from "./domEditingTypes";
|
|
|
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
function renderInto(node: React.ReactElement) {
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(node);
|
|
});
|
|
return { host, root };
|
|
}
|
|
|
|
const FIELDS = [
|
|
{
|
|
key: "a",
|
|
label: "Text",
|
|
value: "Headline",
|
|
tagName: "div",
|
|
attributes: [],
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
source: "self" as const,
|
|
},
|
|
{
|
|
key: "b",
|
|
label: "Text",
|
|
value: "Subhead",
|
|
tagName: "span",
|
|
attributes: [],
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
source: "self" as const,
|
|
},
|
|
];
|
|
|
|
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", () => {
|
|
const onSelect = vi.fn();
|
|
const onAdd = vi.fn();
|
|
const onRemove = vi.fn();
|
|
const { host, root } = renderInto(
|
|
<FlatTextLayerList
|
|
fields={FIELDS as never}
|
|
activeFieldKey="a"
|
|
styles={{}}
|
|
onSelect={onSelect}
|
|
onAdd={onAdd}
|
|
onRemove={onRemove}
|
|
/>,
|
|
);
|
|
expect(host.textContent).toContain("Headline");
|
|
expect(host.textContent).toContain("Subhead");
|
|
|
|
const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
|
|
expect(rows).toHaveLength(2);
|
|
expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true");
|
|
expect((rows[1] as HTMLElement).getAttribute("data-active")).toBe("false");
|
|
|
|
act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(onSelect).toHaveBeenCalledWith("b");
|
|
|
|
const addButton = host.querySelector<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
|
|
act(() => addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(onAdd).toHaveBeenCalledTimes(1);
|
|
|
|
const removeButton = host.querySelector<HTMLButtonElement>(
|
|
'[data-flat-text-layer-remove="true"]',
|
|
);
|
|
act(() => removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(onRemove).toHaveBeenCalledWith("a");
|
|
// stopPropagation on the remove button must prevent the row's own onClick
|
|
// from also firing onSelect for the removed field's key.
|
|
expect(onSelect).toHaveBeenCalledTimes(1);
|
|
expect(onSelect).not.toHaveBeenCalledWith("a");
|
|
act(() => root.unmount());
|
|
});
|
|
});
|
|
|
|
function makeMultiFieldElement(): DomEditSelection {
|
|
return {
|
|
element: document.createElement("div"),
|
|
id: "multi",
|
|
selector: ".multi",
|
|
label: "Multi",
|
|
tagName: "div",
|
|
sourceFile: "index.html",
|
|
compositionPath: "index.html",
|
|
isCompositionHost: false,
|
|
isInsideLockedComposition: false,
|
|
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
|
|
textContent: "Headline Subhead",
|
|
dataAttributes: {},
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
textFields: [
|
|
{
|
|
key: "a",
|
|
label: "Text",
|
|
value: "Headline",
|
|
tagName: "div",
|
|
attributes: [],
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
source: "self",
|
|
},
|
|
{
|
|
key: "b",
|
|
label: "Text",
|
|
value: "Subhead",
|
|
tagName: "span",
|
|
attributes: [],
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
source: "self",
|
|
},
|
|
],
|
|
capabilities: {
|
|
canSelect: true,
|
|
canEditStyles: true,
|
|
canCrop: true,
|
|
canMove: true,
|
|
canResize: true,
|
|
canApplyManualOffset: true,
|
|
canApplyManualSize: true,
|
|
canApplyManualRotation: true,
|
|
},
|
|
} 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 but re-clicking it is a no-op — preserves the logical value", () => {
|
|
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");
|
|
// Clicking the option that's already visually active for "end" must NOT
|
|
// rewrite it to the physical "right" — that would destroy the logical
|
|
// semantics and break RTL content, where "end" and "right" differ.
|
|
act(() => rightButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(onSetTextFieldStyle).not.toHaveBeenCalled();
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("commits a genuine align change away from a logical value", () => {
|
|
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 centerButton = alignButtons.find((button) => button.textContent === "C");
|
|
expect(centerButton).not.toBeUndefined();
|
|
act(() => centerButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(onSetTextFieldStyle).toHaveBeenCalledWith("a", "text-align", "center");
|
|
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", () => {
|
|
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");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(
|
|
<FlatTextSection
|
|
element={makeMultiFieldElement()}
|
|
styles={{}}
|
|
fontAssets={[]}
|
|
onSetText={vi.fn()}
|
|
onSetTextFieldStyle={vi.fn()}
|
|
onAddTextField={vi.fn()}
|
|
onRemoveTextField={vi.fn()}
|
|
/>,
|
|
);
|
|
});
|
|
expect(host.textContent).toContain("Headline");
|
|
expect(host.textContent).toContain("Subhead");
|
|
// Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor).
|
|
expect(host.textContent).toContain("Weight");
|
|
// Exactly one "Text layers" micro-label — this component doesn't duplicate its own list.
|
|
const layerLabels = Array.from(host.querySelectorAll("div")).filter(
|
|
(el) => el.textContent === "Text layers",
|
|
);
|
|
expect(layerLabels.length).toBeLessThanOrEqual(1);
|
|
|
|
const rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
|
|
act(() => rows[1].dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
|
expect(host.textContent).toContain("Subhead");
|
|
act(() => root.unmount());
|
|
});
|
|
|
|
it("wires onAdd/onRemove end-to-end: async onAddTextField switches the active field once it appears in props, and the resync effect falls back to the first field when the active one disappears", 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: "Third",
|
|
tagName: "div",
|
|
attributes: [],
|
|
inlineStyles: {},
|
|
computedStyles: {},
|
|
source: "self",
|
|
},
|
|
]);
|
|
return "c";
|
|
})
|
|
}
|
|
onRemoveTextField={(fieldKey: string) =>
|
|
setFields((prev) => prev.filter((field) => field.key !== fieldKey))
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const host = document.createElement("div");
|
|
document.body.append(host);
|
|
const root = createRoot(host);
|
|
act(() => {
|
|
root.render(<Harness />);
|
|
});
|
|
|
|
let rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
|
|
expect(rows).toHaveLength(2);
|
|
expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true");
|
|
|
|
const addButton = host.querySelector<HTMLButtonElement>('[data-flat-text-layer-add="true"]');
|
|
await act(async () => {
|
|
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
expect(addResolved).toBe(true);
|
|
rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
|
|
expect(rows).toHaveLength(3);
|
|
expect((rows[2] as HTMLElement).getAttribute("data-active")).toBe("true");
|
|
|
|
// Remove the active field ("c") through the wired onRemoveTextField — the
|
|
// resync useEffect must fall back to the first remaining field ("a")
|
|
// since "c" no longer exists in element.textFields.
|
|
const removeButtons = host.querySelectorAll<HTMLButtonElement>(
|
|
'[data-flat-text-layer-remove="true"]',
|
|
);
|
|
act(() => {
|
|
removeButtons[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
rows = host.querySelectorAll('[data-flat-text-layer-row="true"]');
|
|
expect(rows).toHaveLength(2);
|
|
expect((rows[0] as HTMLElement).getAttribute("data-active")).toBe("true");
|
|
|
|
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());
|
|
});
|
|
});
|