Compare commits

..
Author SHA1 Message Date
hanzo-dev e93be43638 feat: Add comprehensive animation controls panel to page builder
- Added animation properties to PageItem interface
- Entrance animations: fadeIn, slideIn (up/down/left/right), scaleIn, rotateIn
- Exit animations: fadeOut, slideOut (up/down/left/right), scaleOut, rotateOut
- Hover animations: bounce, pulse, ping, spin, shake, grow, shrink
- Scroll animations: parallax, reveal, fadeInScroll, slideInScroll
- Duration control: 100-2000ms slider
- Delay control: 0-2000ms slider
- Easing functions: linear, ease, ease-in, ease-out, ease-in-out, bounce
- Preview button to test animations

Uses Tailwind animate classes and custom animation values.
2025-11-13 10:17:32 -08:00
+221 -306
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 { ChevronLeft, ChevronRight, Copy, Download, Eye, GripVertical, Maximize2, Minimize2, Monitor, Palette, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Play, Plus, Settings2, Smartphone, Sparkles, Tablet, Trash2 } from "lucide-react"
import { BuilderPreview } from "@/components/builder-preview"
import { OpenInHButton } from "@/components/open-in-h-button"
@@ -45,19 +45,8 @@ import {
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 { Slider } from "@/registry/default/ui/slider"
import { Label } from "@/registry/default/ui/label"
interface PageItem {
id: string
@@ -67,16 +56,17 @@ interface PageItem {
layoutType?: "flex" | "grid" | "stack"
children?: PageItem[]
props?: Record<string, any>
boxModel?: BoxModel
animation?: {
entrance?: string
exit?: string
hover?: string
scroll?: string
duration?: number
delay?: number
easing?: string
}
}
// 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 +82,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 +396,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,278 +808,239 @@ ${renderItems(pageItems, 3)}
</div>
</div>
{/* Box Model */}
{/* Animations */}
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Box className="h-4 w-4" />
Box Model
<Sparkles className="h-4 w-4" />
Animations
</h3>
{/* Margin */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">
Margin
</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 ? (
<div className="space-y-3">
{/* Entrance Animation */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Entrance Animation
</Label>
<Select
value={selectedItemData.boxModel?.margin?.top || "0"}
value={selectedItemData.animation?.entrance || "none"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "margin", "all", value)
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
entrance: value === "none" ? undefined : value,
},
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select spacing" />
<SelectValue placeholder="Select animation" />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectItem value="fadeIn">Fade In</SelectItem>
<SelectItem value="slideInUp">Slide In Up</SelectItem>
<SelectItem value="slideInDown">Slide In Down</SelectItem>
<SelectItem value="slideInLeft">Slide In Left</SelectItem>
<SelectItem value="slideInRight">Slide In Right</SelectItem>
<SelectItem value="scaleIn">Scale In</SelectItem>
<SelectItem value="rotateIn">Rotate In</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 ? (
{/* Exit Animation */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Exit Animation
</Label>
<Select
value={selectedItemData.boxModel?.padding?.top || "0"}
value={selectedItemData.animation?.exit || "none"}
onValueChange={(value) =>
updateBoxModel(selectedItemData.id, "padding", "all", value)
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
exit: value === "none" ? undefined : value,
},
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select spacing" />
<SelectValue placeholder="Select animation" />
</SelectTrigger>
<SelectContent>
{SPACING_SCALE.map((scale) => (
<SelectItem key={scale} value={scale}>
{scale}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectItem value="fadeOut">Fade Out</SelectItem>
<SelectItem value="slideOutUp">Slide Out Up</SelectItem>
<SelectItem value="slideOutDown">Slide Out Down</SelectItem>
<SelectItem value="slideOutLeft">Slide Out Left</SelectItem>
<SelectItem value="slideOutRight">Slide Out Right</SelectItem>
<SelectItem value="scaleOut">Scale Out</SelectItem>
<SelectItem value="rotateOut">Rotate Out</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>
{/* 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>
</div>
</div>
{/* Hover Animation */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Hover Animation
</Label>
<Select
value={selectedItemData.animation?.hover || "none"}
onValueChange={(value) =>
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
hover: value === "none" ? undefined : value,
},
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select animation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="animate-bounce">Bounce</SelectItem>
<SelectItem value="animate-pulse">Pulse</SelectItem>
<SelectItem value="animate-ping">Ping</SelectItem>
<SelectItem value="animate-spin">Spin</SelectItem>
<SelectItem value="shake">Shake</SelectItem>
<SelectItem value="grow">Grow</SelectItem>
<SelectItem value="shrink">Shrink</SelectItem>
</SelectContent>
</Select>
</div>
{/* Scroll Animation */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Scroll Animation
</Label>
<Select
value={selectedItemData.animation?.scroll || "none"}
onValueChange={(value) =>
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
scroll: value === "none" ? undefined : value,
},
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select animation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="parallax">Parallax</SelectItem>
<SelectItem value="reveal">Reveal</SelectItem>
<SelectItem value="fadeInScroll">Fade In on Scroll</SelectItem>
<SelectItem value="slideInScroll">Slide In on Scroll</SelectItem>
</SelectContent>
</Select>
</div>
{/* Duration Control */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
Duration (ms)
</Label>
<span className="text-xs text-muted-foreground">
{selectedItemData.animation?.duration || 300}ms
</span>
</div>
<Slider
min={100}
max={2000}
step={100}
value={[selectedItemData.animation?.duration || 300]}
onValueChange={([value]) =>
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
duration: value,
},
})
}
/>
</div>
{/* Delay Control */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
Delay (ms)
</Label>
<span className="text-xs text-muted-foreground">
{selectedItemData.animation?.delay || 0}ms
</span>
</div>
<Slider
min={0}
max={2000}
step={100}
value={[selectedItemData.animation?.delay || 0]}
onValueChange={([value]) =>
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
delay: value,
},
})
}
/>
</div>
{/* Easing Function */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Easing Function
</Label>
<Select
value={selectedItemData.animation?.easing || "ease"}
onValueChange={(value) =>
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
easing: value,
},
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select easing" />
</SelectTrigger>
<SelectContent>
<SelectItem value="linear">Linear</SelectItem>
<SelectItem value="ease">Ease</SelectItem>
<SelectItem value="ease-in">Ease In</SelectItem>
<SelectItem value="ease-out">Ease Out</SelectItem>
<SelectItem value="ease-in-out">Ease In Out</SelectItem>
<SelectItem value="cubic-bezier(0.68, -0.55, 0.265, 1.55)">Bounce</SelectItem>
</SelectContent>
</Select>
</div>
{/* Preview Button */}
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
// Trigger animation preview
const element = document.getElementById(`item-${selectedItemData.id}`)
if (element) {
element.classList.add('animate-preview')
setTimeout(() => {
element.classList.remove('animate-preview')
}, selectedItemData.animation?.duration || 300)
}
}}
>
<Play className="mr-2 h-4 w-4" />
Preview Animation
</Button>
</div>
</div>