mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): add FlatTextSection, the flat inspector's canonical reference group
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import { Plus } 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 { FlatRow, FlatSegmentedRow } from "./propertyPanelFlatPrimitives";
|
||||
import {
|
||||
resolveValueTier,
|
||||
VALUE_TIER_LABEL_CLASS,
|
||||
VALUE_TIER_VALUE_CLASS,
|
||||
} from "./propertyPanelValueTier";
|
||||
import {
|
||||
detectAvailableWeights,
|
||||
getTextFieldColor,
|
||||
getTextStyleValue,
|
||||
TextAreaField,
|
||||
TextSection,
|
||||
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", node: "–" },
|
||||
{ key: "uppercase", node: "AG" },
|
||||
{ key: "lowercase", node: "ag" },
|
||||
];
|
||||
|
||||
function FlatTextFieldEditor({
|
||||
field,
|
||||
styles,
|
||||
fontAssets,
|
||||
onImportFonts,
|
||||
onSetText,
|
||||
onSetTextFieldStyle,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
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 (
|
||||
<>
|
||||
<TextAreaField
|
||||
flat
|
||||
label="Content"
|
||||
value={field.value}
|
||||
onCommit={(next) => onSetText(next, field.key)}
|
||||
/>
|
||||
<FontFamilyField
|
||||
flat
|
||||
value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
|
||||
importedFonts={fontAssets}
|
||||
onImportFonts={onImportFonts}
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
|
||||
/>
|
||||
<FlatRow
|
||||
label="Size"
|
||||
value={field.computedStyles["font-size"] || styles["font-size"] || "16px"}
|
||||
tier={resolveValueTier(field.inlineStyles["font-size"], styles["font-size"] || "16px")}
|
||||
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,
|
||||
active: align === option.key || (option.key === "left" && align === "start"),
|
||||
}))}
|
||||
onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)}
|
||||
/>
|
||||
<FlatSegmentedRow
|
||||
label="Case · Style"
|
||||
options={[
|
||||
...CASE_OPTIONS.map((option) => ({
|
||||
key: option.key,
|
||||
node: option.node,
|
||||
active: textTransform === option.key,
|
||||
})),
|
||||
{ key: "normal", node: "A", active: fontStyle === "normal" },
|
||||
{ key: "italic", node: "A", 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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ColorField
|
||||
flat
|
||||
label="Color"
|
||||
value={getTextFieldColor(field, styles)}
|
||||
onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
if (!isTextEditableSelection(element)) return null;
|
||||
const textFields = element.textFields;
|
||||
const activeField = textFields[0];
|
||||
if (!activeField) return null;
|
||||
|
||||
if (textFields.length > 1) {
|
||||
return (
|
||||
<TextSection
|
||||
element={element}
|
||||
styles={styles}
|
||||
fontAssets={fontAssets}
|
||||
onImportFonts={onImportFonts}
|
||||
onSetText={onSetText}
|
||||
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||
onAddTextField={onAddTextField}
|
||||
onRemoveTextField={onRemoveTextField}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FlatTextSection } from "./propertyPanelFlatTextSection";
|
||||
import type { DomEditSelection } from "./domEditingTypes";
|
||||
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function makeElement(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
|
||||
return {
|
||||
element: document.createElement("div"),
|
||||
id: "mono-label",
|
||||
selector: ".mono-label",
|
||||
label: "Mono Label",
|
||||
tagName: "div",
|
||||
sourceFile: "index.html",
|
||||
compositionPath: "index.html",
|
||||
isCompositionHost: false,
|
||||
isInsideLockedComposition: false,
|
||||
boundingBox: { x: 0, y: -24, width: 257, height: 29 },
|
||||
textContent: "PACKETS / FRAME",
|
||||
dataAttributes: {},
|
||||
inlineStyles: {},
|
||||
computedStyles: {},
|
||||
textFields: [
|
||||
{
|
||||
key: "field-0",
|
||||
label: "Text",
|
||||
value: "PACKETS / FRAME",
|
||||
tagName: "div",
|
||||
attributes: [],
|
||||
inlineStyles: { "letter-spacing": "3.96px" },
|
||||
computedStyles: {
|
||||
"font-family": "JetBrains Mono",
|
||||
"font-size": "22px",
|
||||
"font-weight": "400",
|
||||
"letter-spacing": "3.96px",
|
||||
"line-height": "normal",
|
||||
"text-align": "right",
|
||||
"text-transform": "none",
|
||||
"font-style": "normal",
|
||||
color: "rgb(255, 176, 32)",
|
||||
},
|
||||
source: "self",
|
||||
},
|
||||
],
|
||||
capabilities: {
|
||||
canSelect: true,
|
||||
canEditStyles: true,
|
||||
canCrop: true,
|
||||
canMove: true,
|
||||
canResize: true,
|
||||
canApplyManualOffset: true,
|
||||
canApplyManualSize: true,
|
||||
canApplyManualRotation: true,
|
||||
},
|
||||
...overrides,
|
||||
} as DomEditSelection;
|
||||
}
|
||||
|
||||
function renderSection(overrides: Partial<DomEditSelection> = {}) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const element = makeElement(overrides);
|
||||
act(() => {
|
||||
root.render(
|
||||
<FlatTextSection
|
||||
element={element}
|
||||
styles={{}}
|
||||
fontAssets={[]}
|
||||
onSetText={vi.fn()}
|
||||
onSetTextFieldStyle={vi.fn()}
|
||||
onAddTextField={vi.fn()}
|
||||
onRemoveTextField={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return { host, root };
|
||||
}
|
||||
|
||||
describe("FlatTextSection", () => {
|
||||
it("renders the content block and every row from #10a", () => {
|
||||
const { host, root } = renderSection();
|
||||
expect(host.textContent).toContain("PACKETS / FRAME");
|
||||
expect(host.textContent).toContain("Font");
|
||||
expect(host.textContent).toContain("Weight");
|
||||
expect(host.textContent).toContain("Letter spacing");
|
||||
expect(host.textContent).toContain("Line height");
|
||||
expect(host.textContent).toContain("Align");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("colors letter-spacing mint (explicit, differs from 0px default) with a reset button", () => {
|
||||
const { host, root } = renderSection();
|
||||
const resetButtons = host.querySelectorAll('[data-flat-row-reset="true"]');
|
||||
expect(resetButtons.length).toBeGreaterThan(0);
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("commits a font-weight change through onSetTextFieldStyle", () => {
|
||||
const onSetTextFieldStyle = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
const element = makeElement();
|
||||
act(() => {
|
||||
root.render(
|
||||
<FlatTextSection
|
||||
element={element}
|
||||
styles={{}}
|
||||
fontAssets={[]}
|
||||
onSetText={vi.fn()}
|
||||
onSetTextFieldStyle={onSetTextFieldStyle}
|
||||
onAddTextField={vi.fn()}
|
||||
onRemoveTextField={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const select = host.querySelector<HTMLSelectElement>("select");
|
||||
if (!select) throw new Error("expected a weight <select>");
|
||||
act(() => {
|
||||
select.value = "700";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
expect(onSetTextFieldStyle).toHaveBeenCalledWith("field-0", "font-weight", "700");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -17,14 +17,14 @@ function formatTextFieldPreview(value: string): string {
|
||||
return `${collapsed.slice(0, 55)}…`;
|
||||
}
|
||||
|
||||
function getTextFieldColor(
|
||||
export function getTextFieldColor(
|
||||
field: { computedStyles: Record<string, string> },
|
||||
inheritedStyles: Record<string, string>,
|
||||
): string {
|
||||
return field.computedStyles.color || inheritedStyles.color || "rgb(0, 0, 0)";
|
||||
}
|
||||
|
||||
function getTextStyleValue(
|
||||
export function getTextStyleValue(
|
||||
field: { computedStyles: Record<string, string> },
|
||||
inheritedStyles: Record<string, string>,
|
||||
property: string,
|
||||
@@ -34,7 +34,7 @@ function getTextStyleValue(
|
||||
}
|
||||
|
||||
const ALL_WEIGHTS = ["100", "200", "300", "400", "500", "600", "700", "800", "900"];
|
||||
const WEIGHT_LABELS: Record<string, string> = {
|
||||
export const WEIGHT_LABELS: Record<string, string> = {
|
||||
"100": "100 · Thin",
|
||||
"200": "200 · Extra Light",
|
||||
"300": "300 · Light",
|
||||
@@ -46,7 +46,7 @@ const WEIGHT_LABELS: Record<string, string> = {
|
||||
"900": "900 · Black",
|
||||
};
|
||||
|
||||
function detectAvailableWeights(fontFamily: string): string[] {
|
||||
export function detectAvailableWeights(fontFamily: string): string[] {
|
||||
const fonts = document.fonts;
|
||||
if (!fonts) return ALL_WEIGHTS;
|
||||
const family = fontFamily.split(",")[0]?.trim().replace(/['"]/g, "");
|
||||
|
||||
Reference in New Issue
Block a user