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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,14 +18,14 @@ function formatTextFieldPreview(value: string): string {
|
|||||||
return `${collapsed.slice(0, 55)}…`;
|
return `${collapsed.slice(0, 55)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextFieldColor(
|
export function getTextFieldColor(
|
||||||
field: { computedStyles: Record<string, string> },
|
field: { computedStyles: Record<string, string> },
|
||||||
inheritedStyles: Record<string, string>,
|
inheritedStyles: Record<string, string>,
|
||||||
): string {
|
): string {
|
||||||
return field.computedStyles.color || inheritedStyles.color || "rgb(0, 0, 0)";
|
return field.computedStyles.color || inheritedStyles.color || "rgb(0, 0, 0)";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTextStyleValue(
|
export function getTextStyleValue(
|
||||||
field: { computedStyles: Record<string, string> },
|
field: { computedStyles: Record<string, string> },
|
||||||
inheritedStyles: Record<string, string>,
|
inheritedStyles: Record<string, string>,
|
||||||
property: string,
|
property: string,
|
||||||
@@ -35,7 +35,7 @@ function getTextStyleValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ALL_WEIGHTS = ["100", "200", "300", "400", "500", "600", "700", "800", "900"];
|
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",
|
"100": "100 · Thin",
|
||||||
"200": "200 · Extra Light",
|
"200": "200 · Extra Light",
|
||||||
"300": "300 · Light",
|
"300": "300 · Light",
|
||||||
@@ -47,7 +47,7 @@ const WEIGHT_LABELS: Record<string, string> = {
|
|||||||
"900": "900 · Black",
|
"900": "900 · Black",
|
||||||
};
|
};
|
||||||
|
|
||||||
function detectAvailableWeights(fontFamily: string): string[] {
|
export function detectAvailableWeights(fontFamily: string): string[] {
|
||||||
const fonts = document.fonts;
|
const fonts = document.fonts;
|
||||||
if (!fonts) return ALL_WEIGHTS;
|
if (!fonts) return ALL_WEIGHTS;
|
||||||
const family = fontFamily.split(",")[0]?.trim().replace(/['"]/g, "");
|
const family = fontFamily.split(",")[0]?.trim().replace(/['"]/g, "");
|
||||||
|
|||||||
Reference in New Issue
Block a user