feat(studio): add FlatTextSection, the flat inspector's canonical reference group

This commit is contained in:
Vance Ingalls
2026-07-09 11:52:12 -07:00
parent eba8a0fa29
commit 6f2e9848c4
3 changed files with 382 additions and 4 deletions
@@ -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, "");