Fortura/apps/www/components/docs/motion-grid-editor.tsx
2025-08-20 04:12:49 -06:00

796 lines
30 KiB
TypeScript

'use client';
import {
type Frame,
type Frames,
MotionGrid,
} from '@/registry/components/motion-grid';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/registry/components/tooltip';
import { Trash2Icon } from '@/registry/icons/trash-2';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/registry/radix/dropdown-menu';
import { Button } from '@workspace/ui/components/ui/button';
import { Input } from '@workspace/ui/components/ui/input';
import { Label } from '@workspace/ui/components/ui/label';
import { ScrollArea } from '@workspace/ui/components/ui/scroll-area';
import { cn } from '@workspace/ui/lib/utils';
import {
ArrowLeftIcon,
ArrowRightIcon,
CopyIcon,
PlusIcon,
RotateCcwIcon,
SaveIcon,
CheckIcon,
XIcon,
Timer,
SquareRoundCorner,
} from 'lucide-react';
import * as React from 'react';
const GRID_SIZE = [7, 7] as [number, number];
const GRID_SIZE_MAX = 20;
const GRID_SIZE_MIN = 4;
const DEFAULT_DURATION = '200';
const DEFAULT_BORDER_RADIUS = '100';
const DEFAULT_BORDER_RADIUS_UNIT = '%';
const BORDER_RADIUS_UNITS = ['px', 'rem', 'em', '%'];
const formatGridSizeNumber = (value: number) => {
if (value < GRID_SIZE_MIN) return GRID_SIZE_MIN;
if (value > GRID_SIZE_MAX) return GRID_SIZE_MAX;
return Math.round(value);
};
const MyAnimation = ({
name,
value,
selectAnimation,
deleteAnimation,
active,
}: {
name: string;
value: {
gridSize: [number, number];
frames: Frames;
duration: string;
borderRadius: string;
borderRadiusUnit: string;
};
selectAnimation: () => void;
deleteAnimation: () => void;
active: boolean;
}) => {
const [isDeleting, setIsDeleting] = React.useState<boolean>(false);
const [isHovering, setIsHovering] = React.useState<boolean>(false);
return (
<div
onClick={selectAnimation}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={cn(
'group flex flex-row gap-3 items-center hover:bg-neutral-100 dark:hover:bg-neutral-900 cursor-pointer rounded-xl p-2',
active && 'bg-neutral-100 dark:bg-neutral-900',
)}
>
<div className="flex size-20 shrink-0 aspect-square justify-center items-center">
<MotionGrid
className={cn(
'dark:bg-neutral-900 bg-neutral-100 group-hover:ring-2 group-hover:ring-neutral-200 dark:group-hover:ring-neutral-800 rounded-md p-1.5',
active && 'ring-2 ring-neutral-300 dark:ring-neutral-700',
value.gridSize[0] > value.gridSize[1]
? 'w-20 h-auto'
: 'h-20 w-auto',
Math.max(value.gridSize[0], value.gridSize[1]) > 10
? 'gap-px'
: 'gap-0.5',
)}
cellProps={{
style: {
borderRadius: `${value.borderRadius}${value.borderRadiusUnit}`,
},
}}
duration={Number(value.duration)}
animate={isHovering}
cellClassName="!size-full"
cellActiveClassName="bg-neutral-800 dark:bg-neutral-200"
cellInactiveClassName="bg-neutral-200 dark:bg-neutral-800"
gridSize={value.gridSize}
frames={value.frames}
/>
</div>
<div className="flex flex-row gap-2 justify-between items-center w-full">
<div className="flex flex-col gap-1 text-left">
<Label className="text-sm">{name}</Label>
<p className="text-xs text-neutral-500 mt-0 mb-0">
{value.gridSize[0]}x{value.gridSize[1]} {value.frames.length}{' '}
frames
</p>
</div>
{isDeleting ? (
<div className="flex flex-row gap-2">
<Button
size="icon-xs"
variant="neutral"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDeleting(false);
}}
>
<XIcon />
</Button>
<Button
size="icon-xs"
variant="destructive"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
deleteAnimation();
setIsDeleting(false);
}}
>
<CheckIcon />
</Button>
</div>
) : (
<Button
size="icon-xs"
variant="neutral"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDeleting(true);
}}
>
<Trash2Icon />
</Button>
)}
</div>
</div>
);
};
interface Animation {
gridSize: [number, number];
frames: Frames;
duration: string;
borderRadius: string;
borderRadiusUnit: string;
}
export const MotionGridEditor = () => {
const [gridSizeInput, setGridSizeInput] = React.useState<[string, string]>(
GRID_SIZE.map((n) => n.toString()) as [string, string],
);
const [gridSize, setGridSize] = React.useState<[number, number]>(GRID_SIZE);
const [frames, setFrames] = React.useState<Frames>([[]]);
const [activeFrame, setActiveFrame] = React.useState<number>(0);
const [isCopied, setIsCopied] = React.useState<boolean>(false);
const [isSaved, setIsSaved] = React.useState<boolean>(false);
const [animationName, setAnimationName] = React.useState<string>('');
const [selectedAnimation, setSelectedAnimation] = React.useState<
string | null
>(null);
const [animations, setAnimations] = React.useState<Record<string, Animation>>(
{},
);
const [isDrawing, setIsDrawing] = React.useState(false);
const [drawAction, setDrawAction] = React.useState<'add' | 'remove' | null>(
null,
);
const [duration, setDuration] = React.useState<string>(DEFAULT_DURATION);
const [borderRadius, setBorderRadius] = React.useState<string>(
DEFAULT_BORDER_RADIUS,
);
const [borderRadiusUnit, setBorderRadiusUnit] = React.useState<string>('px');
React.useEffect(() => {
const handleUp = () => {
setIsDrawing(false);
setDrawAction(null);
};
window.addEventListener('mouseup', handleUp);
return () => window.removeEventListener('mouseup', handleUp);
}, []);
const normalizeAnimation = (anim: Partial<Animation>): Animation =>
({
...anim,
duration: anim.duration ?? DEFAULT_DURATION,
borderRadius: anim.borderRadius ?? DEFAULT_BORDER_RADIUS,
borderRadiusUnit: anim.borderRadiusUnit ?? DEFAULT_BORDER_RADIUS_UNIT,
}) as Animation;
React.useEffect(() => {
const raw = localStorage.getItem('animations');
const parsed: Record<string, Partial<Animation>> = raw
? JSON.parse(raw)
: {};
const fixed: Record<string, Animation> = Object.fromEntries(
Object.entries(parsed).map(([key, value]) => [
key,
normalizeAnimation(value),
]),
);
setAnimations(fixed);
localStorage.setItem('animations', JSON.stringify(fixed));
}, []);
const activeFrameDots = new Set<number>(
frames[activeFrame]?.map(([x, y]) => y * gridSize[0]! + x) ?? [],
);
const applyDot = (x: number, y: number, action: 'add' | 'remove') => {
setFrames((prev) => {
const clone = [...prev];
const current = clone[activeFrame]!;
const idx = current.findIndex(([px, py]) => px === x && py === y);
if (action === 'add' && idx === -1) {
clone[activeFrame] = [...current, [x, y]];
} else if (action === 'remove' && idx !== -1) {
clone[activeFrame] = [
...current.slice(0, idx),
...current.slice(idx + 1),
];
}
return clone;
});
};
const moveCurrentFrame = (direction: -1 | 1) => {
if (frames.length <= 1) return;
setFrames((prev) => {
const clone = [...prev];
const len = clone.length;
const newIndex = (activeFrame + direction + len) % len;
const [moved] = clone.splice(activeFrame, 1);
clone.splice(newIndex, 0, moved!);
setActiveFrame(newIndex);
return clone;
});
};
const removeDotNotInGrid = () => {
setFrames((prev) =>
prev.map((frame) =>
frame.filter(([x, y]) => x < gridSize[0] && y < gridSize[1]),
),
);
};
const createNewAnimation = () => {
setGridSize(GRID_SIZE);
setGridSizeInput(GRID_SIZE.map((n) => n.toString()) as [string, string]);
setDuration(DEFAULT_DURATION);
setBorderRadius(DEFAULT_BORDER_RADIUS);
setBorderRadiusUnit(DEFAULT_BORDER_RADIUS_UNIT);
setFrames([[]]);
setActiveFrame(0);
setAnimationName('');
setIsSaved(false);
setIsCopied(false);
setSelectedAnimation(null);
};
return (
<div className="grid grid-cols-12 gap-4 lg:h-[506px]">
<div className="lg:col-span-5 col-span-12 border rounded-xl overflow-hidden flex flex-col min-h-0 h-[506px]">
<div className="relative flex flex-row p-4 bg-muted border-b border-border/75 dark:border-border/50">
<Label>My Animations</Label>
<Button
size="icon-xs"
variant="neutral"
className="absolute top-1/2 -translate-y-1/2 right-2"
onClick={createNewAnimation}
>
<PlusIcon />
</Button>
</div>
<div className="flex-1 flex flex-col gap-y-2 justify-between overflow-y-auto p-2">
<div className="flex flex-col gap-y-2">
{Object.entries(animations).map(([name, value]) => (
<MyAnimation
key={name}
name={name}
value={value}
selectAnimation={() => {
setGridSize(value.gridSize);
setFrames(value.frames);
setDuration(value.duration);
setBorderRadius(value.borderRadius);
setBorderRadiusUnit(value.borderRadiusUnit);
setActiveFrame(0);
setAnimationName(name);
setIsSaved(false);
setIsCopied(false);
setGridSizeInput(
value.gridSize.map((n) => n.toString()) as [string, string],
);
setSelectedAnimation(name);
}}
deleteAnimation={() => {
if (selectedAnimation === name) createNewAnimation();
const newAnimations = { ...animations };
delete newAnimations[name];
setAnimations(newAnimations);
localStorage.setItem(
'animations',
JSON.stringify(newAnimations),
);
}}
active={selectedAnimation === name}
/>
))}
</div>
<span className="text-xs text-neutral-400 dark:text-neutral-600 text-center italic">
Animations stored in local storage
</span>
</div>
</div>
<div className="lg:col-span-7 col-span-12 border rounded-xl overflow-hidden">
<div className="relative flex flex-row justify-between items-center gap-2 p-4 bg-muted border-b border-border/75 dark:border-border/50">
<Label className="whitespace-nowrap">
{selectedAnimation ? 'Edit Animation' : 'Create New Animation'}
</Label>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex flex-row gap-1 items-center">
<Input
placeholder="X"
type="number"
className="border-none h-8 text-sm max-w-8 p-0 bg-white text-center dark:bg-neutral-900 shadow-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={gridSizeInput[0]}
onChange={(e) => {
setGridSizeInput((prev) => [e.target.value, prev[1]]);
setGridSize((prev) => [
formatGridSizeNumber(Number(e.target.value)),
prev[1],
]);
}}
onBlur={() => {
setGridSizeInput(
gridSize.map((n) => n.toString()) as [string, string],
);
removeDotNotInGrid();
}}
/>
<XIcon className="size-3 text-neutral-500 stroke-3" />
<Input
placeholder="Y"
type="number"
className="border-none h-8 text-sm max-w-8 p-0 bg-white text-center dark:bg-neutral-900 shadow-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={gridSizeInput[1]}
onChange={(e) => {
setGridSizeInput((prev) => [prev[0], e.target.value]);
setGridSize((prev) => [
prev[0],
formatGridSizeNumber(Number(e.target.value)),
]);
}}
onBlur={() => {
setGridSizeInput(
gridSize.map((n) => n.toString()) as [string, string],
);
removeDotNotInGrid();
}}
/>
</div>
</div>
<div>
<div className="p-4 relative">
<div className="flex size-20 shrink-0 aspect-square justify-end items-start absolute right-4 top-4">
<MotionGrid
className={cn(
'dark:bg-neutral-900 bg-neutral-100 rounded-md p-1.5',
gridSize[0] > gridSize[1] ? 'w-20 h-auto' : 'h-20 w-auto',
Math.max(gridSize[0], gridSize[1]) > 10
? 'gap-px'
: 'gap-0.5',
)}
cellProps={{
style: {
borderRadius: `${borderRadius}${borderRadiusUnit}`,
},
}}
duration={Number(duration)}
cellClassName="!size-full"
cellActiveClassName="bg-neutral-800 dark:bg-neutral-200"
cellInactiveClassName="bg-neutral-200 dark:bg-neutral-800"
gridSize={gridSize}
frames={frames}
/>
</div>
<div className="max-w-[150px] sm:max-w-[200px] flex items-center justify-center aspect-square mx-auto">
<div
className={cn(
'grid w-fit p-3 dark:bg-neutral-900 bg-neutral-100 rounded-xl',
gridSize[0] > gridSize[1]
? 'w-[150px] sm:w-[200px]'
: 'h-[150px] sm:h-[200px]',
Math.max(gridSize[0], gridSize[1]) > 10
? 'gap-0.5'
: Math.max(gridSize[0], gridSize[1]) > 10
? 'gap-1'
: 'gap-1.5',
)}
style={{
gridTemplateColumns: `repeat(${gridSize[0]}, minmax(0, 1fr))`,
gridAutoRows: '1fr',
}}
>
{Array.from({ length: gridSize[0]! * gridSize[1]! }).map(
(_, i) => (
<div
key={i}
onMouseDown={() => {
const x = i % gridSize[0]!;
const y = Math.floor(i / gridSize[0]!);
const action: 'add' | 'remove' = activeFrameDots.has(i)
? 'remove'
: 'add';
setDrawAction(action);
setIsDrawing(true);
applyDot(x, y, action);
}}
onMouseEnter={() => {
if (!isDrawing || !drawAction) return;
const x = i % gridSize[0]!;
const y = Math.floor(i / gridSize[0]!);
applyDot(x, y, drawAction);
}}
className={cn(
'rounded-full aspect-square hover:ring hover:ring-neutral-300 dark:hover:ring-neutral-700',
activeFrameDots.has(i)
? 'bg-neutral-800 dark:bg-neutral-200'
: 'bg-neutral-200 dark:bg-neutral-800',
)}
style={{
borderRadius: `${borderRadius}${borderRadiusUnit}`,
}}
/>
),
)}
</div>
</div>
</div>
<TooltipProvider>
<div className="p-4 space-y-4 pt-0">
<div className="h-20 w-full flex flex-row items-center gap-2">
<ScrollArea className="w-full h-full flex-1 bg-neutral-100 dark:bg-neutral-900 rounded-2xl overflow-x-auto">
<div className="w-max p-2 h-full">
<div className="flex h-full flex-row gap-2 items-center">
{frames.map((_, index) => {
const activeDot = new Set<number>(
frames[index]?.map(
([x, y]) => y * gridSize[0]! + x,
) ?? [],
);
return (
<button
key={index}
className={cn(
'h-full cursor-pointer shrink-0 bg-background rounded-lg',
activeFrame === index
? 'ring-2 ring-neutral-300 dark:ring-neutral-700'
: '',
)}
onClick={() => setActiveFrame(index)}
>
<div
className={cn(
'grid p-[5px] h-full',
Math.max(gridSize[0], gridSize[1]) > 10
? 'gap-[0.5px]'
: Math.max(gridSize[0], gridSize[1]) > 10
? 'gap-px'
: 'gap-[1.5px]',
)}
style={{
gridTemplateColumns: `repeat(${gridSize[0]}, minmax(0, 1fr))`,
}}
>
{Array.from({
length: gridSize[0]! * gridSize[1]!,
}).map((_, i) => (
<div
key={i}
className={cn(
'bg-neutral-200 dark:bg-neutral-800 aspect-square w-full',
activeDot.has(i)
? 'bg-neutral-800 dark:bg-neutral-200'
: 'bg-neutral-200 dark:bg-neutral-800',
)}
style={{
borderRadius: `${borderRadius}${borderRadiusUnit}`,
}}
/>
))}
</div>
</button>
);
})}
</div>
</div>
</ScrollArea>
<div className="grid grid-cols-2 gap-2">
<Tooltip>
<TooltipTrigger>
<Button
size="icon"
onClick={() => {
setFrames((prev) => {
const currentIndex = activeFrame;
const currentFrame = prev[currentIndex] ?? [];
const newFrame: Frame = currentFrame.map(
([x, y]) => [x, y],
);
const updatedFrames = [...prev];
updatedFrames.splice(currentIndex + 1, 0, newFrame);
setActiveFrame(currentIndex + 1);
return updatedFrames;
});
}}
>
<PlusIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Add Frame</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
size="icon"
variant="destructive"
disabled={frames.length <= 1}
onClick={() => {
if (frames.length <= 1) return;
setFrames((prev) => {
const newFrames = prev.filter(
(_, i) => i !== activeFrame,
);
setActiveFrame(
newFrames.length - 1 > 0
? newFrames.length - 1
: 0,
);
return newFrames;
});
}}
>
<Trash2Icon />
</Button>
</TooltipTrigger>
<TooltipContent>Remove Frame</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
size="icon"
className="h-8"
variant="neutral"
disabled={frames.length <= 1}
onClick={() => moveCurrentFrame(-1)}
>
<ArrowLeftIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Move Frame Left</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
size="icon"
className="h-8"
variant="neutral"
disabled={frames.length <= 1}
onClick={() => moveCurrentFrame(1)}
>
<ArrowRightIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Move Frame Right</TooltipContent>
</Tooltip>
</div>
</div>
<div className="h-10 flex flex-row gap-4 items-center">
<div className="relative flex-1 h-full bg-neutral-100 dark:bg-neutral-900 rounded-lg">
<div className="absolute inset-y-0 left-0 h-full aspect-square bg-neutral-200 dark:bg-neutral-800 rounded-l-lg flex items-center justify-center">
<Timer className="size-5 text-neutral-400 dark:text-neutral-600" />
</div>
<Input
type="number"
className="size-full px-13 border-none bg-transparent shadow-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={duration}
placeholder="Duration"
step={10}
onChange={(e) => {
const v = e.target.value;
if (v === '') {
setDuration('');
return;
}
const n = Number(v);
if (!Number.isNaN(n) && n >= 0) {
setDuration(v);
}
}}
/>
<div className="absolute inset-y-0 right-0 h-full aspect-square bg-neutral-200 dark:bg-neutral-800 rounded-r-lg flex items-center justify-center">
<span className="text-neutral-400 dark:text-neutral-600 text-sm">
ms
</span>
</div>
</div>
<div className="relative flex-1 h-full bg-neutral-100 dark:bg-neutral-900 rounded-lg">
<div className="absolute inset-y-0 left-0 h-full aspect-square bg-neutral-200 dark:bg-neutral-800 rounded-l-lg flex items-center justify-center">
<SquareRoundCorner className="size-5 text-neutral-400 dark:text-neutral-600" />
</div>
<Input
type="number"
className="size-full px-13 border-none bg-transparent shadow-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={borderRadius}
placeholder="Border Radius"
onChange={(e) => {
const v = e.target.value;
if (v === '') {
setBorderRadius('');
return;
}
const n = Number(v);
if (!Number.isNaN(n) && n >= 0) {
setBorderRadius(v);
}
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="absolute inset-y-0 right-0 h-full aspect-square bg-neutral-200 dark:bg-neutral-800 rounded-r-lg flex items-center justify-center">
<button className="text-neutral-400 dark:text-neutral-600 text-sm">
{borderRadiusUnit}
</button>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
{BORDER_RADIUS_UNITS.map((unit) => (
<DropdownMenuItem
key={unit}
onClick={() => setBorderRadiusUnit(unit)}
>
{unit}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div className="flex p-4 flex-row gap-2 border-t">
<Input
required
placeholder="Animation Name"
className="border-none bg-neutral-100 dark:bg-neutral-900 shadow-none"
value={animationName}
onChange={(e) => setAnimationName(e.target.value)}
/>
<Tooltip>
<TooltipTrigger>
<Button
variant="neutral"
size="icon"
onClick={() => setFrames([[]])}
>
<RotateCcwIcon />
</Button>
</TooltipTrigger>
<TooltipContent>Reset Animation</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
variant="neutral"
size="icon"
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(frames));
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 2000);
}}
>
{isCopied ? <CheckIcon /> : <CopyIcon />}
</Button>
</TooltipTrigger>
<TooltipContent>
{isCopied ? 'Copied' : 'Copy Animation'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button
size="icon"
disabled={!animationName.trim()}
onClick={() => {
if (!animationName.trim())
return alert('Please enter a name');
const nameAlreadyExists = selectedAnimation
? animationName !== selectedAnimation &&
animations[animationName]
: Boolean(animations[animationName]);
if (nameAlreadyExists) {
return alert('Animation with this name already exists');
}
const newAnimations = { ...animations };
if (selectedAnimation) {
if (selectedAnimation !== animationName) {
delete newAnimations[selectedAnimation];
}
}
newAnimations[animationName] = {
gridSize,
frames,
duration,
borderRadius,
borderRadiusUnit,
};
setAnimations(newAnimations);
setSelectedAnimation(animationName);
localStorage.setItem(
'animations',
JSON.stringify(newAnimations),
);
setIsSaved(true);
setTimeout(() => setIsSaved(false), 2000);
}}
>
{isSaved ? <CheckIcon /> : <SaveIcon />}
</Button>
</TooltipTrigger>
<TooltipContent>Save Animation</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
</div>
</div>
);
};