diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx new file mode 100644 index 000000000..dde867a46 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -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; + fontAssets: ImportedFontAsset[]; + onImportFonts?: (files: FileList | File[]) => Promise; + 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 ( + <> + onSetText(next, field.key)} + /> + onSetTextFieldStyle(field.key, "font-family", next)} + /> + onSetTextFieldStyle(field.key, "font-size", next)} + /> +
+ + Weight + + +
+ + onSetTextFieldStyle( + field.key, + "letter-spacing", + normalizeTextMetricValue("letter-spacing", next), + ) + } + onReset={() => onSetTextFieldStyle(field.key, "letter-spacing", "")} + /> + + onSetTextFieldStyle( + field.key, + "line-height", + normalizeTextMetricValue("line-height", next), + ) + } + onReset={() => onSetTextFieldStyle(field.key, "line-height", "")} + /> + ({ + key: option.key, + node: option.node, + active: align === option.key || (option.key === "left" && align === "start"), + }))} + onChange={(next) => onSetTextFieldStyle(field.key, "text-align", next)} + /> + ({ + 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); + } + }} + /> + onSetTextFieldStyle(field.key, "color", next)} + /> + + ); +} + +export function FlatTextSection({ + element, + styles, + fontAssets, + onImportFonts, + onSetText, + onSetTextFieldStyle, + onAddTextField, + onRemoveTextField, +}: { + element: DomEditSelection; + styles: Record; + fontAssets: ImportedFontAsset[]; + onImportFonts?: (files: FileList | File[]) => Promise; + onSetText: (value: string, fieldKey?: string) => void; + onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void; + onAddTextField: (afterFieldKey?: string) => string | Promise | 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 ( + + ); + } + + return ( +
+ + +
+ ); +} diff --git a/packages/studio/src/components/editor/propertyPanelSections.test.tsx b/packages/studio/src/components/editor/propertyPanelSections.test.tsx new file mode 100644 index 000000000..52fc8dd07 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelSections.test.tsx @@ -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 { + 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 = {}) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const element = makeElement(overrides); + act(() => { + root.render( + , + ); + }); + 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( + , + ); + }); + const select = host.querySelector("select"); + if (!select) throw new Error("expected a weight