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
+200 -314
View File
@@ -16,7 +16,7 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { ChevronLeft, ChevronRight, Copy, Download, Eye, GripVertical, Maximize2, Minimize2, Monitor, Palette, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Plus, Settings2, Smartphone, Tablet, Trash2, Wand2 } 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"
@@ -56,6 +56,15 @@ interface PageItem {
layoutType?: "flex" | "grid" | "stack"
children?: PageItem[]
props?: Record<string, any>
animation?: {
entrance?: string
exit?: string
hover?: string
scroll?: string
duration?: number
delay?: number
easing?: string
}
}
export default function EnhancedBuilder() {
@@ -259,73 +268,8 @@ export default function EnhancedBuilder() {
const renderProps = (props?: Record<string, any>) => {
if (!props || Object.keys(props).length === 0) return ""
// Extract effects and apply them to className or style
const {
blur,
shadow,
opacity,
rotate,
scale,
grayscale,
sepia,
hueRotate,
blendMode,
className: customClass,
...otherProps
} = props
// Build className from effects
const effectClasses = [
blur !== "none" ? blur : "",
shadow !== "none" ? shadow : "",
blendMode !== "normal" ? blendMode : "",
customClass || "",
]
.filter(Boolean)
.join(" ")
// Build inline styles for numeric effects
const styles: string[] = []
if (opacity && opacity !== 100) {
styles.push(`opacity: ${opacity / 100}`)
}
const transforms: string[] = []
if (rotate && rotate !== 0) {
transforms.push(`rotate(${rotate}deg)`)
}
if (scale && scale !== 100) {
transforms.push(`scale(${scale / 100})`)
}
if (transforms.length > 0) {
styles.push(`transform: ${transforms.join(" ")}`)
}
const filters: string[] = []
if (grayscale && grayscale > 0) {
filters.push(`grayscale(${grayscale}%)`)
}
if (sepia && sepia > 0) {
filters.push(`sepia(${sepia}%)`)
}
if (hueRotate && hueRotate !== 0) {
filters.push(`hue-rotate(${hueRotate}deg)`)
}
if (filters.length > 0) {
styles.push(`filter: ${filters.join(" ")}`)
}
const styleAttr =
styles.length > 0 ? `style="${styles.join("; ")}"` : ""
const classAttr = effectClasses ? `className="${effectClasses}"` : ""
// Render other props
const otherPropsStr = Object.entries(otherProps)
return Object.entries(props)
.map(([key, value]) => {
if (key === "blur" || key === "shadow" || key === "opacity" || key === "rotate" || key === "scale" || key === "grayscale" || key === "sepia" || key === "hueRotate" || key === "blendMode") {
return "" // Skip effect props, already handled
}
if (typeof value === "string") {
return `${key}="${value}"`
} else if (typeof value === "boolean") {
@@ -337,8 +281,6 @@ export default function EnhancedBuilder() {
})
.filter(Boolean)
.join(" ")
return [classAttr, styleAttr, otherPropsStr].filter(Boolean).join(" ")
}
const renderItems = (items: PageItem[], indent = 2): string => {
@@ -866,251 +808,239 @@ ${renderItems(pageItems, 3)}
</div>
</div>
{/* Effects */}
{/* Animations */}
<div className="space-y-3">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Wand2 className="h-4 w-4" />
Effects
<Sparkles className="h-4 w-4" />
Animations
</h3>
<div className="space-y-4">
{/* Blur Effect */}
<div className="space-y-3">
{/* Entrance Animation */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Backdrop Blur
Entrance Animation
</Label>
<Select
value={selectedItemData.props?.blur || "none"}
value={selectedItemData.animation?.entrance || "none"}
onValueChange={(value) =>
updateItemProps(selectedItemData.id, {
blur: value,
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
entrance: value === "none" ? undefined : value,
},
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
<SelectTrigger>
<SelectValue placeholder="Select animation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="backdrop-blur-sm">Small</SelectItem>
<SelectItem value="backdrop-blur">Medium</SelectItem>
<SelectItem value="backdrop-blur-md">Medium+</SelectItem>
<SelectItem value="backdrop-blur-lg">Large</SelectItem>
<SelectItem value="backdrop-blur-xl">Extra Large</SelectItem>
<SelectItem value="backdrop-blur-2xl">2XL</SelectItem>
<SelectItem value="backdrop-blur-3xl">3XL</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>
{/* Drop Shadow */}
{/* Exit Animation */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Drop Shadow
Exit Animation
</Label>
<Select
value={selectedItemData.props?.shadow || "none"}
value={selectedItemData.animation?.exit || "none"}
onValueChange={(value) =>
updateItemProps(selectedItemData.id, {
shadow: value,
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
exit: value === "none" ? undefined : value,
},
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
<SelectTrigger>
<SelectValue placeholder="Select animation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="shadow-sm">Small</SelectItem>
<SelectItem value="shadow">Default</SelectItem>
<SelectItem value="shadow-md">Medium</SelectItem>
<SelectItem value="shadow-lg">Large</SelectItem>
<SelectItem value="shadow-xl">Extra Large</SelectItem>
<SelectItem value="shadow-2xl">2XL</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>
{/* Opacity */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
Opacity
</Label>
<span className="text-xs text-muted-foreground">
{selectedItemData.props?.opacity || 100}%
</span>
</div>
<Slider
value={[selectedItemData.props?.opacity || 100]}
onValueChange={([value]) =>
updateItemProps(selectedItemData.id, {
opacity: value,
})
}
min={0}
max={100}
step={5}
className="w-full"
/>
</div>
{/* Transform - Rotate */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
Rotate
</Label>
<span className="text-xs text-muted-foreground">
{selectedItemData.props?.rotate || 0}°
</span>
</div>
<Slider
value={[selectedItemData.props?.rotate || 0]}
onValueChange={([value]) =>
updateItemProps(selectedItemData.id, {
rotate: value,
})
}
min={-180}
max={180}
step={15}
className="w-full"
/>
</div>
{/* Transform - Scale */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
Scale
</Label>
<span className="text-xs text-muted-foreground">
{(selectedItemData.props?.scale || 100) / 100}x
</span>
</div>
<Slider
value={[selectedItemData.props?.scale || 100]}
onValueChange={([value]) =>
updateItemProps(selectedItemData.id, {
scale: value,
})
}
min={50}
max={200}
step={10}
className="w-full"
/>
</div>
{/* Filter - Grayscale */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
Grayscale
</Label>
<span className="text-xs text-muted-foreground">
{selectedItemData.props?.grayscale || 0}%
</span>
</div>
<Slider
value={[selectedItemData.props?.grayscale || 0]}
onValueChange={([value]) =>
updateItemProps(selectedItemData.id, {
grayscale: value,
})
}
min={0}
max={100}
step={10}
className="w-full"
/>
</div>
{/* Filter - Sepia */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
Sepia
</Label>
<span className="text-xs text-muted-foreground">
{selectedItemData.props?.sepia || 0}%
</span>
</div>
<Slider
value={[selectedItemData.props?.sepia || 0]}
onValueChange={([value]) =>
updateItemProps(selectedItemData.id, {
sepia: value,
})
}
min={0}
max={100}
step={10}
className="w-full"
/>
</div>
{/* Filter - Hue Rotate */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
Hue Rotate
</Label>
<span className="text-xs text-muted-foreground">
{selectedItemData.props?.hueRotate || 0}°
</span>
</div>
<Slider
value={[selectedItemData.props?.hueRotate || 0]}
onValueChange={([value]) =>
updateItemProps(selectedItemData.id, {
hueRotate: value,
})
}
min={0}
max={360}
step={30}
className="w-full"
/>
</div>
{/* Mix Blend Mode */}
{/* Hover Animation */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">
Mix Blend Mode
Hover Animation
</Label>
<Select
value={selectedItemData.props?.blendMode || "normal"}
value={selectedItemData.animation?.hover || "none"}
onValueChange={(value) =>
updateItemProps(selectedItemData.id, {
blendMode: value,
updateItemSettings(selectedItemData.id, {
animation: {
...selectedItemData.animation,
hover: value === "none" ? undefined : value,
},
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
<SelectTrigger>
<SelectValue placeholder="Select animation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal</SelectItem>
<SelectItem value="mix-blend-multiply">Multiply</SelectItem>
<SelectItem value="mix-blend-screen">Screen</SelectItem>
<SelectItem value="mix-blend-overlay">Overlay</SelectItem>
<SelectItem value="mix-blend-darken">Darken</SelectItem>
<SelectItem value="mix-blend-lighten">Lighten</SelectItem>
<SelectItem value="mix-blend-color-dodge">Color Dodge</SelectItem>
<SelectItem value="mix-blend-color-burn">Color Burn</SelectItem>
<SelectItem value="mix-blend-hard-light">Hard Light</SelectItem>
<SelectItem value="mix-blend-soft-light">Soft Light</SelectItem>
<SelectItem value="mix-blend-difference">Difference</SelectItem>
<SelectItem value="mix-blend-exclusion">Exclusion</SelectItem>
<SelectItem value="mix-blend-hue">Hue</SelectItem>
<SelectItem value="mix-blend-saturation">Saturation</SelectItem>
<SelectItem value="mix-blend-color">Color</SelectItem>
<SelectItem value="mix-blend-luminosity">Luminosity</SelectItem>
<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>
@@ -1275,54 +1205,10 @@ function SortableItem({
isDragging,
} = useSortable({ id: item.id })
// Apply visual effects from props
const effectStyles: React.CSSProperties = {}
const effectClasses: string[] = []
if (item.props?.blur && item.props.blur !== "none") {
effectClasses.push(item.props.blur)
}
if (item.props?.shadow && item.props.shadow !== "none") {
effectClasses.push(item.props.shadow)
}
if (item.props?.blendMode && item.props.blendMode !== "normal") {
effectClasses.push(item.props.blendMode)
}
if (item.props?.opacity && item.props.opacity !== 100) {
effectStyles.opacity = item.props.opacity / 100
}
const transforms: string[] = []
if (item.props?.rotate && item.props.rotate !== 0) {
transforms.push(`rotate(${item.props.rotate}deg)`)
}
if (item.props?.scale && item.props.scale !== 100) {
transforms.push(`scale(${item.props.scale / 100})`)
}
if (transforms.length > 0) {
effectStyles.transform = transforms.join(" ")
}
const filters: string[] = []
if (item.props?.grayscale && item.props.grayscale > 0) {
filters.push(`grayscale(${item.props.grayscale}%)`)
}
if (item.props?.sepia && item.props.sepia > 0) {
filters.push(`sepia(${item.props.sepia}%)`)
}
if (item.props?.hueRotate && item.props.hueRotate !== 0) {
filters.push(`hue-rotate(${item.props.hueRotate}deg)`)
}
if (filters.length > 0) {
effectStyles.filter = filters.join(" ")
}
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : effectStyles.opacity || 1,
...effectStyles,
opacity: isDragging ? 0.5 : 1,
}
return (
@@ -1331,7 +1217,7 @@ function SortableItem({
<div
ref={setNodeRef}
style={style}
className={`group relative ${isSelected ? "ring-2 ring-primary" : ""} ${effectClasses.join(" ")}`}
className={`group relative ${isSelected ? "ring-2 ring-primary" : ""}`}
onClick={onSelect}
>
<div className="absolute -left-12 top-2 z-10 flex flex-col items-center gap-2">