Compare commits

..
Author SHA1 Message Date
hanzo-dev d07fddde38 feat: Add comprehensive typography controls to page builder
Add detailed typography controls to the right sidebar property panel:
- Font family dropdown (system fonts + 8 Google Fonts)
- Font size selector (xs to 9xl, 13 options)
- Font weight (100-900, 9 weights)
- Line height (6 options from tight to loose)
- Letter spacing (6 options from tighter to widest)
- Text alignment (left, center, right, justify with icon toggles)
- Text transform (normal, uppercase, lowercase, capitalize)
- Text decoration (none, underline, strikethrough with toggles)
- Font color (color picker + Tailwind class input)

All controls use Tailwind CSS classes and integrate with existing
updateItemProps system for real-time preview.
2025-11-13 10:18:36 -08:00
+253 -301
View File
@@ -16,7 +16,7 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Box, ChevronLeft, ChevronRight, Copy, Download, Eye, GripVertical, Link2, Link2Off, Maximize2, Minimize2, Monitor, Palette, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Plus, Settings2, Smartphone, Tablet, Trash2 } from "lucide-react"
import { AlignCenter, AlignJustify, AlignLeft, AlignRight, ChevronLeft, ChevronRight, Copy, Download, Eye, GripVertical, Maximize2, Minimize2, Monitor, Palette, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Plus, Settings2, Smartphone, Tablet, Trash2, Type, Underline } from "lucide-react"
import { BuilderPreview } from "@/components/builder-preview"
import { OpenInHButton } from "@/components/open-in-h-button"
@@ -39,25 +39,17 @@ import {
SelectValue,
} from "@/registry/default/ui/select"
import { Separator } from "@/registry/default/ui/separator"
import { Slider } from "@/registry/default/ui/slider"
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/registry/default/ui/tabs"
interface BoxModelValue {
top?: string
right?: string
bottom?: string
left?: string
}
interface BoxModel {
margin?: BoxModelValue
padding?: BoxModelValue
border?: BoxModelValue
}
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/default/ui/toggle-group"
interface PageItem {
id: string
@@ -67,16 +59,8 @@ interface PageItem {
layoutType?: "flex" | "grid" | "stack"
children?: PageItem[]
props?: Record<string, any>
boxModel?: BoxModel
}
// Tailwind spacing scale
const SPACING_SCALE = [
"0", "px", "0.5", "1", "1.5", "2", "2.5", "3", "3.5", "4", "5", "6", "7", "8",
"9", "10", "11", "12", "14", "16", "20", "24", "28", "32", "36", "40", "44",
"48", "52", "56", "60", "64", "72", "80", "96"
]
export default function EnhancedBuilder() {
const [blocks, setBlocks] = React.useState<string[]>([])
const [components, setComponents] = React.useState<string[]>([])
@@ -92,11 +76,6 @@ export default function EnhancedBuilder() {
const [leftSidebarCollapsed, setLeftSidebarCollapsed] = React.useState(false)
const [rightSidebarCollapsed, setRightSidebarCollapsed] = React.useState(false)
// Box model link states
const [marginLinked, setMarginLinked] = React.useState(true)
const [paddingLinked, setPaddingLinked] = React.useState(true)
const [borderLinked, setBorderLinked] = React.useState(true)
React.useEffect(() => {
// Get blocks from registry
const blockIds = Object.keys(Index).filter((key) => {
@@ -411,37 +390,6 @@ ${renderItems(pageItems, 3)}
)
}
const updateBoxModel = (
id: string,
property: "margin" | "padding" | "border",
side: "top" | "right" | "bottom" | "left" | "all",
value: string
) => {
setPageItems((items) =>
items.map((item) => {
if (item.id !== id) return item
const currentBoxModel = item.boxModel || {}
const currentProperty = currentBoxModel[property] || {}
let newProperty: BoxModelValue
if (side === "all") {
newProperty = { top: value, right: value, bottom: value, left: value }
} else {
newProperty = { ...currentProperty, [side]: value }
}
return {
...item,
boxModel: {
...currentBoxModel,
[property]: newProperty
}
}
})
)
}
const addChildToContainer = (containerId: string, child: PageItem) => {
setPageItems((items) =>
items.map((item) =>
@@ -854,277 +802,281 @@ ${renderItems(pageItems, 3)}
</div>
</div>
{/* Box Model */}
{/* Typography */}
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Box className="h-4 w-4" />
Box Model
<Type className="h-4 w-4" />
Typography
</h3>
{/* Margin */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-3">
{/* Font Family */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Margin
Font Family
</label>
<Button
variant="ghost"
size="sm"
onClick={() => setMarginLinked(!marginLinked)}
className="h-6 w-6 p-0"
>
{marginLinked ? (
<Link2 className="h-3 w-3" />
) : (
<Link2Off className="h-3 w-3" />
)}
</Button>
</div>
{marginLinked ? (
<Select
value={selectedItemData.boxModel?.margin?.top || "0"}
value={selectedItemData.props?.fontFamily || "default"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "margin", "all", value)
updateItemProps(selectedItemData.id, {
fontFamily: value === "default" ? undefined : value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select spacing" />
<SelectValue placeholder="Select font" />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
<SelectItem value="default">Default</SelectItem>
<SelectItem value="font-sans">Sans Serif</SelectItem>
<SelectItem value="font-serif">Serif</SelectItem>
<SelectItem value="font-mono">Monospace</SelectItem>
<SelectItem value="font-inter">Inter</SelectItem>
<SelectItem value="font-roboto">Roboto</SelectItem>
<SelectItem value="font-open-sans">Open Sans</SelectItem>
<SelectItem value="font-lato">Lato</SelectItem>
<SelectItem value="font-montserrat">Montserrat</SelectItem>
<SelectItem value="font-poppins">Poppins</SelectItem>
<SelectItem value="font-playfair">Playfair Display</SelectItem>
<SelectItem value="font-merriweather">Merriweather</SelectItem>
</SelectContent>
</Select>
) : (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground">Top</label>
<Select
value={selectedItemData.boxModel?.margin?.top || "0"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "margin", "top", value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-muted-foreground">Right</label>
<Select
value={selectedItemData.boxModel?.margin?.right || "0"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "margin", "right", value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-muted-foreground">Bottom</label>
<Select
value={selectedItemData.boxModel?.margin?.bottom || "0"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "margin", "bottom", value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-muted-foreground">Left</label>
<Select
value={selectedItemData.boxModel?.margin?.left || "0"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "margin", "left", value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* Padding */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
Padding
</label>
<Button
variant="ghost"
size="sm"
onClick={() => setPaddingLinked(!paddingLinked)}
className="h-6 w-6 p-0"
>
{paddingLinked ? (
<Link2 className="h-3 w-3" />
) : (
<Link2Off className="h-3 w-3" />
)}
</Button>
</div>
{paddingLinked ? (
{/* Font Size */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Font Size
</label>
<Select
value={selectedItemData.boxModel?.padding?.top || "0"}
value={selectedItemData.props?.fontSize || "text-base"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "padding", "all", value)
updateItemProps(selectedItemData.id, {
fontSize: value === "text-base" ? undefined : value,
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select spacing" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
<SelectItem value="text-xs">Extra Small (xs)</SelectItem>
<SelectItem value="text-sm">Small (sm)</SelectItem>
<SelectItem value="text-base">Base</SelectItem>
<SelectItem value="text-lg">Large (lg)</SelectItem>
<SelectItem value="text-xl">Extra Large (xl)</SelectItem>
<SelectItem value="text-2xl">2XL</SelectItem>
<SelectItem value="text-3xl">3XL</SelectItem>
<SelectItem value="text-4xl">4XL</SelectItem>
<SelectItem value="text-5xl">5XL</SelectItem>
<SelectItem value="text-6xl">6XL</SelectItem>
<SelectItem value="text-7xl">7XL</SelectItem>
<SelectItem value="text-8xl">8XL</SelectItem>
<SelectItem value="text-9xl">9XL</SelectItem>
</SelectContent>
</Select>
) : (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-xs text-muted-foreground">Top</label>
<Select
value={selectedItemData.boxModel?.padding?.top || "0"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "padding", "top", value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-muted-foreground">Right</label>
<Select
value={selectedItemData.boxModel?.padding?.right || "0"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "padding", "right", value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-muted-foreground">Bottom</label>
<Select
value={selectedItemData.boxModel?.padding?.bottom || "0"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "padding", "bottom", value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs text-muted-foreground">Left</label>
<Select
value={selectedItemData.boxModel?.padding?.left || "0"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "padding", "left", value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
{/* Visual Box Model Diagram */}
<div className="rounded-lg border bg-muted/50 p-4">
<div className="space-y-1 text-center">
<div className="text-xs font-medium text-orange-500">
Margin: {selectedItemData.boxModel?.margin?.top || "0"}
</div>
<div className="rounded border-2 border-orange-200 bg-orange-50 p-3 dark:border-orange-900 dark:bg-orange-950/20">
<div className="text-xs font-medium text-blue-500">
Padding: {selectedItemData.boxModel?.padding?.top || "0"}
</div>
<div className="mt-1 rounded border-2 border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950/20">
<div className="text-xs font-medium text-foreground">
Content
</div>
</div>
{/* Font Weight */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Font Weight
</label>
<Select
value={selectedItemData.props?.fontWeight || "font-normal"}
onValueChange={(value) =>
updateItemProps(selectedItemData.id, {
fontWeight: value === "font-normal" ? undefined : value,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="font-thin">Thin (100)</SelectItem>
<SelectItem value="font-extralight">Extra Light (200)</SelectItem>
<SelectItem value="font-light">Light (300)</SelectItem>
<SelectItem value="font-normal">Normal (400)</SelectItem>
<SelectItem value="font-medium">Medium (500)</SelectItem>
<SelectItem value="font-semibold">Semibold (600)</SelectItem>
<SelectItem value="font-bold">Bold (700)</SelectItem>
<SelectItem value="font-extrabold">Extra Bold (800)</SelectItem>
<SelectItem value="font-black">Black (900)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Line Height */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Line Height
</label>
<Select
value={selectedItemData.props?.lineHeight || "leading-normal"}
onValueChange={(value) =>
updateItemProps(selectedItemData.id, {
lineHeight: value === "leading-normal" ? undefined : value,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="leading-none">None (1)</SelectItem>
<SelectItem value="leading-tight">Tight (1.25)</SelectItem>
<SelectItem value="leading-snug">Snug (1.375)</SelectItem>
<SelectItem value="leading-normal">Normal (1.5)</SelectItem>
<SelectItem value="leading-relaxed">Relaxed (1.625)</SelectItem>
<SelectItem value="leading-loose">Loose (2)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Letter Spacing */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Letter Spacing
</label>
<Select
value={selectedItemData.props?.letterSpacing || "tracking-normal"}
onValueChange={(value) =>
updateItemProps(selectedItemData.id, {
letterSpacing: value === "tracking-normal" ? undefined : value,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tracking-tighter">Tighter (-0.05em)</SelectItem>
<SelectItem value="tracking-tight">Tight (-0.025em)</SelectItem>
<SelectItem value="tracking-normal">Normal (0)</SelectItem>
<SelectItem value="tracking-wide">Wide (0.025em)</SelectItem>
<SelectItem value="tracking-wider">Wider (0.05em)</SelectItem>
<SelectItem value="tracking-widest">Widest (0.1em)</SelectItem>
</SelectContent>
</Select>
</div>
{/* Text Alignment */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Text Alignment
</label>
<ToggleGroup
type="single"
value={selectedItemData.props?.textAlign || "text-left"}
onValueChange={(value) => {
if (value) {
updateItemProps(selectedItemData.id, {
textAlign: value === "text-left" ? undefined : value,
})
}
}}
className="justify-start"
>
<ToggleGroupItem value="text-left" aria-label="Align left">
<AlignLeft className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="text-center" aria-label="Align center">
<AlignCenter className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="text-right" aria-label="Align right">
<AlignRight className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="text-justify" aria-label="Justify">
<AlignJustify className="h-4 w-4" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Text Transform */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Text Transform
</label>
<Select
value={selectedItemData.props?.textTransform || "normal-case"}
onValueChange={(value) =>
updateItemProps(selectedItemData.id, {
textTransform: value === "normal-case" ? undefined : value,
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal-case">Normal Case</SelectItem>
<SelectItem value="uppercase">UPPERCASE</SelectItem>
<SelectItem value="lowercase">lowercase</SelectItem>
<SelectItem value="capitalize">Capitalize</SelectItem>
</SelectContent>
</Select>
</div>
{/* Text Decoration */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Text Decoration
</label>
<ToggleGroup
type="single"
value={selectedItemData.props?.textDecoration || "no-underline"}
onValueChange={(value) => {
if (value) {
updateItemProps(selectedItemData.id, {
textDecoration: value === "no-underline" ? undefined : value,
})
}
}}
className="justify-start"
>
<ToggleGroupItem value="no-underline" aria-label="No decoration">
None
</ToggleGroupItem>
<ToggleGroupItem value="underline" aria-label="Underline">
<Underline className="h-4 w-4" />
</ToggleGroupItem>
<ToggleGroupItem value="line-through" aria-label="Line through">
<span className="text-sm">Strike</span>
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Font Color */}
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Font Color
</label>
<div className="flex gap-2">
<Input
type="color"
value={selectedItemData.props?.color || "#000000"}
onChange={(e) =>
updateItemProps(selectedItemData.id, {
color: e.target.value,
})
}
className="h-10 w-20"
/>
<Input
placeholder="e.g. text-primary"
value={selectedItemData.props?.textColor || ""}
onChange={(e) =>
updateItemProps(selectedItemData.id, {
textColor: e.target.value,
})
}
className="flex-1"
/>
</div>
<p className="text-xs text-muted-foreground">
Use color picker or Tailwind class
</p>
</div>
</div>
</div>