diff options
Diffstat (limited to 'apps/web/src/components')
86 files changed, 14269 insertions, 14275 deletions
diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 40390f07..c346ad53 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -1,150 +1,150 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import { formatTimeCode, parseTimeCode } from "@/lib/time"; -import type { TTimeCode } from "@/lib/time"; -import { cn } from "@/utils/ui"; - -interface EditableTimecodeProps { - time: number; - duration: number; - format?: TTimeCode; - fps: number; - onTimeChange?: ({ time }: { time: number }) => void; - className?: string; - disabled?: boolean; -} - -export function EditableTimecode({ - time, - duration, - format = "HH:MM:SS:FF", - fps, - onTimeChange, - className, - disabled = false, -}: EditableTimecodeProps) { - const [isEditing, setIsEditing] = useState(false); - const [inputValue, setInputValue] = useState(""); - const [hasError, setHasError] = useState(false); - const inputRef = useRef<HTMLInputElement>(null); - const enterPressedRef = useRef(false); - const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }); - - const startEditing = () => { - if (disabled) return; - setIsEditing(true); - setInputValue(formattedTime); - setHasError(false); - enterPressedRef.current = false; - }; - - const cancelEditing = () => { - setIsEditing(false); - setInputValue(""); - setHasError(false); - enterPressedRef.current = false; - }; - - const applyEdit = () => { - const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps }); - - if (parsedTime === null) { - setHasError(true); - return; - } - - const clampedTime = Math.max( - 0, - duration ? Math.min(duration, parsedTime) : parsedTime, - ); - - onTimeChange?.({ time: clampedTime }); - setIsEditing(false); - setInputValue(""); - setHasError(false); - enterPressedRef.current = false; - }; - - const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { - if (event.key === "Enter") { - event.preventDefault(); - enterPressedRef.current = true; - applyEdit(); - } else if (event.key === "Escape") { - event.preventDefault(); - cancelEditing(); - } - }; - - const handleInputChange = ({ - target, - }: React.ChangeEvent<HTMLInputElement>) => { - setInputValue(target.value); - setHasError(false); - }; - - const handleBlur = () => { - if (!enterPressedRef.current && isEditing) { - applyEdit(); - } - }; - - const handleDisplayKeyDown = ( - event: React.KeyboardEvent<HTMLButtonElement>, - ) => { - if (disabled) return; - - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - startEditing(); - } - }; - - useEffect(() => { - if (isEditing && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [isEditing]); - - if (isEditing) { - return ( - <input - ref={inputRef} - type="text" - value={inputValue} - onChange={handleInputChange} - onKeyDown={handleKeyDown} - onBlur={handleBlur} - className={cn( - "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none", - "focus:bg-background focus:border-primary focus:rounded", - "text-primary tabular-nums", - hasError && "text-destructive focus:border-destructive", - className, - )} - style={{ width: `${formattedTime.length + 1}ch` }} - placeholder={formattedTime} - /> - ); - } - - return ( - <button - type="button" - onClick={startEditing} - onKeyDown={handleDisplayKeyDown} - disabled={disabled} - className={cn( - "text-primary cursor-pointer font-mono text-xs tabular-nums", - "hover:bg-muted/50 -mx-1 px-1 hover:rounded", - disabled && "cursor-default hover:bg-transparent", - className, - )} - title={disabled ? undefined : "Click to edit time"} - > - {formattedTime} - </button> - ); -} +"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { formatTimeCode, parseTimeCode } from "@/lib/time";
+import type { TTimeCode } from "@/lib/time";
+import { cn } from "@/utils/ui";
+
+interface EditableTimecodeProps {
+ time: number;
+ duration: number;
+ format?: TTimeCode;
+ fps: number;
+ onTimeChange?: ({ time }: { time: number }) => void;
+ className?: string;
+ disabled?: boolean;
+}
+
+export function EditableTimecode({
+ time,
+ duration,
+ format = "HH:MM:SS:FF",
+ fps,
+ onTimeChange,
+ className,
+ disabled = false,
+}: EditableTimecodeProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [inputValue, setInputValue] = useState("");
+ const [hasError, setHasError] = useState(false);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const enterPressedRef = useRef(false);
+ const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps });
+
+ const startEditing = () => {
+ if (disabled) return;
+ setIsEditing(true);
+ setInputValue(formattedTime);
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const cancelEditing = () => {
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const applyEdit = () => {
+ const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps });
+
+ if (parsedTime === null) {
+ setHasError(true);
+ return;
+ }
+
+ const clampedTime = Math.max(
+ 0,
+ duration ? Math.min(duration, parsedTime) : parsedTime,
+ );
+
+ onTimeChange?.({ time: clampedTime });
+ setIsEditing(false);
+ setInputValue("");
+ setHasError(false);
+ enterPressedRef.current = false;
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ enterPressedRef.current = true;
+ applyEdit();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ cancelEditing();
+ }
+ };
+
+ const handleInputChange = ({
+ target,
+ }: React.ChangeEvent<HTMLInputElement>) => {
+ setInputValue(target.value);
+ setHasError(false);
+ };
+
+ const handleBlur = () => {
+ if (!enterPressedRef.current && isEditing) {
+ applyEdit();
+ }
+ };
+
+ const handleDisplayKeyDown = (
+ event: React.KeyboardEvent<HTMLButtonElement>,
+ ) => {
+ if (disabled) return;
+
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ startEditing();
+ }
+ };
+
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ if (isEditing) {
+ return (
+ <input
+ ref={inputRef}
+ type="text"
+ value={inputValue}
+ onChange={handleInputChange}
+ onKeyDown={handleKeyDown}
+ onBlur={handleBlur}
+ className={cn(
+ "-mx-1 border border-transparent bg-transparent px-1 font-mono text-xs outline-none",
+ "focus:bg-background focus:border-primary focus:rounded",
+ "text-primary tabular-nums",
+ hasError && "text-destructive focus:border-destructive",
+ className,
+ )}
+ style={{ width: `${formattedTime.length + 1}ch` }}
+ placeholder={formattedTime}
+ />
+ );
+ }
+
+ return (
+ <button
+ type="button"
+ onClick={startEditing}
+ onKeyDown={handleDisplayKeyDown}
+ disabled={disabled}
+ className={cn(
+ "text-primary cursor-pointer font-mono text-xs tabular-nums",
+ "hover:bg-muted/50 -mx-1 px-1 hover:rounded",
+ disabled && "cursor-default hover:bg-transparent",
+ className,
+ )}
+ title={disabled ? undefined : "Click to edit time"}
+ >
+ {formattedTime}
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx index 42f99c79..fad711e3 100644 --- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx @@ -1,83 +1,83 @@ -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import type { TProjectMetadata } from "@/lib/project/types"; -import { formatDate } from "@/utils/date"; -import { formatTimeCode } from "@/lib/time"; -import { Button } from "@/components/ui/button"; - -function InfoRow({ - label, - value, -}: { - label: string; - value: string | React.ReactNode; -}) { - return ( - <div className="flex justify-between items-center py-0 last:pb-0"> - <span className="text-muted-foreground text-sm">{label}</span> - <span className="text-sm font-medium">{value}</span> - </div> - ); -} - -export function ProjectInfoDialog({ - isOpen, - onOpenChange, - project, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; - project: TProjectMetadata; -}) { - const durationFormatted = - project.duration > 0 - ? formatTimeCode({ - timeInSeconds: project.duration, - format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS", - }) - : "0:00"; - - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent onOpenAutoFocus={(event) => event.preventDefault()}> - <DialogHeader> - <DialogTitle className="truncate max-w-[350px]"> - {project.name} - </DialogTitle> - </DialogHeader> - - <DialogBody className="flex flex-col"> - <InfoRow label="Duration" value={durationFormatted} /> - <InfoRow - label="Created" - value={formatDate({ date: project.createdAt })} - /> - <InfoRow - label="Modified" - value={formatDate({ date: project.updatedAt })} - /> - <InfoRow - label="Project ID" - value={ - <code className="text-xs bg-muted px-1.5 py-0.5 rounded"> - {project.id.slice(0, 8)} - </code> - } - /> - </DialogBody> - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - Close - </Button> - <Button onClick={() => onOpenChange(false)}>Done</Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} +import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import type { TProjectMetadata } from "@/lib/project/types";
+import { formatDate } from "@/utils/date";
+import { formatTimeCode } from "@/lib/time";
+import { Button } from "@/components/ui/button";
+
+function InfoRow({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | React.ReactNode;
+}) {
+ return (
+ <div className="flex justify-between items-center py-0 last:pb-0">
+ <span className="text-muted-foreground text-sm">{label}</span>
+ <span className="text-sm font-medium">{value}</span>
+ </div>
+ );
+}
+
+export function ProjectInfoDialog({
+ isOpen,
+ onOpenChange,
+ project,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TProjectMetadata;
+}) {
+ const durationFormatted =
+ project.duration > 0
+ ? formatTimeCode({
+ timeInSeconds: project.duration,
+ format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
+ })
+ : "0:00";
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent onOpenAutoFocus={(event) => event.preventDefault()}>
+ <DialogHeader>
+ <DialogTitle className="truncate max-w-[350px]">
+ {project.name}
+ </DialogTitle>
+ </DialogHeader>
+
+ <DialogBody className="flex flex-col">
+ <InfoRow label="Duration" value={durationFormatted} />
+ <InfoRow
+ label="Created"
+ value={formatDate({ date: project.createdAt })}
+ />
+ <InfoRow
+ label="Modified"
+ value={formatDate({ date: project.updatedAt })}
+ />
+ <InfoRow
+ label="Project ID"
+ value={
+ <code className="text-xs bg-muted px-1.5 py-0.5 rounded">
+ {project.id.slice(0, 8)}
+ </code>
+ }
+ />
+ </DialogBody>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ Close
+ </Button>
+ <Button onClick={() => onOpenChange(false)}>Done</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx index 9a353a1c..70802b5b 100644 --- a/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/shortcuts-dialog.tsx @@ -1,229 +1,229 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { - type KeyboardShortcut, - useKeyboardShortcutsHelp, -} from "@/hooks/use-keyboard-shortcuts-help"; -import { useKeybindingsStore } from "@/stores/keybindings-store"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -export function ShortcutsDialog({ - isOpen, - onOpenChange, -}: { - isOpen: boolean; - onOpenChange: (open: boolean) => void; -}) { - const [recordingShortcut, setRecordingShortcut] = - useState<KeyboardShortcut | null>(null); - - const { - updateKeybinding, - removeKeybinding, - getKeybindingString, - validateKeybinding, - getKeybindingsForAction, - setIsRecording, - resetToDefaults, - isRecording, - } = useKeybindingsStore(); - - const { shortcuts } = useKeyboardShortcutsHelp(); - - const categories = Array.from(new Set(shortcuts.map((s) => s.category))); - - useEffect(() => { - if (!isRecording || !recordingShortcut) return; - - const handleKeyDown = (e: KeyboardEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const keyString = getKeybindingString(e); - if (keyString) { - const conflict = validateKeybinding( - keyString, - recordingShortcut.action, - ); - if (conflict) { - toast.error( - `Key "${keyString}" is already bound to "${conflict.existingAction}"`, - ); - setRecordingShortcut(null); - return; - } - - const oldKeys = getKeybindingsForAction(recordingShortcut.action); - for (const key of oldKeys) { - removeKeybinding(key); - } - - updateKeybinding(keyString, recordingShortcut.action); - - setIsRecording(false); - setRecordingShortcut(null); - } - }; - - const handleClickOutside = () => { - setRecordingShortcut(null); - setIsRecording(false); - }; - - document.addEventListener("keydown", handleKeyDown); - document.addEventListener("click", handleClickOutside); - - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("click", handleClickOutside); - }; - }, [ - recordingShortcut, - getKeybindingString, - updateKeybinding, - removeKeybinding, - validateKeybinding, - getKeybindingsForAction, - setIsRecording, - isRecording, - ]); - - const handleStartRecording = (shortcut: KeyboardShortcut) => { - setRecordingShortcut(shortcut); - setIsRecording(true); - }; - - return ( - <Dialog open={isOpen} onOpenChange={onOpenChange}> - <DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0"> - <DialogHeader> - <DialogTitle>Keyboard shortcuts</DialogTitle> - </DialogHeader> - - <DialogBody className="scrollbar-thin grow overflow-y-auto"> - <div className="flex flex-col gap-6"> - {categories.map((category) => ( - <div key={category} className="flex flex-col gap-1"> - <h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> - {category} - </h3> - <div className="flex flex-col gap-1"> - {shortcuts - .filter((shortcut) => shortcut.category === category) - .map((shortcut) => ( - <ShortcutItem - key={shortcut.action} - shortcut={shortcut} - isRecording={ - shortcut.action === recordingShortcut?.action - } - onStartRecording={() => handleStartRecording(shortcut)} - /> - ))} - </div> - </div> - ))} - </div> - </DialogBody> - <DialogFooter> - <Button variant="destructive" onClick={resetToDefaults}> - Reset to default - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} - -function ShortcutItem({ - shortcut, - isRecording, - onStartRecording, -}: { - shortcut: KeyboardShortcut; - isRecording: boolean; - onStartRecording: (params: { shortcut: KeyboardShortcut }) => void; -}) { - const displayKeys = shortcut.keys.filter((key: string) => { - if ( - key.includes("Cmd") && - shortcut.keys.includes(key.replace("Cmd", "Ctrl")) - ) - return false; - - return true; - }); - - return ( - <div className="flex items-center justify-between"> - <div className="flex items-center gap-3"> - {shortcut.icon && ( - <div className="text-muted-foreground">{shortcut.icon}</div> - )} - <span className="text-sm">{shortcut.description}</span> - </div> - <div className="flex items-center gap-2"> - {displayKeys.map((key: string, index: number) => ( - <div key={key} className="flex items-center gap-2"> - <div className="flex items-center gap-1"> - {key.split("+").map((keyPart: string, partIndex: number) => { - const keyId = `${shortcut.id}-${index}-${partIndex}`; - return ( - <EditableShortcutKey - key={keyId} - isRecording={isRecording} - onStartRecording={() => onStartRecording({ shortcut })} - > - {keyPart} - </EditableShortcutKey> - ); - })} - </div> - {index < displayKeys.length - 1 && ( - <span className="text-muted-foreground text-xs">or</span> - )} - </div> - ))} - </div> - </div> - ); -} - -function EditableShortcutKey({ - children, - isRecording, - onStartRecording, -}: { - children: React.ReactNode; - isRecording: boolean; - onStartRecording: () => void; -}) { - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onStartRecording(); - }; - - return ( - <Button - variant="outline" - size="sm" - onClick={handleClick} - title={ - isRecording ? "Press any key combination..." : "Click to edit shortcut" - } - > - {children} - </Button> - ); -} +"use client";
+
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import {
+ type KeyboardShortcut,
+ useKeyboardShortcutsHelp,
+} from "@/hooks/use-keyboard-shortcuts-help";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+export function ShortcutsDialog({
+ isOpen,
+ onOpenChange,
+}: {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [recordingShortcut, setRecordingShortcut] =
+ useState<KeyboardShortcut | null>(null);
+
+ const {
+ updateKeybinding,
+ removeKeybinding,
+ getKeybindingString,
+ validateKeybinding,
+ getKeybindingsForAction,
+ setIsRecording,
+ resetToDefaults,
+ isRecording,
+ } = useKeybindingsStore();
+
+ const { shortcuts } = useKeyboardShortcutsHelp();
+
+ const categories = Array.from(new Set(shortcuts.map((s) => s.category)));
+
+ useEffect(() => {
+ if (!isRecording || !recordingShortcut) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const keyString = getKeybindingString(e);
+ if (keyString) {
+ const conflict = validateKeybinding(
+ keyString,
+ recordingShortcut.action,
+ );
+ if (conflict) {
+ toast.error(
+ `Key "${keyString}" is already bound to "${conflict.existingAction}"`,
+ );
+ setRecordingShortcut(null);
+ return;
+ }
+
+ const oldKeys = getKeybindingsForAction(recordingShortcut.action);
+ for (const key of oldKeys) {
+ removeKeybinding(key);
+ }
+
+ updateKeybinding(keyString, recordingShortcut.action);
+
+ setIsRecording(false);
+ setRecordingShortcut(null);
+ }
+ };
+
+ const handleClickOutside = () => {
+ setRecordingShortcut(null);
+ setIsRecording(false);
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("click", handleClickOutside);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("click", handleClickOutside);
+ };
+ }, [
+ recordingShortcut,
+ getKeybindingString,
+ updateKeybinding,
+ removeKeybinding,
+ validateKeybinding,
+ getKeybindingsForAction,
+ setIsRecording,
+ isRecording,
+ ]);
+
+ const handleStartRecording = (shortcut: KeyboardShortcut) => {
+ setRecordingShortcut(shortcut);
+ setIsRecording(true);
+ };
+
+ return (
+ <Dialog open={isOpen} onOpenChange={onOpenChange}>
+ <DialogContent className="flex max-h-[80vh] max-w-2xl flex-col p-0">
+ <DialogHeader>
+ <DialogTitle>Keyboard shortcuts</DialogTitle>
+ </DialogHeader>
+
+ <DialogBody className="scrollbar-thin grow overflow-y-auto">
+ <div className="flex flex-col gap-6">
+ {categories.map((category) => (
+ <div key={category} className="flex flex-col gap-1">
+ <h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
+ {category}
+ </h3>
+ <div className="flex flex-col gap-1">
+ {shortcuts
+ .filter((shortcut) => shortcut.category === category)
+ .map((shortcut) => (
+ <ShortcutItem
+ key={shortcut.action}
+ shortcut={shortcut}
+ isRecording={
+ shortcut.action === recordingShortcut?.action
+ }
+ onStartRecording={() => handleStartRecording(shortcut)}
+ />
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+ </DialogBody>
+ <DialogFooter>
+ <Button variant="destructive" onClick={resetToDefaults}>
+ Reset to default
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
+function ShortcutItem({
+ shortcut,
+ isRecording,
+ onStartRecording,
+}: {
+ shortcut: KeyboardShortcut;
+ isRecording: boolean;
+ onStartRecording: (params: { shortcut: KeyboardShortcut }) => void;
+}) {
+ const displayKeys = shortcut.keys.filter((key: string) => {
+ if (
+ key.includes("Cmd") &&
+ shortcut.keys.includes(key.replace("Cmd", "Ctrl"))
+ )
+ return false;
+
+ return true;
+ });
+
+ return (
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ {shortcut.icon && (
+ <div className="text-muted-foreground">{shortcut.icon}</div>
+ )}
+ <span className="text-sm">{shortcut.description}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ {displayKeys.map((key: string, index: number) => (
+ <div key={key} className="flex items-center gap-2">
+ <div className="flex items-center gap-1">
+ {key.split("+").map((keyPart: string, partIndex: number) => {
+ const keyId = `${shortcut.id}-${index}-${partIndex}`;
+ return (
+ <EditableShortcutKey
+ key={keyId}
+ isRecording={isRecording}
+ onStartRecording={() => onStartRecording({ shortcut })}
+ >
+ {keyPart}
+ </EditableShortcutKey>
+ );
+ })}
+ </div>
+ {index < displayKeys.length - 1 && (
+ <span className="text-muted-foreground text-xs">or</span>
+ )}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+function EditableShortcutKey({
+ children,
+ isRecording,
+ onStartRecording,
+}: {
+ children: React.ReactNode;
+ isRecording: boolean;
+ onStartRecording: () => void;
+}) {
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onStartRecording();
+ };
+
+ return (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleClick}
+ title={
+ isRecording ? "Press any key combination..." : "Click to edit shortcut"
+ }
+ >
+ {children}
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx index d391928c..0e854a93 100644 --- a/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/storage-persistence-dialog.tsx @@ -1,41 +1,41 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogBody, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { useStoragePersistence } from "@/hooks/use-storage-persistence"; - -export function StoragePersistenceDialog() { - const { showDialog, onConfirm, onDismiss } = useStoragePersistence(); - - return ( - <Dialog open={showDialog} onOpenChange={(open) => !open && onDismiss()}> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>Don't lose your projects</DialogTitle> - </DialogHeader> - <DialogBody> - <p className="text-base text-muted-foreground"> - Your browser can automatically delete your projects when storage - runs low. - </p> - <p className="text-base text-muted-foreground"> - Allow OpenCut to protect them? - </p> - </DialogBody> - <DialogFooter> - <Button variant="outline" onClick={onDismiss}> - Not now - </Button> - <Button onClick={onConfirm}>Allow</Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} +"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { useStoragePersistence } from "@/hooks/use-storage-persistence";
+
+export function StoragePersistenceDialog() {
+ const { showDialog, onConfirm, onDismiss } = useStoragePersistence();
+
+ return (
+ <Dialog open={showDialog} onOpenChange={(open) => !open && onDismiss()}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>Don't lose your projects</DialogTitle>
+ </DialogHeader>
+ <DialogBody>
+ <p className="text-base text-muted-foreground">
+ Your browser can automatically delete your projects when storage
+ runs low.
+ </p>
+ <p className="text-base text-muted-foreground">
+ Allow OpenCut to protect them?
+ </p>
+ </DialogBody>
+ <DialogFooter>
+ <Button variant="outline" onClick={onDismiss}>
+ Not now
+ </Button>
+ <Button onClick={onConfirm}>Allow</Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index 8dd730b5..460e8ed6 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -1,245 +1,245 @@ -"use client"; - -import { Button } from "../ui/button"; -import { useRef, useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import Link from "next/link"; -import { RenameProjectDialog } from "./dialogs/rename-project-dialog"; -import { DeleteProjectDialog } from "./dialogs/delete-project-dialog"; -import { useRouter } from "next/navigation"; -import { FaDiscord } from "react-icons/fa6"; -import { ExportButton } from "./export-button"; -import { ThemeToggle } from "../theme-toggle"; -import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; -import { toast } from "sonner"; -import { useEditor } from "@/hooks/use-editor"; -import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { ShortcutsDialog } from "./dialogs/shortcuts-dialog"; -import Image from "next/image"; -import { cn } from "@/utils/ui"; - -export function EditorHeader() { - return ( - <header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5"> - <div className="flex items-center gap-1"> - <ProjectDropdown /> - <EditableProjectName /> - </div> - <nav className="flex items-center gap-2"> - <ExportButton /> - <ThemeToggle /> - </nav> - </header> - ); -} - -function ProjectDropdown() { - const [openDialog, setOpenDialog] = useState< - "delete" | "rename" | "shortcuts" | null - >(null); - const [isExiting, setIsExiting] = useState(false); - const router = useRouter(); - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - - const handleExit = async () => { - if (isExiting) return; - setIsExiting(true); - - try { - await editor.project.prepareExit(); - editor.project.closeProject(); - } catch (error) { - console.error("Failed to prepare project exit:", error); - } finally { - editor.project.closeProject(); - router.push("/projects"); - } - }; - - const handleSaveProjectName = async (newName: string) => { - if ( - activeProject && - newName.trim() && - newName !== activeProject.metadata.name - ) { - try { - await editor.project.renameProject({ - id: activeProject.metadata.id, - name: newName.trim(), - }); - } catch (error) { - toast.error("Failed to rename project", { - description: - error instanceof Error ? error.message : "Please try again", - }); - } finally { - setOpenDialog(null); - } - } - }; - - const handleDeleteProject = async () => { - if (activeProject) { - try { - await editor.project.deleteProjects({ - ids: [activeProject.metadata.id], - }); - router.push("/projects"); - } catch (error) { - toast.error("Failed to delete project", { - description: - error instanceof Error ? error.message : "Please try again", - }); - } finally { - setOpenDialog(null); - } - } - }; - - return ( - <> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" size="icon" className="p-1 rounded-sm size-8"> - <Image - src={DEFAULT_LOGO_URL} - alt="Project thumbnail" - width={32} - height={32} - className="invert dark:invert-0 size-5" - /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start" className="z-100 w-44"> - <DropdownMenuItem - onClick={handleExit} - disabled={isExiting} - icon={<HugeiconsIcon icon={Logout05Icon} />} - > - Exit project - </DropdownMenuItem> - - <DropdownMenuItem - onClick={() => setOpenDialog("shortcuts")} - icon={<HugeiconsIcon icon={CommandIcon} />} - > - Shortcuts - </DropdownMenuItem> - - <DropdownMenuSeparator /> - - <DropdownMenuItem asChild icon={<FaDiscord className="size-4!" />}> - <Link - href={SOCIAL_LINKS.discord} - target="_blank" - rel="noopener noreferrer" - > - Discord - </Link> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - <RenameProjectDialog - isOpen={openDialog === "rename"} - onOpenChange={(isOpen) => setOpenDialog(isOpen ? "rename" : null)} - onConfirm={(newName) => handleSaveProjectName(newName)} - projectName={activeProject?.metadata.name || ""} - /> - <DeleteProjectDialog - isOpen={openDialog === "delete"} - onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)} - onConfirm={handleDeleteProject} - projectNames={[activeProject?.metadata.name || ""]} - /> - <ShortcutsDialog - isOpen={openDialog === "shortcuts"} - onOpenChange={(isOpen) => setOpenDialog(isOpen ? "shortcuts" : null)} - /> - </> - ); -} - -function EditableProjectName() { - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - const [isEditing, setIsEditing] = useState(false); - const inputRef = useRef<HTMLInputElement>(null); - const originalNameRef = useRef(""); - - const projectName = activeProject?.metadata.name || ""; - - const startEditing = () => { - if (isEditing) return; - originalNameRef.current = projectName; - setIsEditing(true); - - requestAnimationFrame(() => { - inputRef.current?.select(); - }); - }; - - const saveEdit = async () => { - if (!inputRef.current || !activeProject) return; - const newName = inputRef.current.value.trim(); - setIsEditing(false); - - if (!newName) { - inputRef.current.value = originalNameRef.current; - return; - } - - if (newName !== originalNameRef.current) { - try { - await editor.project.renameProject({ - id: activeProject.metadata.id, - name: newName, - }); - } catch (error) { - toast.error("Failed to rename project", { - description: - error instanceof Error ? error.message : "Please try again", - }); - } - } - }; - - const handleKeyDown = (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - inputRef.current?.blur(); - } else if (event.key === "Escape") { - event.preventDefault(); - if (inputRef.current) { - inputRef.current.value = originalNameRef.current; - inputRef.current.setSelectionRange(0, 0); - } - setIsEditing(false); - inputRef.current?.blur(); - } - }; - - return ( - <input - ref={inputRef} - type="text" - defaultValue={projectName} - readOnly={!isEditing} - onClick={startEditing} - onBlur={saveEdit} - onKeyDown={handleKeyDown} - style={{ fieldSizing: "content" }} - className={cn( - "text-[0.9rem] h-8 px-2 py-1 rounded-sm bg-transparent outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground", - isEditing && "ring-1 ring-ring cursor-text hover:bg-transparent", - )} - /> - ); -} +"use client";
+
+import { Button } from "../ui/button";
+import { useRef, useState } from "react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../ui/dropdown-menu";
+import Link from "next/link";
+import { RenameProjectDialog } from "./dialogs/rename-project-dialog";
+import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
+import { useRouter } from "next/navigation";
+import { FaDiscord } from "react-icons/fa6";
+import { ExportButton } from "./export-button";
+import { ThemeToggle } from "../theme-toggle";
+import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
+import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
+import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
+import Image from "next/image";
+import { cn } from "@/utils/ui";
+
+export function EditorHeader() {
+ return (
+ <header className="bg-background flex h-[3.4rem] items-center justify-between px-3 pt-0.5">
+ <div className="flex items-center gap-1">
+ <ProjectDropdown />
+ <EditableProjectName />
+ </div>
+ <nav className="flex items-center gap-2">
+ <ExportButton />
+ <ThemeToggle />
+ </nav>
+ </header>
+ );
+}
+
+function ProjectDropdown() {
+ const [openDialog, setOpenDialog] = useState<
+ "delete" | "rename" | "shortcuts" | null
+ >(null);
+ const [isExiting, setIsExiting] = useState(false);
+ const router = useRouter();
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+
+ const handleExit = async () => {
+ if (isExiting) return;
+ setIsExiting(true);
+
+ try {
+ await editor.project.prepareExit();
+ editor.project.closeProject();
+ } catch (error) {
+ console.error("Failed to prepare project exit:", error);
+ } finally {
+ editor.project.closeProject();
+ router.push("/projects");
+ }
+ };
+
+ const handleSaveProjectName = async (newName: string) => {
+ if (
+ activeProject &&
+ newName.trim() &&
+ newName !== activeProject.metadata.name
+ ) {
+ try {
+ await editor.project.renameProject({
+ id: activeProject.metadata.id,
+ name: newName.trim(),
+ });
+ } catch (error) {
+ toast.error("Failed to rename project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ } finally {
+ setOpenDialog(null);
+ }
+ }
+ };
+
+ const handleDeleteProject = async () => {
+ if (activeProject) {
+ try {
+ await editor.project.deleteProjects({
+ ids: [activeProject.metadata.id],
+ });
+ router.push("/projects");
+ } catch (error) {
+ toast.error("Failed to delete project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ } finally {
+ setOpenDialog(null);
+ }
+ }
+ };
+
+ return (
+ <>
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon" className="p-1 rounded-sm size-8">
+ <Image
+ src={DEFAULT_LOGO_URL}
+ alt="Project thumbnail"
+ width={32}
+ height={32}
+ className="invert dark:invert-0 size-5"
+ />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start" className="z-100 w-44">
+ <DropdownMenuItem
+ onClick={handleExit}
+ disabled={isExiting}
+ icon={<HugeiconsIcon icon={Logout05Icon} />}
+ >
+ Exit project
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onClick={() => setOpenDialog("shortcuts")}
+ icon={<HugeiconsIcon icon={CommandIcon} />}
+ >
+ Shortcuts
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem asChild icon={<FaDiscord className="size-4!" />}>
+ <Link
+ href={SOCIAL_LINKS.discord}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ Discord
+ </Link>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <RenameProjectDialog
+ isOpen={openDialog === "rename"}
+ onOpenChange={(isOpen) => setOpenDialog(isOpen ? "rename" : null)}
+ onConfirm={(newName) => handleSaveProjectName(newName)}
+ projectName={activeProject?.metadata.name || ""}
+ />
+ <DeleteProjectDialog
+ isOpen={openDialog === "delete"}
+ onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)}
+ onConfirm={handleDeleteProject}
+ projectNames={[activeProject?.metadata.name || ""]}
+ />
+ <ShortcutsDialog
+ isOpen={openDialog === "shortcuts"}
+ onOpenChange={(isOpen) => setOpenDialog(isOpen ? "shortcuts" : null)}
+ />
+ </>
+ );
+}
+
+function EditableProjectName() {
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const [isEditing, setIsEditing] = useState(false);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const originalNameRef = useRef("");
+
+ const projectName = activeProject?.metadata.name || "";
+
+ const startEditing = () => {
+ if (isEditing) return;
+ originalNameRef.current = projectName;
+ setIsEditing(true);
+
+ requestAnimationFrame(() => {
+ inputRef.current?.select();
+ });
+ };
+
+ const saveEdit = async () => {
+ if (!inputRef.current || !activeProject) return;
+ const newName = inputRef.current.value.trim();
+ setIsEditing(false);
+
+ if (!newName) {
+ inputRef.current.value = originalNameRef.current;
+ return;
+ }
+
+ if (newName !== originalNameRef.current) {
+ try {
+ await editor.project.renameProject({
+ id: activeProject.metadata.id,
+ name: newName,
+ });
+ } catch (error) {
+ toast.error("Failed to rename project", {
+ description:
+ error instanceof Error ? error.message : "Please try again",
+ });
+ }
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ inputRef.current?.blur();
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ if (inputRef.current) {
+ inputRef.current.value = originalNameRef.current;
+ inputRef.current.setSelectionRange(0, 0);
+ }
+ setIsEditing(false);
+ inputRef.current?.blur();
+ }
+ };
+
+ return (
+ <input
+ ref={inputRef}
+ type="text"
+ defaultValue={projectName}
+ readOnly={!isEditing}
+ onClick={startEditing}
+ onBlur={saveEdit}
+ onKeyDown={handleKeyDown}
+ style={{ fieldSizing: "content" }}
+ className={cn(
+ "text-[0.9rem] h-8 px-2 py-1 rounded-sm bg-transparent outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground",
+ isEditing && "ring-1 ring-ring cursor-text hover:bg-transparent",
+ )}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index ec39e7e6..3fa4af7a 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -1,336 +1,336 @@ -"use client"; - -import { useState } from "react"; -import { TransitionTopIcon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Progress } from "@/components/ui/progress"; -import { Checkbox } from "@/components/ui/checkbox"; -import { cn } from "@/utils/ui"; -import { - getExportMimeType, - getExportFileExtension, - downloadBuffer, -} from "@/lib/export"; -import { Check, Copy, Download, RotateCcw } from "lucide-react"; -import { - EXPORT_FORMAT_VALUES, - EXPORT_QUALITY_VALUES, - type ExportFormat, - type ExportQuality, -} from "@/lib/export"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; - -function isExportFormat(value: string): value is ExportFormat { - return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); -} - -function isExportQuality(value: string): value is ExportQuality { - return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value); -} - -export function ExportButton() { - const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActiveOrNull()); - const hasProject = !!activeProject; - - const handlePopoverOpenChange = ({ open }: { open: boolean }) => { - if (!open) { - editor.project.cancelExport(); - editor.project.clearExportState(); - } - setIsExportPopoverOpen(open); - }; - - return ( - <Popover - open={isExportPopoverOpen} - onOpenChange={(open) => handlePopoverOpenChange({ open })} - > - <PopoverTrigger asChild> - <button - type="button" - className={cn( - "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white", - hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50", - )} - onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined} - disabled={!hasProject} - onKeyDown={(event) => { - if (hasProject && (event.key === "Enter" || event.key === " ")) { - event.preventDefault(); - setIsExportPopoverOpen(true); - } - }} - > - <div className="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]"> - <HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" /> - <span className="z-50 text-[0.875rem]">Export</span> - <div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50"> - <div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div> - </div> - </div> - </button> - </PopoverTrigger> - {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />} - </Popover> - ); -} - -function ExportPopover({ - onOpenChange, -}: { - onOpenChange: (open: boolean) => void; -}) { - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - const exportState = useEditor((e) => e.project.getExportState()); - const { isExporting, progress, result: exportResult } = exportState; - const [format, setFormat] = useState<ExportFormat>( - DEFAULT_EXPORT_OPTIONS.format, - ); - const [quality, setQuality] = useState<ExportQuality>( - DEFAULT_EXPORT_OPTIONS.quality, - ); - const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>( - DEFAULT_EXPORT_OPTIONS.includeAudio ?? true, - ); - - const handleExport = async () => { - if (!activeProject) return; - - const result = await editor.project.export({ - options: { - format, - quality, - fps: activeProject.settings.fps, - includeAudio: shouldIncludeAudio, - }, - }); - - if (result.cancelled) { - editor.project.clearExportState(); - return; - } - - if (result.success && result.buffer) { - downloadBuffer({ - buffer: result.buffer, - filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`, - mimeType: getExportMimeType({ format }), - }); - - editor.project.clearExportState(); - onOpenChange(false); - } - }; - - const handleCancel = () => { - editor.project.cancelExport(); - }; - - return ( - <PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0"> - {exportResult && !exportResult.success ? ( - <ExportError - error={exportResult.error || "Unknown error occurred"} - onRetry={handleExport} - /> - ) : ( - <> - <div className="flex items-center justify-between p-3 border-b"> - <h3 className="font-medium text-sm"> - {isExporting ? "Exporting project" : "Export project"} - </h3> - </div> - - <div className="flex flex-col gap-4"> - {!isExporting && ( - <> - <div className="flex flex-col"> - <Section - collapsible - defaultOpen={false} - showTopBorder={false} - > - <SectionHeader> - <SectionTitle>Format</SectionTitle> - </SectionHeader> - <SectionContent> - <RadioGroup - value={format} - onValueChange={(value) => { - if (isExportFormat(value)) { - setFormat(value); - } - }} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="mp4" id="mp4" /> - <Label htmlFor="mp4"> - MP4 (H.264) - Better compatibility - </Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="webm" id="webm" /> - <Label htmlFor="webm"> - WebM (VP9) - Smaller file size - </Label> - </div> - </RadioGroup> - </SectionContent> - </Section> - - <Section collapsible defaultOpen={false}> - <SectionHeader> - <SectionTitle>Quality</SectionTitle> - </SectionHeader> - <SectionContent> - <RadioGroup - value={quality} - onValueChange={(value) => { - if (isExportQuality(value)) { - setQuality(value); - } - }} - > - <div className="flex items-center space-x-2"> - <RadioGroupItem value="low" id="low" /> - <Label htmlFor="low">Low - Smallest file size</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="medium" id="medium" /> - <Label htmlFor="medium">Medium - Balanced</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="high" id="high" /> - <Label htmlFor="high">High - Recommended</Label> - </div> - <div className="flex items-center space-x-2"> - <RadioGroupItem value="very_high" id="very_high" /> - <Label htmlFor="very_high"> - Very high - Largest file size - </Label> - </div> - </RadioGroup> - </SectionContent> - </Section> - - <Section collapsible defaultOpen={false}> - <SectionHeader> - <SectionTitle>Audio</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-center space-x-2"> - <Checkbox - id="include-audio" - checked={shouldIncludeAudio} - onCheckedChange={(checked) => - setShouldIncludeAudio(!!checked) - } - /> - <Label htmlFor="include-audio"> - Include audio in export - </Label> - </div> - </SectionContent> - </Section> - </div> - - <div className="p-3 pt-0"> - <Button onClick={handleExport} className="w-full gap-2"> - <Download className="size-4" /> - Export - </Button> - </div> - </> - )} - - {isExporting && ( - <div className="space-y-4 p-3"> - <div className="flex flex-col gap-2"> - <div className="flex items-center justify-between text-center"> - <p className="text-muted-foreground text-sm"> - {Math.round(progress * 100)}% - </p> - <p className="text-muted-foreground text-sm">100%</p> - </div> - <Progress value={progress * 100} className="w-full" /> - </div> - - <Button - variant="outline" - className="w-full rounded-md" - onClick={handleCancel} - > - Cancel - </Button> - </div> - )} - </div> - </> - )} - </PopoverContent> - ); -} - -function ExportError({ - error, - onRetry, -}: { - error: string; - onRetry: () => void; -}) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - await navigator.clipboard.writeText(error); - setCopied(true); - setTimeout(() => setCopied(false), 1000); - }; - - return ( - <div className="space-y-4 p-3"> - <div className="flex flex-col gap-1.5"> - <p className="text-destructive text-sm font-medium">Export failed</p> - <p className="text-muted-foreground text-xs">{error}</p> - </div> - - <div className="flex gap-2"> - <Button - variant="outline" - size="sm" - className="h-8 flex-1 text-xs" - onClick={handleCopy} - > - {copied ? <Check className="text-constructive" /> : <Copy />} - Copy - </Button> - <Button - variant="outline" - size="sm" - className="h-8 flex-1 text-xs" - onClick={onRetry} - > - <RotateCcw /> - Retry - </Button> - </div> - </div> - ); -} +"use client";
+
+import { useState } from "react";
+import { TransitionTopIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Progress } from "@/components/ui/progress";
+import { Checkbox } from "@/components/ui/checkbox";
+import { cn } from "@/utils/ui";
+import {
+ getExportMimeType,
+ getExportFileExtension,
+ downloadBuffer,
+} from "@/lib/export";
+import { Check, Copy, Download, RotateCcw } from "lucide-react";
+import {
+ EXPORT_FORMAT_VALUES,
+ EXPORT_QUALITY_VALUES,
+ type ExportFormat,
+ type ExportQuality,
+} from "@/lib/export";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
+
+function isExportFormat(value: string): value is ExportFormat {
+ return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
+}
+
+function isExportQuality(value: string): value is ExportQuality {
+ return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
+}
+
+export function ExportButton() {
+ const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActiveOrNull());
+ const hasProject = !!activeProject;
+
+ const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
+ if (!open) {
+ editor.project.cancelExport();
+ editor.project.clearExportState();
+ }
+ setIsExportPopoverOpen(open);
+ };
+
+ return (
+ <Popover
+ open={isExportPopoverOpen}
+ onOpenChange={(open) => handlePopoverOpenChange({ open })}
+ >
+ <PopoverTrigger asChild>
+ <button
+ type="button"
+ className={cn(
+ "flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
+ hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
+ )}
+ onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
+ disabled={!hasProject}
+ onKeyDown={(event) => {
+ if (hasProject && (event.key === "Enter" || event.key === " ")) {
+ event.preventDefault();
+ setIsExportPopoverOpen(true);
+ }
+ }}
+ >
+ <div className="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
+ <HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
+ <span className="z-50 text-[0.875rem]">Export</span>
+ <div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50">
+ <div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
+ </div>
+ </div>
+ </button>
+ </PopoverTrigger>
+ {hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
+ </Popover>
+ );
+}
+
+function ExportPopover({
+ onOpenChange,
+}: {
+ onOpenChange: (open: boolean) => void;
+}) {
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const exportState = useEditor((e) => e.project.getExportState());
+ const { isExporting, progress, result: exportResult } = exportState;
+ const [format, setFormat] = useState<ExportFormat>(
+ DEFAULT_EXPORT_OPTIONS.format,
+ );
+ const [quality, setQuality] = useState<ExportQuality>(
+ DEFAULT_EXPORT_OPTIONS.quality,
+ );
+ const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
+ DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
+ );
+
+ const handleExport = async () => {
+ if (!activeProject) return;
+
+ const result = await editor.project.export({
+ options: {
+ format,
+ quality,
+ fps: activeProject.settings.fps,
+ includeAudio: shouldIncludeAudio,
+ },
+ });
+
+ if (result.cancelled) {
+ editor.project.clearExportState();
+ return;
+ }
+
+ if (result.success && result.buffer) {
+ downloadBuffer({
+ buffer: result.buffer,
+ filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
+ mimeType: getExportMimeType({ format }),
+ });
+
+ editor.project.clearExportState();
+ onOpenChange(false);
+ }
+ };
+
+ const handleCancel = () => {
+ editor.project.cancelExport();
+ };
+
+ return (
+ <PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
+ {exportResult && !exportResult.success ? (
+ <ExportError
+ error={exportResult.error || "Unknown error occurred"}
+ onRetry={handleExport}
+ />
+ ) : (
+ <>
+ <div className="flex items-center justify-between p-3 border-b">
+ <h3 className="font-medium text-sm">
+ {isExporting ? "Exporting project" : "Export project"}
+ </h3>
+ </div>
+
+ <div className="flex flex-col gap-4">
+ {!isExporting && (
+ <>
+ <div className="flex flex-col">
+ <Section
+ collapsible
+ defaultOpen={false}
+ showTopBorder={false}
+ >
+ <SectionHeader>
+ <SectionTitle>Format</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <RadioGroup
+ value={format}
+ onValueChange={(value) => {
+ if (isExportFormat(value)) {
+ setFormat(value);
+ }
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="mp4" id="mp4" />
+ <Label htmlFor="mp4">
+ MP4 (H.264) - Better compatibility
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="webm" id="webm" />
+ <Label htmlFor="webm">
+ WebM (VP9) - Smaller file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </SectionContent>
+ </Section>
+
+ <Section collapsible defaultOpen={false}>
+ <SectionHeader>
+ <SectionTitle>Quality</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <RadioGroup
+ value={quality}
+ onValueChange={(value) => {
+ if (isExportQuality(value)) {
+ setQuality(value);
+ }
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="low" id="low" />
+ <Label htmlFor="low">Low - Smallest file size</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="medium" id="medium" />
+ <Label htmlFor="medium">Medium - Balanced</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="high" id="high" />
+ <Label htmlFor="high">High - Recommended</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="very_high" id="very_high" />
+ <Label htmlFor="very_high">
+ Very high - Largest file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </SectionContent>
+ </Section>
+
+ <Section collapsible defaultOpen={false}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="include-audio"
+ checked={shouldIncludeAudio}
+ onCheckedChange={(checked) =>
+ setShouldIncludeAudio(!!checked)
+ }
+ />
+ <Label htmlFor="include-audio">
+ Include audio in export
+ </Label>
+ </div>
+ </SectionContent>
+ </Section>
+ </div>
+
+ <div className="p-3 pt-0">
+ <Button onClick={handleExport} className="w-full gap-2">
+ <Download className="size-4" />
+ Export
+ </Button>
+ </div>
+ </>
+ )}
+
+ {isExporting && (
+ <div className="space-y-4 p-3">
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center justify-between text-center">
+ <p className="text-muted-foreground text-sm">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-muted-foreground text-sm">100%</p>
+ </div>
+ <Progress value={progress * 100} className="w-full" />
+ </div>
+
+ <Button
+ variant="outline"
+ className="w-full rounded-md"
+ onClick={handleCancel}
+ >
+ Cancel
+ </Button>
+ </div>
+ )}
+ </div>
+ </>
+ )}
+ </PopoverContent>
+ );
+}
+
+function ExportError({
+ error,
+ onRetry,
+}: {
+ error: string;
+ onRetry: () => void;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(error);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1000);
+ };
+
+ return (
+ <div className="space-y-4 p-3">
+ <div className="flex flex-col gap-1.5">
+ <p className="text-destructive text-sm font-medium">Export failed</p>
+ <p className="text-muted-foreground text-xs">{error}</p>
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 flex-1 text-xs"
+ onClick={handleCopy}
+ >
+ {copied ? <Check className="text-constructive" /> : <Copy />}
+ Copy
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 flex-1 text-xs"
+ onClick={onRetry}
+ >
+ <RotateCcw />
+ Retry
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/mobile-gate.tsx b/apps/web/src/components/editor/mobile-gate.tsx index 6483e438..3dde2fa7 100644 --- a/apps/web/src/components/editor/mobile-gate.tsx +++ b/apps/web/src/components/editor/mobile-gate.tsx @@ -1,72 +1,72 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Link from "next/link"; -import { Button } from "../ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"; -import { useRouter } from "next/navigation"; - -const STORAGE_KEY = "mobile-acknowledged"; - -interface MobileGateProps { - children: React.ReactNode; -} - -export function MobileGate({ children }: MobileGateProps) { - const router = useRouter(); - const [show, setShow] = useState<boolean | null>(null); - - useEffect(() => { - const isMobile = window.innerWidth < 1024; - const acknowledged = localStorage.getItem(STORAGE_KEY) === "true"; - setShow(isMobile && !acknowledged); - }, []); - - if (show === null) return null; - if (!show) return <>{children}</>; - - const handleContinue = () => { - localStorage.setItem(STORAGE_KEY, "true"); - setShow(false); - }; - - const handleGoBack = () => { - router.back(); - }; - - return ( - <div className="bg-background relative flex h-screen w-screen flex-col overflow-hidden"> - <Button - variant="text" - className="absolute top-6 left-6 flex items-center gap-1 text-muted-foreground" - onClick={handleGoBack} - > - <HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" /> - <span className=" text-sm">Go back</span> - </Button> - - <div className="flex flex-1 flex-col justify-center gap-5 px-7"> - <div className="flex flex-col gap-3"> - <h1 className="text-foreground text-3xl font-bold tracking-tight"> - Desktop only (for now) - </h1> - <p className="text-muted-foreground text-sm leading-relaxed"> - OpenCut isn't optimized for mobile or iPad yet. Things will break - and the layout will be a mess. Come back on a desktop for the real - experience. - </p> - </div> - <div className="flex items-center gap-3"> - <Button onClick={handleContinue}>Take a look anyway</Button> - <Button variant="ghost" asChild> - <Link href="/roadmap" className="flex items-center gap-1"> - Roadmap - <HugeiconsIcon icon={ArrowRight01Icon} size={14} /> - </Link> - </Button> - </div> - </div> - </div> - ); -} +"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { Button } from "../ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons";
+import { useRouter } from "next/navigation";
+
+const STORAGE_KEY = "mobile-acknowledged";
+
+interface MobileGateProps {
+ children: React.ReactNode;
+}
+
+export function MobileGate({ children }: MobileGateProps) {
+ const router = useRouter();
+ const [show, setShow] = useState<boolean | null>(null);
+
+ useEffect(() => {
+ const isMobile = window.innerWidth < 1024;
+ const acknowledged = localStorage.getItem(STORAGE_KEY) === "true";
+ setShow(isMobile && !acknowledged);
+ }, []);
+
+ if (show === null) return null;
+ if (!show) return <>{children}</>;
+
+ const handleContinue = () => {
+ localStorage.setItem(STORAGE_KEY, "true");
+ setShow(false);
+ };
+
+ const handleGoBack = () => {
+ router.back();
+ };
+
+ return (
+ <div className="bg-background relative flex h-screen w-screen flex-col overflow-hidden">
+ <Button
+ variant="text"
+ className="absolute top-6 left-6 flex items-center gap-1 text-muted-foreground"
+ onClick={handleGoBack}
+ >
+ <HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" />
+ <span className=" text-sm">Go back</span>
+ </Button>
+
+ <div className="flex flex-1 flex-col justify-center gap-5 px-7">
+ <div className="flex flex-col gap-3">
+ <h1 className="text-foreground text-3xl font-bold tracking-tight">
+ Desktop only (for now)
+ </h1>
+ <p className="text-muted-foreground text-sm leading-relaxed">
+ OpenCut isn't optimized for mobile or iPad yet. Things will break
+ and the layout will be a mess. Come back on a desktop for the real
+ experience.
+ </p>
+ </div>
+ <div className="flex items-center gap-3">
+ <Button onClick={handleContinue}>Take a look anyway</Button>
+ <Button variant="ghost" asChild>
+ <Link href="/roadmap" className="flex items-center gap-1">
+ Roadmap
+ <HugeiconsIcon icon={ArrowRight01Icon} size={14} />
+ </Link>
+ </Button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx index 9d9ae903..00ccc6e4 100644 --- a/apps/web/src/components/editor/panels/assets/drag-overlay.tsx +++ b/apps/web/src/components/editor/panels/assets/drag-overlay.tsx @@ -1,61 +1,61 @@ -import { HugeiconsIcon } from "@hugeicons/react"; -import { UploadIcon } from "@hugeicons/core-free-icons"; - -interface MediaDragOverlayProps { - isVisible: boolean; - isProcessing?: boolean; - progress?: number; - onClick?: () => void; -} - -export function MediaDragOverlay({ - isVisible, - isProcessing = false, - progress = 0, - onClick, -}: MediaDragOverlayProps) { - if (!isVisible) return null; - - const handleClick = ({ - event, - }: { - event: React.MouseEvent<HTMLButtonElement>; - }) => { - if (isProcessing || !onClick) return; - event.preventDefault(); - event.stopPropagation(); - onClick(); - }; - - return ( - <button - className="bg-foreground/5 hover:bg-foreground/10 flex flex-col items-center justify-center gap-4 rounded-lg p-8 text-center" - type="button" - disabled={isProcessing || !onClick} - onClick={(event) => handleClick({ event })} - > - <div className="flex items-center justify-center"> - <HugeiconsIcon icon={UploadIcon} className="text-foreground size-10" /> - </div> - - <div className="space-y-2"> - <p className="text-muted-foreground max-w-sm text-xs"> - {isProcessing - ? `Processing your files (${progress}%)` - : "Drag and drop videos, photos, and audio files here"} - </p> - </div> - - {isProcessing && ( - <div className="w-full max-w-xs"> - <div className="bg-muted/50 h-2 w-full rounded-full"> - <div - className="bg-primary h-2 rounded-full" - style={{ width: `${progress}%` }} - /> - </div> - </div> - )} - </button> - ); -} +import { HugeiconsIcon } from "@hugeicons/react";
+import { UploadIcon } from "@hugeicons/core-free-icons";
+
+interface MediaDragOverlayProps {
+ isVisible: boolean;
+ isProcessing?: boolean;
+ progress?: number;
+ onClick?: () => void;
+}
+
+export function MediaDragOverlay({
+ isVisible,
+ isProcessing = false,
+ progress = 0,
+ onClick,
+}: MediaDragOverlayProps) {
+ if (!isVisible) return null;
+
+ const handleClick = ({
+ event,
+ }: {
+ event: React.MouseEvent<HTMLButtonElement>;
+ }) => {
+ if (isProcessing || !onClick) return;
+ event.preventDefault();
+ event.stopPropagation();
+ onClick();
+ };
+
+ return (
+ <button
+ className="bg-foreground/5 hover:bg-foreground/10 flex flex-col items-center justify-center gap-4 rounded-lg p-8 text-center"
+ type="button"
+ disabled={isProcessing || !onClick}
+ onClick={(event) => handleClick({ event })}
+ >
+ <div className="flex items-center justify-center">
+ <HugeiconsIcon icon={UploadIcon} className="text-foreground size-10" />
+ </div>
+
+ <div className="space-y-2">
+ <p className="text-muted-foreground max-w-sm text-xs">
+ {isProcessing
+ ? `Processing your files (${progress}%)`
+ : "Drag and drop videos, photos, and audio files here"}
+ </p>
+ </div>
+
+ {isProcessing && (
+ <div className="w-full max-w-xs">
+ <div className="bg-muted/50 h-2 w-full rounded-full">
+ <div
+ className="bg-primary h-2 rounded-full"
+ style={{ width: `${progress}%` }}
+ />
+ </div>
+ </div>
+ )}
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx index 8c9acce8..166c81db 100644 --- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx +++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx @@ -1,239 +1,239 @@ -"use client"; - -import { Plus } from "lucide-react"; -import { type ReactNode, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; -import { AspectRatio } from "@/components/ui/aspect-ratio"; -import { Button } from "@/components/ui/button"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useEditor } from "@/hooks/use-editor"; -import { clearDragData, setDragData } from "@/lib/drag-data"; -import type { TimelineDragData } from "@/lib/timeline/drag"; -import { cn } from "@/utils/ui"; - -export interface DraggableItemProps { - name: string; - preview: ReactNode; - dragData: TimelineDragData; - onDragStart?: ({ e }: { e: React.DragEvent }) => void; - onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void; - aspectRatio?: number; - className?: string; - containerClassName?: string; - shouldShowPlusOnDrag?: boolean; - shouldShowLabel?: boolean; - isRounded?: boolean; - variant?: "card" | "compact"; - isDraggable?: boolean; -} - -export function DraggableItem({ - name, - preview, - dragData, - onDragStart, - onAddToTimeline, - aspectRatio = 16 / 9, - className = "", - containerClassName, - shouldShowPlusOnDrag = true, - shouldShowLabel = true, - isRounded = true, - variant = "card", - isDraggable = true, -}: DraggableItemProps) { - const [isDragging, setIsDragging] = useState(false); - const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 }); - const dragRef = useRef<HTMLDivElement>(null); - const editor = useEditor(); - - const handleAddToTimeline = () => { - onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() }); - }; - - const emptyImg = new window.Image(); - emptyImg.src = - "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs="; - - useEffect(() => { - if (!isDragging) return; - - const handleDragOver = (e: DragEvent) => { - setDragPosition({ x: e.clientX, y: e.clientY }); - }; - - document.addEventListener("dragover", handleDragOver); - - return () => { - document.removeEventListener("dragover", handleDragOver); - }; - }, [isDragging]); - - const handleDragStart = (e: React.DragEvent) => { - e.dataTransfer.setDragImage(emptyImg, 0, 0); - - setDragData({ dataTransfer: e.dataTransfer, dragData }); - e.dataTransfer.effectAllowed = "copy"; - - setDragPosition({ x: e.clientX, y: e.clientY }); - setIsDragging(true); - - onDragStart?.({ e }); - }; - - const handleDragEnd = () => { - setIsDragging(false); - clearDragData(); - }; - - return ( - <> - {variant === "card" ? ( - <div - ref={dragRef} - className={cn("group relative", containerClassName ?? "w-28")} - > - <div - className={cn( - "relative flex h-auto w-full cursor-default flex-col gap-1 p-", - className, - )} - > - <AspectRatio - ratio={aspectRatio} - className={cn( - "bg-accent relative overflow-hidden", - isRounded && "rounded-sm", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - {preview} - {!isDragging && ( - <PlusButton - className="opacity-0 group-hover:opacity-100" - onClick={handleAddToTimeline} - /> - )} - </AspectRatio> - {shouldShowLabel && ( - <span - className="text-muted-foreground w-full truncate text-left text-[0.7rem]" - title={name} - > - <span className="sr-only">{name}</span> - <span aria-hidden="true"> - {name.length > 8 - ? `${name.slice(0, 16)}...${name.slice(-3)}` - : name} - </span> - </span> - )} - </div> - </div> - ) : ( - <div - ref={dragRef} - className={cn("group relative w-full", containerClassName)} - > - <button - type="button" - className={cn( - "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none", - isDraggable && "[&::-webkit-drag-ghost]:opacity-0", - className, - )} - draggable={isDraggable} - onDragStart={isDraggable ? handleDragStart : undefined} - onDragEnd={isDraggable ? handleDragEnd : undefined} - > - <div className="size-6 shrink-0 overflow-hidden rounded-sm"> - {preview} - </div> - <span className="w-full flex-1 truncate text-sm text-left"> - {name} - </span> - </button> - </div> - )} - - {isDraggable && - isDragging && - typeof document !== "undefined" && - createPortal( - <div - className="pointer-events-none fixed z-9999" - style={{ - left: dragPosition.x - 40, - top: dragPosition.y - 40, - }} - > - <div className="w-[80px]"> - <AspectRatio - ratio={1} - className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3" - > - <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover"> - {preview} - </div> - {shouldShowPlusOnDrag && ( - <PlusButton - onClick={handleAddToTimeline} - tooltipText="Add to timeline or drag to position" - /> - )} - </AspectRatio> - </div> - </div>, - document.body, - )} - </> - ); -} - -function PlusButton({ - className, - onClick, - tooltipText, -}: { - className?: string; - onClick?: () => void; - tooltipText?: string; -}) { - const button = ( - <Button - size="icon" - className={cn( - "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5", - className, - )} - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - onClick?.(); - }} - title={tooltipText} - > - <Plus /> - </Button> - ); - - if (tooltipText) { - return ( - <Tooltip> - <TooltipTrigger asChild>{button}</TooltipTrigger> - <TooltipContent> - <p>{tooltipText}</p> - </TooltipContent> - </Tooltip> - ); - } - - return button; -} +"use client";
+
+import { Plus } from "lucide-react";
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { AspectRatio } from "@/components/ui/aspect-ratio";
+import { Button } from "@/components/ui/button";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useEditor } from "@/hooks/use-editor";
+import { clearDragData, setDragData } from "@/lib/drag-data";
+import type { TimelineDragData } from "@/lib/timeline/drag";
+import { cn } from "@/utils/ui";
+
+export interface DraggableItemProps {
+ name: string;
+ preview: ReactNode;
+ dragData: TimelineDragData;
+ onDragStart?: ({ e }: { e: React.DragEvent }) => void;
+ onAddToTimeline?: ({ currentTime }: { currentTime: number }) => void;
+ aspectRatio?: number;
+ className?: string;
+ containerClassName?: string;
+ shouldShowPlusOnDrag?: boolean;
+ shouldShowLabel?: boolean;
+ isRounded?: boolean;
+ variant?: "card" | "compact";
+ isDraggable?: boolean;
+}
+
+export function DraggableItem({
+ name,
+ preview,
+ dragData,
+ onDragStart,
+ onAddToTimeline,
+ aspectRatio = 16 / 9,
+ className = "",
+ containerClassName,
+ shouldShowPlusOnDrag = true,
+ shouldShowLabel = true,
+ isRounded = true,
+ variant = "card",
+ isDraggable = true,
+}: DraggableItemProps) {
+ const [isDragging, setIsDragging] = useState(false);
+ const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
+ const dragRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleAddToTimeline = () => {
+ onAddToTimeline?.({ currentTime: editor.playback.getCurrentTime() });
+ };
+
+ const emptyImg = new window.Image();
+ emptyImg.src =
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=";
+
+ useEffect(() => {
+ if (!isDragging) return;
+
+ const handleDragOver = (e: DragEvent) => {
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ };
+
+ document.addEventListener("dragover", handleDragOver);
+
+ return () => {
+ document.removeEventListener("dragover", handleDragOver);
+ };
+ }, [isDragging]);
+
+ const handleDragStart = (e: React.DragEvent) => {
+ e.dataTransfer.setDragImage(emptyImg, 0, 0);
+
+ setDragData({ dataTransfer: e.dataTransfer, dragData });
+ e.dataTransfer.effectAllowed = "copy";
+
+ setDragPosition({ x: e.clientX, y: e.clientY });
+ setIsDragging(true);
+
+ onDragStart?.({ e });
+ };
+
+ const handleDragEnd = () => {
+ setIsDragging(false);
+ clearDragData();
+ };
+
+ return (
+ <>
+ {variant === "card" ? (
+ <div
+ ref={dragRef}
+ className={cn("group relative", containerClassName ?? "w-28")}
+ >
+ <div
+ className={cn(
+ "relative flex h-auto w-full cursor-default flex-col gap-1 p-",
+ className,
+ )}
+ >
+ <AspectRatio
+ ratio={aspectRatio}
+ className={cn(
+ "bg-accent relative overflow-hidden",
+ isRounded && "rounded-sm",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ {preview}
+ {!isDragging && (
+ <PlusButton
+ className="opacity-0 group-hover:opacity-100"
+ onClick={handleAddToTimeline}
+ />
+ )}
+ </AspectRatio>
+ {shouldShowLabel && (
+ <span
+ className="text-muted-foreground w-full truncate text-left text-[0.7rem]"
+ title={name}
+ >
+ <span className="sr-only">{name}</span>
+ <span aria-hidden="true">
+ {name.length > 8
+ ? `${name.slice(0, 16)}...${name.slice(-3)}`
+ : name}
+ </span>
+ </span>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div
+ ref={dragRef}
+ className={cn("group relative w-full", containerClassName)}
+ >
+ <button
+ type="button"
+ className={cn(
+ "flex h-8 w-full cursor-default items-center gap-3 px-1 outline-none",
+ isDraggable && "[&::-webkit-drag-ghost]:opacity-0",
+ className,
+ )}
+ draggable={isDraggable}
+ onDragStart={isDraggable ? handleDragStart : undefined}
+ onDragEnd={isDraggable ? handleDragEnd : undefined}
+ >
+ <div className="size-6 shrink-0 overflow-hidden rounded-sm">
+ {preview}
+ </div>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
+ </button>
+ </div>
+ )}
+
+ {isDraggable &&
+ isDragging &&
+ typeof document !== "undefined" &&
+ createPortal(
+ <div
+ className="pointer-events-none fixed z-9999"
+ style={{
+ left: dragPosition.x - 40,
+ top: dragPosition.y - 40,
+ }}
+ >
+ <div className="w-[80px]">
+ <AspectRatio
+ ratio={1}
+ className="ring-primary relative overflow-hidden rounded-md shadow-2xl ring-3"
+ >
+ <div className="size-full [&_img]:size-full [&_img]:rounded-none [&_img]:object-cover">
+ {preview}
+ </div>
+ {shouldShowPlusOnDrag && (
+ <PlusButton
+ onClick={handleAddToTimeline}
+ tooltipText="Add to timeline or drag to position"
+ />
+ )}
+ </AspectRatio>
+ </div>
+ </div>,
+ document.body,
+ )}
+ </>
+ );
+}
+
+function PlusButton({
+ className,
+ onClick,
+ tooltipText,
+}: {
+ className?: string;
+ onClick?: () => void;
+ tooltipText?: string;
+}) {
+ const button = (
+ <Button
+ size="icon"
+ className={cn(
+ "bg-background hover:bg-background text-foreground absolute right-2 bottom-2 size-5",
+ className,
+ )}
+ onClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onClick?.();
+ }}
+ title={tooltipText}
+ >
+ <Plus />
+ </Button>
+ );
+
+ if (tooltipText) {
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
+ <TooltipContent>
+ <p>{tooltipText}</p>
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return button;
+}
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx index a4e072f1..da53bed0 100644 --- a/apps/web/src/components/editor/panels/assets/tabbar.tsx +++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx @@ -1,111 +1,111 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/utils/ui"; -import { - TAB_KEYS, - tabs, - useAssetsPanelStore, -} from "@/stores/assets-panel-store"; - -export function TabBar() { - const { activeTab, setActiveTab } = useAssetsPanelStore(); - const [showTopFade, setShowTopFade] = useState(false); - const [showBottomFade, setShowBottomFade] = useState(false); - const scrollRef = useRef<HTMLDivElement>(null); - - const checkScrollPosition = useCallback(() => { - const element = scrollRef.current; - if (!element) return; - - const { scrollTop, scrollHeight, clientHeight } = element; - setShowTopFade(scrollTop > 0); - setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1); - }, []); - - useEffect(() => { - const element = scrollRef.current; - if (!element) return; - - checkScrollPosition(); - element.addEventListener("scroll", checkScrollPosition); - - const resizeObserver = new ResizeObserver(checkScrollPosition); - resizeObserver.observe(element); - - return () => { - element.removeEventListener("scroll", checkScrollPosition); - resizeObserver.disconnect(); - }; - }, [checkScrollPosition]); - - return ( - <div className="relative flex"> - <div - ref={scrollRef} - className="scrollbar-hidden relative flex size-full p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto" - > - {TAB_KEYS.map((tabKey) => { - const tab = tabs[tabKey]; - return ( - <Tooltip key={tabKey} delayDuration={10}> - <TooltipTrigger asChild> - <Button - variant={activeTab === tabKey ? "secondary" : "ghost"} - size="icon" - aria-label={tab.label} - className={cn( - "shrink-0", - "h-8 w-8", - activeTab !== tabKey && "text-muted-foreground", - )} - onClick={() => setActiveTab(tabKey)} - > - <tab.icon /> - </Button> - </TooltipTrigger> - <TooltipContent - side="right" - align="center" - variant="sidebar" - sideOffset={8} - > - <div className="text-foreground text-sm leading-none font-medium"> - {tab.label} - </div> - </TooltipContent> - </Tooltip> - ); - })} - </div> - - <FadeOverlay direction="top" show={showTopFade} /> - <FadeOverlay direction="bottom" show={showBottomFade} /> - </div> - ); -} - -function FadeOverlay({ - direction, - show, -}: { - direction: "top" | "bottom"; - show: boolean; -}) { - return ( - <div - className={cn( - "pointer-events-none absolute right-0 left-0 h-6", - direction === "top" && show - ? "from-background top-0 bg-linear-to-b to-transparent" - : "from-background bottom-0 bg-linear-to-t to-transparent", - )} - /> - ); -} +"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/utils/ui";
+import {
+ TAB_KEYS,
+ tabs,
+ useAssetsPanelStore,
+} from "@/stores/assets-panel-store";
+
+export function TabBar() {
+ const { activeTab, setActiveTab } = useAssetsPanelStore();
+ const [showTopFade, setShowTopFade] = useState(false);
+ const [showBottomFade, setShowBottomFade] = useState(false);
+ const scrollRef = useRef<HTMLDivElement>(null);
+
+ const checkScrollPosition = useCallback(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = element;
+ setShowTopFade(scrollTop > 0);
+ setShowBottomFade(scrollTop < scrollHeight - clientHeight - 1);
+ }, []);
+
+ useEffect(() => {
+ const element = scrollRef.current;
+ if (!element) return;
+
+ checkScrollPosition();
+ element.addEventListener("scroll", checkScrollPosition);
+
+ const resizeObserver = new ResizeObserver(checkScrollPosition);
+ resizeObserver.observe(element);
+
+ return () => {
+ element.removeEventListener("scroll", checkScrollPosition);
+ resizeObserver.disconnect();
+ };
+ }, [checkScrollPosition]);
+
+ return (
+ <div className="relative flex">
+ <div
+ ref={scrollRef}
+ className="scrollbar-hidden relative flex size-full p-1 flex-col items-center justify-start gap-0.5 overflow-y-auto"
+ >
+ {TAB_KEYS.map((tabKey) => {
+ const tab = tabs[tabKey];
+ return (
+ <Tooltip key={tabKey} delayDuration={10}>
+ <TooltipTrigger asChild>
+ <Button
+ variant={activeTab === tabKey ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={tab.label}
+ className={cn(
+ "shrink-0",
+ "h-8 w-8",
+ activeTab !== tabKey && "text-muted-foreground",
+ )}
+ onClick={() => setActiveTab(tabKey)}
+ >
+ <tab.icon />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent
+ side="right"
+ align="center"
+ variant="sidebar"
+ sideOffset={8}
+ >
+ <div className="text-foreground text-sm leading-none font-medium">
+ {tab.label}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ );
+ })}
+ </div>
+
+ <FadeOverlay direction="top" show={showTopFade} />
+ <FadeOverlay direction="bottom" show={showBottomFade} />
+ </div>
+ );
+}
+
+function FadeOverlay({
+ direction,
+ show,
+}: {
+ direction: "top" | "bottom";
+ show: boolean;
+}) {
+ return (
+ <div
+ className={cn(
+ "pointer-events-none absolute right-0 left-0 h-6",
+ direction === "top" && show
+ ? "from-background top-0 bg-linear-to-b to-transparent"
+ : "from-background bottom-0 bg-linear-to-t to-transparent",
+ )}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/base-panel.tsx b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx index 0ece9b19..869304cd 100644 --- a/apps/web/src/components/editor/panels/assets/views/base-panel.tsx +++ b/apps/web/src/components/editor/panels/assets/views/base-panel.tsx @@ -1,57 +1,57 @@ -import { cn } from "@/utils/ui"; - -interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> { - title?: string; - actions?: React.ReactNode; - children: React.ReactNode; - contentClassName?: string; - scrollClassName?: string; - hideHeader?: boolean; - ref?: React.Ref<HTMLDivElement>; - onScroll?: React.UIEventHandler<HTMLDivElement>; - scrollRef?: React.Ref<HTMLDivElement>; -} - -export function PanelView({ - title, - actions, - children, - className, - contentClassName, - scrollClassName, - hideHeader = false, - ref, - onScroll, - scrollRef, - ...rest -}: PanelViewProps) { - return ( - <div - className={cn("relative flex h-full flex-col", className)} - ref={ref} - {...rest} - > - {!hideHeader && ( - <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b"> - {title && ( - <span className="text-muted-foreground text-sm">{title}</span> - )} - {actions} - </div> - )} - <div - className={cn( - "scrollbar-hidden size-full overflow-y-auto", - hideHeader ? "pt-4" : "pt-2", - scrollClassName, - )} - ref={scrollRef} - onScroll={onScroll} - > - <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}> - {children} - </div> - </div> - </div> - ); -} +import { cn } from "@/utils/ui";
+
+interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
+ title?: string;
+ actions?: React.ReactNode;
+ children: React.ReactNode;
+ contentClassName?: string;
+ scrollClassName?: string;
+ hideHeader?: boolean;
+ ref?: React.Ref<HTMLDivElement>;
+ onScroll?: React.UIEventHandler<HTMLDivElement>;
+ scrollRef?: React.Ref<HTMLDivElement>;
+}
+
+export function PanelView({
+ title,
+ actions,
+ children,
+ className,
+ contentClassName,
+ scrollClassName,
+ hideHeader = false,
+ ref,
+ onScroll,
+ scrollRef,
+ ...rest
+}: PanelViewProps) {
+ return (
+ <div
+ className={cn("relative flex h-full flex-col", className)}
+ ref={ref}
+ {...rest}
+ >
+ {!hideHeader && (
+ <div className="bg-background h-11 shrink-0 pl-3 pr-2 flex items-center justify-between border-b">
+ {title && (
+ <span className="text-muted-foreground text-sm">{title}</span>
+ )}
+ {actions}
+ </div>
+ )}
+ <div
+ className={cn(
+ "scrollbar-hidden size-full overflow-y-auto",
+ hideHeader ? "pt-4" : "pt-2",
+ scrollClassName,
+ )}
+ ref={scrollRef}
+ onScroll={onScroll}
+ >
+ <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
+ {children}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index 023e1578..f22c0211 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -1,179 +1,179 @@ -import { Button } from "@/components/ui/button"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useState, useRef } from "react"; -import { extractTimelineAudio } from "@/lib/media/mediabunny"; -import { useEditor } from "@/hooks/use-editor"; -import { - DEFAULT_TRANSCRIPTION_SAMPLE_RATE, - TRANSCRIPTION_LANGUAGES, -} from "@/constants/transcription-constants"; -import type { - TranscriptionLanguage, - TranscriptionProgress, -} from "@/lib/transcription/types"; -import { transcriptionService } from "@/services/transcription/service"; -import { decodeAudioToFloat32 } from "@/lib/media/audio"; -import { buildCaptionChunks } from "@/lib/transcription/caption"; -import { Spinner } from "@/components/ui/spinner"; -import { - Section, - SectionContent, - SectionField, - SectionFields, -} from "@/components/section"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - AddTrackCommand, - BatchCommand, - InsertElementCommand, -} from "@/lib/commands"; - -export function Captions() { - const [selectedLanguage, setSelectedLanguage] = - useState<TranscriptionLanguage>("auto"); - const [isProcessing, setIsProcessing] = useState(false); - const [processingStep, setProcessingStep] = useState(""); - const [error, setError] = useState<string | null>(null); - const containerRef = useRef<HTMLDivElement>(null); - const editor = useEditor(); - - const handleProgress = (progress: TranscriptionProgress) => { - if (progress.status === "loading-model") { - setProcessingStep(`Loading model ${Math.round(progress.progress)}%`); - } else if (progress.status === "transcribing") { - setProcessingStep("Transcribing..."); - } - }; - - const handleGenerateTranscript = async () => { - try { - setIsProcessing(true); - setError(null); - setProcessingStep("Extracting audio..."); - - const audioBlob = await extractTimelineAudio({ - tracks: editor.timeline.getTracks(), - mediaAssets: editor.media.getAssets(), - totalDuration: editor.timeline.getTotalDuration(), - }); - - setProcessingStep("Preparing audio..."); - const { samples } = await decodeAudioToFloat32({ - audioBlob, - sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE, - }); - - const result = await transcriptionService.transcribe({ - audioData: samples, - language: selectedLanguage === "auto" ? undefined : selectedLanguage, - onProgress: handleProgress, - }); - - setProcessingStep("Generating captions..."); - const captionChunks = buildCaptionChunks({ segments: result.segments }); - - const addTrackCommand = new AddTrackCommand("text", 0); - const insertCommands = captionChunks.map( - (caption, i) => - new InsertElementCommand({ - placement: { - mode: "explicit", - trackId: addTrackCommand.getTrackId(), - }, - element: { - ...DEFAULTS.text.element, - name: `Caption ${i + 1}`, - content: caption.text, - duration: caption.duration, - startTime: caption.startTime, - fontSize: 65, - fontWeight: "bold", - }, - }), - ); - - editor.command.execute({ - command: new BatchCommand([addTrackCommand, ...insertCommands]), - }); - } catch (error) { - console.error("Transcription failed:", error); - setError( - error instanceof Error ? error.message : "An unexpected error occurred", - ); - } finally { - setIsProcessing(false); - setProcessingStep(""); - } - }; - - const handleLanguageChange = ({ value }: { value: string }) => { - if (value === "auto") { - setSelectedLanguage("auto"); - return; - } - - const matchedLanguage = TRANSCRIPTION_LANGUAGES.find( - (language) => language.code === value, - ); - if (!matchedLanguage) return; - setSelectedLanguage(matchedLanguage.code); - }; - - return ( - <PanelView - title="Captions" - contentClassName="px-0 flex flex-col h-full" - ref={containerRef} - > - <Section showTopBorder={false} showBottomBorder={false} className="flex-1"> - <SectionContent className="flex flex-col gap-4 h-full pt-1"> - <SectionFields> - <SectionField label="Language"> - <Select - value={selectedLanguage} - onValueChange={(value) => handleLanguageChange({ value })} - > - <SelectTrigger> - <SelectValue placeholder="Select a language" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="auto">Auto detect</SelectItem> - {TRANSCRIPTION_LANGUAGES.map((language) => ( - <SelectItem key={language.code} value={language.code}> - {language.name} - </SelectItem> - ))} - </SelectContent> - </Select> - </SectionField> - </SectionFields> - - {error && ( - <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3"> - <p className="text-destructive text-sm">{error}</p> - </div> - )} - </SectionContent> - </Section> - <Section showBottomBorder={false} showTopBorder={false}> - <SectionContent> - <Button - className="w-full" - onClick={handleGenerateTranscript} - disabled={isProcessing} - > - {isProcessing && <Spinner className="mr-1" />} - {isProcessing ? processingStep : "Generate transcript"} - </Button> - </SectionContent> - </Section> - </PanelView> - ); -} +import { Button } from "@/components/ui/button";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useState, useRef } from "react";
+import { extractTimelineAudio } from "@/lib/media/mediabunny";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
+ TRANSCRIPTION_LANGUAGES,
+} from "@/constants/transcription-constants";
+import type {
+ TranscriptionLanguage,
+ TranscriptionProgress,
+} from "@/lib/transcription/types";
+import { transcriptionService } from "@/services/transcription/service";
+import { decodeAudioToFloat32 } from "@/lib/media/audio";
+import { buildCaptionChunks } from "@/lib/transcription/caption";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+} from "@/components/section";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ AddTrackCommand,
+ BatchCommand,
+ InsertElementCommand,
+} from "@/lib/commands";
+
+export function Captions() {
+ const [selectedLanguage, setSelectedLanguage] =
+ useState<TranscriptionLanguage>("auto");
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [processingStep, setProcessingStep] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const containerRef = useRef<HTMLDivElement>(null);
+ const editor = useEditor();
+
+ const handleProgress = (progress: TranscriptionProgress) => {
+ if (progress.status === "loading-model") {
+ setProcessingStep(`Loading model ${Math.round(progress.progress)}%`);
+ } else if (progress.status === "transcribing") {
+ setProcessingStep("Transcribing...");
+ }
+ };
+
+ const handleGenerateTranscript = async () => {
+ try {
+ setIsProcessing(true);
+ setError(null);
+ setProcessingStep("Extracting audio...");
+
+ const audioBlob = await extractTimelineAudio({
+ tracks: editor.timeline.getTracks(),
+ mediaAssets: editor.media.getAssets(),
+ totalDuration: editor.timeline.getTotalDuration(),
+ });
+
+ setProcessingStep("Preparing audio...");
+ const { samples } = await decodeAudioToFloat32({
+ audioBlob,
+ sampleRate: DEFAULT_TRANSCRIPTION_SAMPLE_RATE,
+ });
+
+ const result = await transcriptionService.transcribe({
+ audioData: samples,
+ language: selectedLanguage === "auto" ? undefined : selectedLanguage,
+ onProgress: handleProgress,
+ });
+
+ setProcessingStep("Generating captions...");
+ const captionChunks = buildCaptionChunks({ segments: result.segments });
+
+ const addTrackCommand = new AddTrackCommand("text", 0);
+ const insertCommands = captionChunks.map(
+ (caption, i) =>
+ new InsertElementCommand({
+ placement: {
+ mode: "explicit",
+ trackId: addTrackCommand.getTrackId(),
+ },
+ element: {
+ ...DEFAULTS.text.element,
+ name: `Caption ${i + 1}`,
+ content: caption.text,
+ duration: caption.duration,
+ startTime: caption.startTime,
+ fontSize: 65,
+ fontWeight: "bold",
+ },
+ }),
+ );
+
+ editor.command.execute({
+ command: new BatchCommand([addTrackCommand, ...insertCommands]),
+ });
+ } catch (error) {
+ console.error("Transcription failed:", error);
+ setError(
+ error instanceof Error ? error.message : "An unexpected error occurred",
+ );
+ } finally {
+ setIsProcessing(false);
+ setProcessingStep("");
+ }
+ };
+
+ const handleLanguageChange = ({ value }: { value: string }) => {
+ if (value === "auto") {
+ setSelectedLanguage("auto");
+ return;
+ }
+
+ const matchedLanguage = TRANSCRIPTION_LANGUAGES.find(
+ (language) => language.code === value,
+ );
+ if (!matchedLanguage) return;
+ setSelectedLanguage(matchedLanguage.code);
+ };
+
+ return (
+ <PanelView
+ title="Captions"
+ contentClassName="px-0 flex flex-col h-full"
+ ref={containerRef}
+ >
+ <Section showTopBorder={false} showBottomBorder={false} className="flex-1">
+ <SectionContent className="flex flex-col gap-4 h-full pt-1">
+ <SectionFields>
+ <SectionField label="Language">
+ <Select
+ value={selectedLanguage}
+ onValueChange={(value) => handleLanguageChange({ value })}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select a language" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="auto">Auto detect</SelectItem>
+ {TRANSCRIPTION_LANGUAGES.map((language) => (
+ <SelectItem key={language.code} value={language.code}>
+ {language.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </SectionFields>
+
+ {error && (
+ <div className="bg-destructive/10 border-destructive/20 rounded-md border p-3">
+ <p className="text-destructive text-sm">{error}</p>
+ </div>
+ )}
+ </SectionContent>
+ </Section>
+ <Section showBottomBorder={false} showTopBorder={false}>
+ <SectionContent>
+ <Button
+ className="w-full"
+ onClick={handleGenerateTranscript}
+ disabled={isProcessing}
+ >
+ {isProcessing && <Spinner className="mr-1" />}
+ {isProcessing ? processingStep : "Generate transcript"}
+ </Button>
+ </SectionContent>
+ </Section>
+ </PanelView>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx index b3ef1eb1..7a81188f 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/background.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/background.tsx @@ -1,227 +1,227 @@ -"use client"; - -import { memo, useCallback, useEffect, useMemo, useRef } from "react"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - BLUR_INTENSITY_PRESETS, - DEFAULT_BLUR_INTENSITY, - DEFAULT_COLOR, -} from "@/constants/project-constants"; -import { patternCraftGradients } from "@/data/colors/pattern-craft"; -import { colors } from "@/data/colors/solid"; -import { syntaxUIGradients } from "@/data/colors/syntax-ui"; -import { useEditor } from "@/hooks/use-editor"; -import { effectPreviewService } from "@/services/renderer/effect-preview"; -import type { TCanvasSize } from "@/lib/project/types"; -import { cn } from "@/utils/ui"; - -const BlurPreview = memo( - ({ - blur, - canvasSize, - isSelected, - onSelect, - }: { - blur: { label: string; value: number }; - canvasSize: TCanvasSize; - isSelected: boolean; - onSelect: () => void; - }) => { - const canvasRef = useRef<HTMLCanvasElement>(null); - const { width, height } = canvasSize; - - useEffect(() => { - const renderPreview = () => { - if (!canvasRef.current) return; - - effectPreviewService.renderPreview({ - effectType: "blur", - params: { intensity: blur.value }, - targetCanvas: canvasRef.current, - uniformDimensions: { width, height }, - }); - }; - - renderPreview(); - return effectPreviewService.onPreviewImageReady({ callback: renderPreview }); - }, [blur.value, width, height]); - - return ( - <button - className={cn( - "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border", - isSelected && "border-primary border-2", - )} - onClick={onSelect} - type="button" - aria-label={`Select ${blur.label} blur`} - > - <canvas - ref={canvasRef} - className="absolute inset-0 h-full w-full object-cover" - /> - <div className="absolute right-1 bottom-1 left-1 text-center"> - <span className="rounded bg-black/50 px-1 text-xs text-white"> - {blur.label} - </span> - </div> - </button> - ); - }, -); - -BlurPreview.displayName = "BlurPreview"; - -const BackgroundPreviews = memo( - ({ - backgrounds, - currentBackgroundColor, - isColorBackground, - onSelect, - useBackgroundColor = false, - }: { - backgrounds: string[]; - currentBackgroundColor: string; - isColorBackground: boolean; - onSelect: (bg: string) => void; - useBackgroundColor?: boolean; - }) => { - return useMemo( - () => - backgrounds.map((bg) => ( - <button - key={bg} - className={cn( - "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border", - isColorBackground && - bg === currentBackgroundColor && - "border-primary border-2", - )} - style={ - useBackgroundColor - ? { backgroundColor: bg } - : { - background: bg, - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", - } - } - onClick={() => onSelect(bg)} - type="button" - aria-label={`Select background ${bg}`} - /> - )), - [ - backgrounds, - isColorBackground, - currentBackgroundColor, - onSelect, - useBackgroundColor, - ], - ); - }, -); - -BackgroundPreviews.displayName = "BackgroundPreviews"; - -const COLOR_SECTIONS = [ - { title: "Colors", backgrounds: colors, useBackgroundColor: true }, - { title: "Pattern craft", backgrounds: patternCraftGradients }, - { title: "Syntax UI", backgrounds: syntaxUIGradients }, -] as const; - -export function BackgroundContent() { - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - - const handleBlurSelect = useCallback( - async (blurIntensity: number) => { - await editor.project.updateSettings({ - settings: { background: { type: "blur", blurIntensity } }, - }); - }, - [editor.project], - ); - - const handleColorSelect = useCallback( - async (color: string) => { - await editor.project.updateSettings({ - settings: { background: { type: "color", color } }, - }); - }, - [editor.project], - ); - - const isBlurBackground = activeProject.settings.background.type === "blur"; - const isColorBackground = activeProject.settings.background.type === "color"; - - const currentBlurIntensity = isBlurBackground - ? (activeProject.settings.background as { blurIntensity: number }) - .blurIntensity - : DEFAULT_BLUR_INTENSITY; - - const currentBackgroundColor = isColorBackground - ? (activeProject.settings.background as { color: string }).color - : DEFAULT_COLOR; - const canvasSize = activeProject.settings.canvasSize; - - const blurPreviews = useMemo( - () => - BLUR_INTENSITY_PRESETS.map((blur) => ( - <BlurPreview - key={blur.value} - blur={blur} - canvasSize={canvasSize} - isSelected={isBlurBackground && currentBlurIntensity === blur.value} - onSelect={() => handleBlurSelect(blur.value)} - /> - )), - [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect], - ); - - return ( - <div className="flex flex-col"> - <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}> - <SectionHeader> - <SectionTitle>Blur</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex flex-wrap gap-2">{blurPreviews}</div> - </SectionContent> - </Section> - {COLOR_SECTIONS.map((section) => ( - <Section - key={section.title} - collapsible - defaultOpen={false} - sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`} - > - <SectionHeader> - <SectionTitle>{section.title}</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex flex-wrap gap-2"> - <BackgroundPreviews - backgrounds={section.backgrounds as string[]} - currentBackgroundColor={currentBackgroundColor} - isColorBackground={isColorBackground} - onSelect={handleColorSelect} - useBackgroundColor={ - "useBackgroundColor" in section - ? section.useBackgroundColor - : false - } - /> - </div> - </SectionContent> - </Section> - ))} - </div> - ); -} +"use client";
+
+import { memo, useCallback, useEffect, useMemo, useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+} from "@/constants/project-constants";
+import { patternCraftGradients } from "@/data/colors/pattern-craft";
+import { colors } from "@/data/colors/solid";
+import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { useEditor } from "@/hooks/use-editor";
+import { effectPreviewService } from "@/services/renderer/effect-preview";
+import type { TCanvasSize } from "@/lib/project/types";
+import { cn } from "@/utils/ui";
+
+const BlurPreview = memo(
+ ({
+ blur,
+ canvasSize,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ canvasSize: TCanvasSize;
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const { width, height } = canvasSize;
+
+ useEffect(() => {
+ const renderPreview = () => {
+ if (!canvasRef.current) return;
+
+ effectPreviewService.renderPreview({
+ effectType: "blur",
+ params: { intensity: blur.value },
+ targetCanvas: canvasRef.current,
+ uniformDimensions: { width, height },
+ });
+ };
+
+ renderPreview();
+ return effectPreviewService.onPreviewImageReady({ callback: renderPreview });
+ }, [blur.value, width, height]);
+
+ return (
+ <button
+ className={cn(
+ "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
+ isSelected && "border-primary border-2",
+ )}
+ onClick={onSelect}
+ type="button"
+ aria-label={`Select ${blur.label} blur`}
+ >
+ <canvas
+ ref={canvasRef}
+ className="absolute inset-0 h-full w-full object-cover"
+ />
+ <div className="absolute right-1 bottom-1 left-1 text-center">
+ <span className="rounded bg-black/50 px-1 text-xs text-white">
+ {blur.label}
+ </span>
+ </div>
+ </button>
+ );
+ },
+);
+
+BlurPreview.displayName = "BlurPreview";
+
+const BackgroundPreviews = memo(
+ ({
+ backgrounds,
+ currentBackgroundColor,
+ isColorBackground,
+ onSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ onSelect: (bg: string) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg) => (
+ <button
+ key={bg}
+ className={cn(
+ "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
+ isColorBackground &&
+ bg === currentBackgroundColor &&
+ "border-primary border-2",
+ )}
+ style={
+ useBackgroundColor
+ ? { backgroundColor: bg }
+ : {
+ background: bg,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ }
+ }
+ onClick={() => onSelect(bg)}
+ type="button"
+ aria-label={`Select background ${bg}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ onSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+const COLOR_SECTIONS = [
+ { title: "Colors", backgrounds: colors, useBackgroundColor: true },
+ { title: "Pattern craft", backgrounds: patternCraftGradients },
+ { title: "Syntax UI", backgrounds: syntaxUIGradients },
+] as const;
+
+export function BackgroundContent() {
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+
+ const handleBlurSelect = useCallback(
+ async (blurIntensity: number) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async (color: string) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const currentBlurIntensity = isBlurBackground
+ ? (activeProject.settings.background as { blurIntensity: number })
+ .blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor = isColorBackground
+ ? (activeProject.settings.background as { color: string }).color
+ : DEFAULT_COLOR;
+ const canvasSize = activeProject.settings.canvasSize;
+
+ const blurPreviews = useMemo(
+ () =>
+ BLUR_INTENSITY_PRESETS.map((blur) => (
+ <BlurPreview
+ key={blur.value}
+ blur={blur}
+ canvasSize={canvasSize}
+ isSelected={isBlurBackground && currentBlurIntensity === blur.value}
+ onSelect={() => handleBlurSelect(blur.value)}
+ />
+ )),
+ [canvasSize, isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible defaultOpen={true} sectionKey="background-blur" showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle>Blur</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">{blurPreviews}</div>
+ </SectionContent>
+ </Section>
+ {COLOR_SECTIONS.map((section) => (
+ <Section
+ key={section.title}
+ collapsible
+ defaultOpen={false}
+ sectionKey={`settings:background-${section.title.toLowerCase().replace(/\s+/g, "-")}`}
+ >
+ <SectionHeader>
+ <SectionTitle>{section.title}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex flex-wrap gap-2">
+ <BackgroundPreviews
+ backgrounds={section.backgrounds as string[]}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ onSelect={handleColorSelect}
+ useBackgroundColor={
+ "useBackgroundColor" in section
+ ? section.useBackgroundColor
+ : false
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx index e0229ea9..0578eeb7 100644 --- a/apps/web/src/components/editor/panels/assets/views/settings/index.tsx +++ b/apps/web/src/components/editor/panels/assets/views/settings/index.tsx @@ -1,369 +1,369 @@ -"use client"; - -import { useRef, useState } from "react"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { FPS_PRESETS } from "@/constants/project-constants"; -import { useEditor } from "@/hooks/use-editor"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { BackgroundContent } from "./background"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { NumberField } from "@/components/ui/number-field"; -import { useEditorStore } from "@/stores/editor-store"; -import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Tick02Icon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; -import { dimensionToAspectRatio } from "@/utils/geometry"; -import { formatNumberForDisplay } from "@/utils/math"; -import { OcSquarePlusIcon } from "@opencut/ui/icons"; -import type { TCanvasSize } from "@/lib/project/types"; - -type SettingsView = "project-info" | "background"; - -const PRESET_LABELS: Record<string, string> = { - "1:1": "1:1", - "16:9": "16:9", - "9:16": "9:16", - "4:3": "4:3", -}; - -function areCanvasSizesEqual({ - left, - right, -}: { - left: TCanvasSize; - right: TCanvasSize; -}) { - return left.width === right.width && left.height === right.height; -} - -function formatCanvasDimension({ value }: { value: number }) { - return formatNumberForDisplay({ value, maxFractionDigits: 0 }); -} - -function parseCanvasDimension({ input }: { input: string }): number | null { - const trimmed = input.trim(); - if (!trimmed) return null; - - const parsed = Number(trimmed); - if (!Number.isFinite(parsed)) return null; - - const rounded = Math.round(parsed); - return rounded > 0 ? rounded : null; -} - -function useCanvasDimensionDraft({ - value, - onCommit, -}: { - value: number; - onCommit: (value: number) => void; -}) { - const pendingValueRef = useRef(value); - const syncedValueRef = useRef(value); - - if (syncedValueRef.current !== value) { - syncedValueRef.current = value; - pendingValueRef.current = value; - } - - return usePropertyDraft({ - displayValue: formatCanvasDimension({ value }), - parse: (input) => parseCanvasDimension({ input }), - onPreview: (nextValue) => { - pendingValueRef.current = nextValue; - }, - onCommit: () => { - if (pendingValueRef.current !== value) { - onCommit(pendingValueRef.current); - } - }, - }); -} - -export function SettingsView() { - const [view, setView] = useState<SettingsView>("project-info"); - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - const { canvasPresets } = useEditorStore(); - const currentCanvasSize = activeProject.settings.canvasSize; - const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset"; - const lastCustomCanvasSize = - activeProject.settings.lastCustomCanvasSize ?? null; - - const presetItems = canvasPresets.map((preset, index) => { - const ratio = dimensionToAspectRatio(preset); - return { - id: index.toString(), - label: PRESET_LABELS[ratio] ?? ratio, - ratio, - canvasSize: preset, - }; - }); - - const selectedPresetId = canvasSizeMode === "preset" - ? (presetItems.find((preset) => - areCanvasSizesEqual({ - left: preset.canvasSize, - right: currentCanvasSize, - }), - )?.id ?? null) - : null; - - const updateCustomCanvasSize = ({ - canvasSize, - }: { - canvasSize: TCanvasSize; - }) => { - const shouldUpdateCanvasSize = !areCanvasSizesEqual({ - left: canvasSize, - right: currentCanvasSize, - }); - const shouldUpdateLastCustomCanvasSize = - lastCustomCanvasSize === null || - !areCanvasSizesEqual({ - left: canvasSize, - right: lastCustomCanvasSize, - }); - const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom"; - - if ( - !shouldUpdateCanvasSize && - !shouldUpdateLastCustomCanvasSize && - !shouldUpdateCanvasSizeMode - ) { - return; - } - - editor.project.updateSettings({ - settings: { - ...(shouldUpdateCanvasSize ? { canvasSize } : {}), - ...(shouldUpdateCanvasSizeMode - ? { canvasSizeMode: "custom" as const } - : {}), - lastCustomCanvasSize: canvasSize, - }, - }); - }; - - const selectPresetCanvasSize = ({ - canvasSize, - }: { - canvasSize: TCanvasSize; - }) => { - const shouldUpdateCanvasSize = !areCanvasSizesEqual({ - left: canvasSize, - right: currentCanvasSize, - }); - const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset"; - - if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return; - - editor.project.updateSettings({ - settings: { - ...(shouldUpdateCanvasSize ? { canvasSize } : {}), - ...(shouldUpdateCanvasSizeMode - ? { canvasSizeMode: "preset" as const } - : {}), - }, - }); - }; - - const selectCustomCanvasSize = () => { - updateCustomCanvasSize({ - canvasSize: lastCustomCanvasSize ?? currentCanvasSize, - }); - }; - - const widthDraft = useCanvasDimensionDraft({ - value: currentCanvasSize.width, - onCommit: (width) => - updateCustomCanvasSize({ - canvasSize: { width, height: currentCanvasSize.height }, - }), - }); - - const heightDraft = useCanvasDimensionDraft({ - value: currentCanvasSize.height, - onCommit: (height) => - updateCustomCanvasSize({ - canvasSize: { width: currentCanvasSize.width, height }, - }), - }); - - const isCustomSelected = canvasSizeMode === "custom"; - - return ( - <PanelView - contentClassName="px-0" - scrollClassName="pt-0" - actions={ - <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}> - <TabsList> - <TabsTrigger value="project-info">Project info</TabsTrigger> - <TabsTrigger value="background">Background</TabsTrigger> - </TabsList> - </Tabs> - } - > - {view === "project-info" && ( - <div className="flex flex-col"> - <Section showTopBorder={false}> - <SectionHeader> - <SectionTitle className="flex-1">Name</SectionTitle> - <span className="text-sm truncate"> - {activeProject.metadata.name} - </span> - </SectionHeader> - </Section> - <Section showTopBorder={false}> - <SectionHeader className="justify-between"> - <SectionTitle className="flex-1">Frame rate</SectionTitle> - <Select - value={activeProject.settings.fps.toString()} - onValueChange={(value) => { - const fps = parseFloat(value); - editor.project.updateSettings({ settings: { fps } }); - }} - > - <SelectTrigger className="bg-transparent border-none p-1 h-auto"> - <SelectValue placeholder="Select a frame rate" /> - </SelectTrigger> - <SelectContent> - {FPS_PRESETS.map((preset) => ( - <SelectItem key={preset.value} value={preset.value}> - {preset.label} - </SelectItem> - ))} - </SelectContent> - </Select> - </SectionHeader> - </Section> - <Section - showTopBorder={false} - collapsible - sectionKey="settings:aspect-ratio" - > - <SectionHeader> - <SectionTitle className="flex-1">Aspect ratio</SectionTitle> - </SectionHeader> - <SectionContent className="px-2 flex flex-col gap-1 pb-2"> - {presetItems.map((preset) => ( - <AspectRatioItem - key={preset.id} - label={preset.label} - previewIcon={<AspectRatioPreview ratio={preset.ratio} />} - isSelected={selectedPresetId === preset.id} - onClick={() => { - selectPresetCanvasSize({ - canvasSize: preset.canvasSize, - }); - }} - /> - ))} - <div className="pb-2"> - <AspectRatioItem - key="custom" - label="Custom" - previewIcon={<OcSquarePlusIcon />} - isSelected={isCustomSelected} - onClick={selectCustomCanvasSize} - uiOptions={ - <div className=" flex items-center gap-2 text-foreground"> - <NumberField - value={widthDraft.displayValue} - className="w-full" - aria-label="Canvas width" - onFocus={widthDraft.onFocus} - onChange={widthDraft.onChange} - onBlur={widthDraft.onBlur} - /> - <NumberField - value={heightDraft.displayValue} - className="w-full" - aria-label="Canvas height" - onFocus={heightDraft.onFocus} - onChange={heightDraft.onChange} - onBlur={heightDraft.onBlur} - /> - </div> - } - /> - </div> - </SectionContent> - </Section> - </div> - )} - {view === "background" && <BackgroundContent />} - </PanelView> - ); -} - -function AspectRatioItem({ - label, - previewIcon, - isSelected, - onClick, - uiOptions, -}: { - label: string; - previewIcon: React.ReactNode; - isSelected: boolean; - onClick: () => void; - uiOptions?: React.ReactNode; -}) { - return ( - <Button - variant={isSelected ? "secondary" : "ghost"} - className={cn( - "px-2 py-0 flex flex-col h-fit w-full", - !isSelected && "border border-transparent opacity-75!", - )} - onClick={onClick} - > - <div className="w-full flex justify-between items-center h-8"> - <div className="flex-1 flex items-center gap-2"> - <div className="flex items-center justify-center size-5"> - {previewIcon} - </div> - <span className="text-sm truncate">{label}</span> - </div> - <div> - {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />} - </div> - </div> - {uiOptions && isSelected && ( - <div className="w-full pb-2">{uiOptions}</div> - )} - </Button> - ); -} - -function AspectRatioPreview({ ratio }: { ratio?: string }) { - if (!ratio) return null; - - const [w, h] = ratio.split(":").map(Number); - const maxSize = 16; - const width = w >= h ? maxSize : (w / h) * maxSize; - const height = h >= w ? maxSize : (h / w) * maxSize; - - return ( - <div - style={{ width, height, borderWidth: 1.5 }} - className="rounded-xs border-current opacity-60" - /> - ); -} +"use client";
+
+import { useRef, useState } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { FPS_PRESETS } from "@/constants/project-constants";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { BackgroundContent } from "./background";
+import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { Button } from "@/components/ui/button";
+import { NumberField } from "@/components/ui/number-field";
+import { useEditorStore } from "@/stores/editor-store";
+import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Tick02Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { formatNumberForDisplay } from "@/utils/math";
+import { OcSquarePlusIcon } from "@opencut/ui/icons";
+import type { TCanvasSize } from "@/lib/project/types";
+
+type SettingsView = "project-info" | "background";
+
+const PRESET_LABELS: Record<string, string> = {
+ "1:1": "1:1",
+ "16:9": "16:9",
+ "9:16": "9:16",
+ "4:3": "4:3",
+};
+
+function areCanvasSizesEqual({
+ left,
+ right,
+}: {
+ left: TCanvasSize;
+ right: TCanvasSize;
+}) {
+ return left.width === right.width && left.height === right.height;
+}
+
+function formatCanvasDimension({ value }: { value: number }) {
+ return formatNumberForDisplay({ value, maxFractionDigits: 0 });
+}
+
+function parseCanvasDimension({ input }: { input: string }): number | null {
+ const trimmed = input.trim();
+ if (!trimmed) return null;
+
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) return null;
+
+ const rounded = Math.round(parsed);
+ return rounded > 0 ? rounded : null;
+}
+
+function useCanvasDimensionDraft({
+ value,
+ onCommit,
+}: {
+ value: number;
+ onCommit: (value: number) => void;
+}) {
+ const pendingValueRef = useRef(value);
+ const syncedValueRef = useRef(value);
+
+ if (syncedValueRef.current !== value) {
+ syncedValueRef.current = value;
+ pendingValueRef.current = value;
+ }
+
+ return usePropertyDraft({
+ displayValue: formatCanvasDimension({ value }),
+ parse: (input) => parseCanvasDimension({ input }),
+ onPreview: (nextValue) => {
+ pendingValueRef.current = nextValue;
+ },
+ onCommit: () => {
+ if (pendingValueRef.current !== value) {
+ onCommit(pendingValueRef.current);
+ }
+ },
+ });
+}
+
+export function SettingsView() {
+ const [view, setView] = useState<SettingsView>("project-info");
+ const editor = useEditor();
+ const activeProject = useEditor((e) => e.project.getActive());
+ const { canvasPresets } = useEditorStore();
+ const currentCanvasSize = activeProject.settings.canvasSize;
+ const canvasSizeMode = activeProject.settings.canvasSizeMode ?? "preset";
+ const lastCustomCanvasSize =
+ activeProject.settings.lastCustomCanvasSize ?? null;
+
+ const presetItems = canvasPresets.map((preset, index) => {
+ const ratio = dimensionToAspectRatio(preset);
+ return {
+ id: index.toString(),
+ label: PRESET_LABELS[ratio] ?? ratio,
+ ratio,
+ canvasSize: preset,
+ };
+ });
+
+ const selectedPresetId = canvasSizeMode === "preset"
+ ? (presetItems.find((preset) =>
+ areCanvasSizesEqual({
+ left: preset.canvasSize,
+ right: currentCanvasSize,
+ }),
+ )?.id ?? null)
+ : null;
+
+ const updateCustomCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateLastCustomCanvasSize =
+ lastCustomCanvasSize === null ||
+ !areCanvasSizesEqual({
+ left: canvasSize,
+ right: lastCustomCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "custom";
+
+ if (
+ !shouldUpdateCanvasSize &&
+ !shouldUpdateLastCustomCanvasSize &&
+ !shouldUpdateCanvasSizeMode
+ ) {
+ return;
+ }
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "custom" as const }
+ : {}),
+ lastCustomCanvasSize: canvasSize,
+ },
+ });
+ };
+
+ const selectPresetCanvasSize = ({
+ canvasSize,
+ }: {
+ canvasSize: TCanvasSize;
+ }) => {
+ const shouldUpdateCanvasSize = !areCanvasSizesEqual({
+ left: canvasSize,
+ right: currentCanvasSize,
+ });
+ const shouldUpdateCanvasSizeMode = canvasSizeMode !== "preset";
+
+ if (!shouldUpdateCanvasSize && !shouldUpdateCanvasSizeMode) return;
+
+ editor.project.updateSettings({
+ settings: {
+ ...(shouldUpdateCanvasSize ? { canvasSize } : {}),
+ ...(shouldUpdateCanvasSizeMode
+ ? { canvasSizeMode: "preset" as const }
+ : {}),
+ },
+ });
+ };
+
+ const selectCustomCanvasSize = () => {
+ updateCustomCanvasSize({
+ canvasSize: lastCustomCanvasSize ?? currentCanvasSize,
+ });
+ };
+
+ const widthDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.width,
+ onCommit: (width) =>
+ updateCustomCanvasSize({
+ canvasSize: { width, height: currentCanvasSize.height },
+ }),
+ });
+
+ const heightDraft = useCanvasDimensionDraft({
+ value: currentCanvasSize.height,
+ onCommit: (height) =>
+ updateCustomCanvasSize({
+ canvasSize: { width: currentCanvasSize.width, height },
+ }),
+ });
+
+ const isCustomSelected = canvasSizeMode === "custom";
+
+ return (
+ <PanelView
+ contentClassName="px-0"
+ scrollClassName="pt-0"
+ actions={
+ <Tabs value={view} onValueChange={(v) => setView(v as SettingsView)}>
+ <TabsList>
+ <TabsTrigger value="project-info">Project info</TabsTrigger>
+ <TabsTrigger value="background">Background</TabsTrigger>
+ </TabsList>
+ </Tabs>
+ }
+ >
+ {view === "project-info" && (
+ <div className="flex flex-col">
+ <Section showTopBorder={false}>
+ <SectionHeader>
+ <SectionTitle className="flex-1">Name</SectionTitle>
+ <span className="text-sm truncate">
+ {activeProject.metadata.name}
+ </span>
+ </SectionHeader>
+ </Section>
+ <Section showTopBorder={false}>
+ <SectionHeader className="justify-between">
+ <SectionTitle className="flex-1">Frame rate</SectionTitle>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={(value) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ }}
+ >
+ <SelectTrigger className="bg-transparent border-none p-1 h-auto">
+ <SelectValue placeholder="Select a frame rate" />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map((preset) => (
+ <SelectItem key={preset.value} value={preset.value}>
+ {preset.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionHeader>
+ </Section>
+ <Section
+ showTopBorder={false}
+ collapsible
+ sectionKey="settings:aspect-ratio"
+ >
+ <SectionHeader>
+ <SectionTitle className="flex-1">Aspect ratio</SectionTitle>
+ </SectionHeader>
+ <SectionContent className="px-2 flex flex-col gap-1 pb-2">
+ {presetItems.map((preset) => (
+ <AspectRatioItem
+ key={preset.id}
+ label={preset.label}
+ previewIcon={<AspectRatioPreview ratio={preset.ratio} />}
+ isSelected={selectedPresetId === preset.id}
+ onClick={() => {
+ selectPresetCanvasSize({
+ canvasSize: preset.canvasSize,
+ });
+ }}
+ />
+ ))}
+ <div className="pb-2">
+ <AspectRatioItem
+ key="custom"
+ label="Custom"
+ previewIcon={<OcSquarePlusIcon />}
+ isSelected={isCustomSelected}
+ onClick={selectCustomCanvasSize}
+ uiOptions={
+ <div className=" flex items-center gap-2 text-foreground">
+ <NumberField
+ value={widthDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas width"
+ onFocus={widthDraft.onFocus}
+ onChange={widthDraft.onChange}
+ onBlur={widthDraft.onBlur}
+ />
+ <NumberField
+ value={heightDraft.displayValue}
+ className="w-full"
+ aria-label="Canvas height"
+ onFocus={heightDraft.onFocus}
+ onChange={heightDraft.onChange}
+ onBlur={heightDraft.onBlur}
+ />
+ </div>
+ }
+ />
+ </div>
+ </SectionContent>
+ </Section>
+ </div>
+ )}
+ {view === "background" && <BackgroundContent />}
+ </PanelView>
+ );
+}
+
+function AspectRatioItem({
+ label,
+ previewIcon,
+ isSelected,
+ onClick,
+ uiOptions,
+}: {
+ label: string;
+ previewIcon: React.ReactNode;
+ isSelected: boolean;
+ onClick: () => void;
+ uiOptions?: React.ReactNode;
+}) {
+ return (
+ <Button
+ variant={isSelected ? "secondary" : "ghost"}
+ className={cn(
+ "px-2 py-0 flex flex-col h-fit w-full",
+ !isSelected && "border border-transparent opacity-75!",
+ )}
+ onClick={onClick}
+ >
+ <div className="w-full flex justify-between items-center h-8">
+ <div className="flex-1 flex items-center gap-2">
+ <div className="flex items-center justify-center size-5">
+ {previewIcon}
+ </div>
+ <span className="text-sm truncate">{label}</span>
+ </div>
+ <div>
+ {isSelected && <HugeiconsIcon icon={Tick02Icon} className="size-4" />}
+ </div>
+ </div>
+ {uiOptions && isSelected && (
+ <div className="w-full pb-2">{uiOptions}</div>
+ )}
+ </Button>
+ );
+}
+
+function AspectRatioPreview({ ratio }: { ratio?: string }) {
+ if (!ratio) return null;
+
+ const [w, h] = ratio.split(":").map(Number);
+ const maxSize = 16;
+ const width = w >= h ? maxSize : (w / h) * maxSize;
+ const height = h >= w ? maxSize : (h / w) * maxSize;
+
+ return (
+ <div
+ style={{ width, height, borderWidth: 1.5 }}
+ className="rounded-xs border-current opacity-60"
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx index 4fb43ac2..dc92ad9c 100644 --- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx +++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx @@ -1,574 +1,574 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; -import { useSoundSearch } from "@/hooks/use-sound-search"; -import { useSoundsStore } from "@/stores/sounds-store"; -import type { SavedSound, SoundEffect } from "@/lib/sounds/types"; -import { cn } from "@/utils/ui"; -import { - FavouriteIcon, - FilterMailIcon, - PauseIcon, - PlayIcon, - PlusSignIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function SoundsView() { - return ( - <div className="flex h-full flex-col"> - <Tabs defaultValue="sound-effects" className="flex h-full flex-col"> - <div className="px-3 pt-4 pb-0"> - <TabsList> - <TabsTrigger value="sound-effects">Sound effects</TabsTrigger> - <TabsTrigger value="songs">Songs</TabsTrigger> - <TabsTrigger value="saved">Saved</TabsTrigger> - </TabsList> - </div> - <Separator className="my-4" /> - <TabsContent - value="sound-effects" - className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" - > - <SoundEffectsView /> - </TabsContent> - <TabsContent - value="saved" - className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" - > - <SavedSoundsView /> - </TabsContent> - <TabsContent - value="songs" - className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0" - > - <SongsView /> - </TabsContent> - </Tabs> - </div> - ); -} - -function SoundEffectsView() { - const { - topSoundEffects, - isLoading, - searchQuery, - setSearchQuery, - scrollPosition, - setScrollPosition, - loadSavedSounds, - showCommercialOnly, - toggleCommercialFilter, - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - } = useSoundsStore(); - const { - results: searchResults, - isLoading: isSearching, - loadMore, - hasNextPage, - isLoadingMore, - } = useSoundSearch({ - query: searchQuery, - commercialOnly: showCommercialOnly, - }); - - const [playingId, setPlayingId] = useState<number | null>(null); - const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( - null, - ); - - const { scrollAreaRef, handleScroll } = useInfiniteScroll({ - onLoadMore: loadMore, - hasMore: hasNextPage, - isLoading: isLoadingMore || isSearching, - }); - - useEffect(() => { - loadSavedSounds(); - }, [loadSavedSounds]); - - useEffect(() => { - if (hasLoaded) { - return; - } - - let shouldIgnore = false; - - const fetchTopSounds = async () => { - try { - if (!shouldIgnore) { - setLoading({ loading: true }); - setError({ error: null }); - } - - const response = await fetch( - "/api/sounds/search?page_size=50&sort=downloads", - ); - - if (!shouldIgnore) { - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status}`); - } - - const data = await response.json(); - setTopSoundEffects({ sounds: data.results }); - setHasLoaded({ loaded: true }); - - setCurrentPage({ page: 1 }); - setHasNextPage({ hasNext: !!data.next }); - setTotalCount({ count: data.count }); - } - } catch (error) { - if (!shouldIgnore) { - console.error("Failed to fetch top sounds:", error); - setError({ - error: - error instanceof Error ? error.message : "Failed to load sounds", - }); - } - } finally { - if (!shouldIgnore) { - setLoading({ loading: false }); - } - } - }; - - const timeoutId = setTimeout(fetchTopSounds, 100, {}); - - return () => { - shouldIgnore = true; - clearTimeout(timeoutId); - }; - }, [ - hasLoaded, - setTopSoundEffects, - setLoading, - setError, - setHasLoaded, - setCurrentPage, - setHasNextPage, - setTotalCount, - ]); - - useEffect(() => { - if (!scrollAreaRef.current || scrollPosition <= 0) { - return; - } - - const restoreScrollPosition = () => { - scrollAreaRef.current?.scrollTo({ top: scrollPosition }); - }; - - const timeoutId = setTimeout(restoreScrollPosition, 100, {}); - - return () => clearTimeout(timeoutId); - }, [scrollPosition, scrollAreaRef]); - - const handleScrollWithPosition = ({ - currentTarget, - }: React.UIEvent<HTMLDivElement>) => { - const { scrollTop } = currentTarget; - setScrollPosition({ position: scrollTop }); - handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>); - }; - - const displayedSounds = searchQuery ? searchResults : topSoundEffects; - - const playSound = ({ sound }: { sound: SoundEffect }) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", () => { - setPlayingId(null); - }); - audio.play().catch((error) => { - console.error("Failed to play sound preview:", error); - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - return ( - <div className="mt-1 flex h-full flex-col gap-5"> - <div className="flex items-center gap-3"> - <Input - placeholder="Search sound effects" - className="w-full" - containerClassName="w-full" - value={searchQuery} - onChange={({ currentTarget }) => - setSearchQuery({ query: currentTarget.value }) - } - showClearIcon - onClear={() => setSearchQuery({ query: "" })} - /> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="text" - size="icon" - className={cn(showCommercialOnly && "text-primary")} - > - <HugeiconsIcon icon={FilterMailIcon} /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuCheckboxItem - checked={showCommercialOnly} - onCheckedChange={() => toggleCommercialFilter()} - > - Show only commercially licensed - </DropdownMenuCheckboxItem> - <div className="text-muted-foreground px-2 py-1.5 text-xs"> - {showCommercialOnly - ? "Only showing sounds licensed for commercial use" - : "Showing all sounds regardless of license"} - </div> - </DropdownMenuContent> - </DropdownMenu> - </div> - - <div className="relative h-full overflow-hidden"> - <ScrollArea - className="h-full flex-1" - ref={scrollAreaRef} - onScrollCapture={handleScrollWithPosition} - > - <div className="flex flex-col gap-4"> - {isLoading && !searchQuery && ( - <div className="text-muted-foreground text-sm"> - Loading sounds... - </div> - )} - {isSearching && searchQuery && ( - <div className="text-muted-foreground text-sm">Searching...</div> - )} - {displayedSounds.map((sound) => ( - <AudioItem - key={sound.id} - sound={sound} - isPlaying={playingId === sound.id} - onPlay={playSound} - /> - ))} - {!isLoading && !isSearching && displayedSounds.length === 0 && ( - <div className="text-muted-foreground text-sm"> - {searchQuery ? "No sounds found" : "No sounds available"} - </div> - )} - {isLoadingMore && ( - <div className="text-muted-foreground py-4 text-center text-sm"> - Loading more sounds... - </div> - )} - </div> - </ScrollArea> - </div> - </div> - ); -} - -function SavedSoundsView() { - const { - savedSounds, - isLoadingSavedSounds, - savedSoundsError, - loadSavedSounds, - clearSavedSounds, - } = useSoundsStore(); - - const [playingId, setPlayingId] = useState<number | null>(null); - const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>( - null, - ); - - const [showClearDialog, setShowClearDialog] = useState(false); - - useEffect(() => { - loadSavedSounds(); - }, [loadSavedSounds]); - - const playSound = ({ sound }: { sound: SoundEffect }) => { - if (playingId === sound.id) { - audioElement?.pause(); - setPlayingId(null); - return; - } - - audioElement?.pause(); - - if (sound.previewUrl) { - const audio = new Audio(sound.previewUrl); - audio.addEventListener("ended", () => { - setPlayingId(null); - }); - audio.addEventListener("error", () => { - setPlayingId(null); - }); - audio.play().catch((error) => { - console.error("Failed to play sound preview:", error); - setPlayingId(null); - }); - - setAudioElement(audio); - setPlayingId(sound.id); - } - }; - - const convertToSoundEffect = ({ - savedSound, - }: { - savedSound: SavedSound; - }): SoundEffect => ({ - id: savedSound.id, - name: savedSound.name, - description: "", - url: "", - previewUrl: savedSound.previewUrl, - downloadUrl: savedSound.downloadUrl, - duration: savedSound.duration, - filesize: 0, - type: "audio", - channels: 0, - bitrate: 0, - bitdepth: 0, - samplerate: 0, - username: savedSound.username, - tags: savedSound.tags, - license: savedSound.license, - created: savedSound.savedAt, - downloads: 0, - rating: 0, - ratingCount: 0, - }); - - if (isLoadingSavedSounds) { - return ( - <div className="flex h-full items-center justify-center"> - <div className="text-muted-foreground text-sm"> - Loading saved sounds... - </div> - </div> - ); - } - - if (savedSoundsError) { - return ( - <div className="flex h-full items-center justify-center"> - <div className="text-destructive text-sm"> - Error: {savedSoundsError} - </div> - </div> - ); - } - - if (savedSounds.length === 0) { - return ( - <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={FavouriteIcon} - className="text-muted-foreground size-10" - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">No saved sounds</p> - <p className="text-muted-foreground text-sm text-balance"> - Click the heart icon on any sound to save it here - </p> - </div> - </div> - ); - } - - return ( - <div className="mt-1 flex h-full flex-col gap-5"> - <div className="flex items-center justify-between"> - <p className="text-muted-foreground text-sm"> - {savedSounds.length} saved{" "} - {savedSounds.length === 1 ? "sound" : "sounds"} - </p> - <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}> - <DialogTrigger asChild> - <Button - variant="text" - size="sm" - className="text-muted-foreground hover:text-destructive h-auto !opacity-100" - > - Clear all - </Button> - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Clear all saved sounds?</DialogTitle> - <DialogDescription> - This will permanently remove all {savedSounds.length} saved - sounds from your collection. This action cannot be undone. - </DialogDescription> - </DialogHeader> - <DialogFooter> - <Button variant="text" onClick={() => setShowClearDialog(false)}> - Cancel - </Button> - <Button - variant="destructive" - onClick={async ({ - stopPropagation, - }: React.MouseEvent<HTMLButtonElement>) => { - stopPropagation(); - await clearSavedSounds(); - setShowClearDialog(false); - }} - > - Clear all sounds - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </div> - - <div className="relative h-full overflow-hidden"> - <ScrollArea className="h-full flex-1"> - <div className="flex flex-col gap-4"> - {savedSounds.map((sound) => ( - <AudioItem - key={sound.id} - sound={convertToSoundEffect({ savedSound: sound })} - isPlaying={playingId === sound.id} - onPlay={playSound} - /> - ))} - </div> - </ScrollArea> - </div> - </div> - ); -} - -function SongsView() { - return <div>Songs</div>; -} - -interface AudioItemProps { - sound: SoundEffect; - isPlaying: boolean; - onPlay: ({ sound }: { sound: SoundEffect }) => void; -} - -function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) { - const { addSoundToTimeline, isSoundSaved, toggleSavedSound } = - useSoundsStore(); - const isSaved = isSoundSaved({ soundId: sound.id }); - - const handleClick = () => { - onPlay({ sound }); - }; - - const handleSaveClick = ({ - stopPropagation, - }: React.MouseEvent<HTMLButtonElement>) => { - stopPropagation(); - toggleSavedSound({ soundEffect: sound }); - }; - - const handleAddToTimeline = async ({ - stopPropagation, - }: React.MouseEvent<HTMLButtonElement>) => { - stopPropagation(); - await addSoundToTimeline({ sound }); - }; - - return ( - <div className="group flex items-center gap-3 opacity-100 hover:opacity-75"> - <button - type="button" - className="flex min-w-0 flex-1 items-center gap-3 text-left" - onClick={handleClick} - > - <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md"> - <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" /> - {isPlaying ? ( - <HugeiconsIcon icon={PauseIcon} className="size-5" /> - ) : ( - <HugeiconsIcon icon={PlayIcon} className="size-5" /> - )} - </div> - - <div className="min-w-0 flex-1 overflow-hidden"> - <p className="truncate text-sm font-medium">{sound.name}</p> - <span className="text-muted-foreground block truncate text-xs"> - {sound.username} - </span> - </div> - </button> - - <div className="flex items-center gap-3 pr-2"> - <Button - variant="text" - size="icon" - className="text-muted-foreground hover:text-foreground w-auto !opacity-100" - onClick={handleAddToTimeline} - title="Add to timeline" - > - <HugeiconsIcon icon={PlusSignIcon} /> - </Button> - <Button - variant="text" - size="icon" - className={`hover:text-foreground w-auto !opacity-100 ${ - isSaved - ? "text-red-500 hover:text-red-600" - : "text-muted-foreground" - }`} - onClick={handleSaveClick} - title={isSaved ? "Remove from saved" : "Save sound"} - > - <HugeiconsIcon - icon={FavouriteIcon} - className={`${isSaved ? "fill-current" : ""}`} - /> - </Button> - </div> - </div> - ); -} +"use client";
+
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Input } from "@/components/ui/input";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Separator } from "@/components/ui/separator";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
+import { useSoundSearch } from "@/hooks/use-sound-search";
+import { useSoundsStore } from "@/stores/sounds-store";
+import type { SavedSound, SoundEffect } from "@/lib/sounds/types";
+import { cn } from "@/utils/ui";
+import {
+ FavouriteIcon,
+ FilterMailIcon,
+ PauseIcon,
+ PlayIcon,
+ PlusSignIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function SoundsView() {
+ return (
+ <div className="flex h-full flex-col">
+ <Tabs defaultValue="sound-effects" className="flex h-full flex-col">
+ <div className="px-3 pt-4 pb-0">
+ <TabsList>
+ <TabsTrigger value="sound-effects">Sound effects</TabsTrigger>
+ <TabsTrigger value="songs">Songs</TabsTrigger>
+ <TabsTrigger value="saved">Saved</TabsTrigger>
+ </TabsList>
+ </div>
+ <Separator className="my-4" />
+ <TabsContent
+ value="sound-effects"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SoundEffectsView />
+ </TabsContent>
+ <TabsContent
+ value="saved"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SavedSoundsView />
+ </TabsContent>
+ <TabsContent
+ value="songs"
+ className="mt-0 flex min-h-0 flex-1 flex-col p-5 pt-0"
+ >
+ <SongsView />
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+}
+
+function SoundEffectsView() {
+ const {
+ topSoundEffects,
+ isLoading,
+ searchQuery,
+ setSearchQuery,
+ scrollPosition,
+ setScrollPosition,
+ loadSavedSounds,
+ showCommercialOnly,
+ toggleCommercialFilter,
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ } = useSoundsStore();
+ const {
+ results: searchResults,
+ isLoading: isSearching,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ } = useSoundSearch({
+ query: searchQuery,
+ commercialOnly: showCommercialOnly,
+ });
+
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null,
+ );
+
+ const { scrollAreaRef, handleScroll } = useInfiniteScroll({
+ onLoadMore: loadMore,
+ hasMore: hasNextPage,
+ isLoading: isLoadingMore || isSearching,
+ });
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ useEffect(() => {
+ if (hasLoaded) {
+ return;
+ }
+
+ let shouldIgnore = false;
+
+ const fetchTopSounds = async () => {
+ try {
+ if (!shouldIgnore) {
+ setLoading({ loading: true });
+ setError({ error: null });
+ }
+
+ const response = await fetch(
+ "/api/sounds/search?page_size=50&sort=downloads",
+ );
+
+ if (!shouldIgnore) {
+ if (!response.ok) {
+ throw new Error(`Failed to fetch: ${response.status}`);
+ }
+
+ const data = await response.json();
+ setTopSoundEffects({ sounds: data.results });
+ setHasLoaded({ loaded: true });
+
+ setCurrentPage({ page: 1 });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ }
+ } catch (error) {
+ if (!shouldIgnore) {
+ console.error("Failed to fetch top sounds:", error);
+ setError({
+ error:
+ error instanceof Error ? error.message : "Failed to load sounds",
+ });
+ }
+ } finally {
+ if (!shouldIgnore) {
+ setLoading({ loading: false });
+ }
+ }
+ };
+
+ const timeoutId = setTimeout(fetchTopSounds, 100, {});
+
+ return () => {
+ shouldIgnore = true;
+ clearTimeout(timeoutId);
+ };
+ }, [
+ hasLoaded,
+ setTopSoundEffects,
+ setLoading,
+ setError,
+ setHasLoaded,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ ]);
+
+ useEffect(() => {
+ if (!scrollAreaRef.current || scrollPosition <= 0) {
+ return;
+ }
+
+ const restoreScrollPosition = () => {
+ scrollAreaRef.current?.scrollTo({ top: scrollPosition });
+ };
+
+ const timeoutId = setTimeout(restoreScrollPosition, 100, {});
+
+ return () => clearTimeout(timeoutId);
+ }, [scrollPosition, scrollAreaRef]);
+
+ const handleScrollWithPosition = ({
+ currentTarget,
+ }: React.UIEvent<HTMLDivElement>) => {
+ const { scrollTop } = currentTarget;
+ setScrollPosition({ position: scrollTop });
+ handleScroll({ currentTarget } as React.UIEvent<HTMLDivElement>);
+ };
+
+ const displayedSounds = searchQuery ? searchResults : topSoundEffects;
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center gap-3">
+ <Input
+ placeholder="Search sound effects"
+ className="w-full"
+ containerClassName="w-full"
+ value={searchQuery}
+ onChange={({ currentTarget }) =>
+ setSearchQuery({ query: currentTarget.value })
+ }
+ showClearIcon
+ onClear={() => setSearchQuery({ query: "" })}
+ />
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ className={cn(showCommercialOnly && "text-primary")}
+ >
+ <HugeiconsIcon icon={FilterMailIcon} />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ <DropdownMenuCheckboxItem
+ checked={showCommercialOnly}
+ onCheckedChange={() => toggleCommercialFilter()}
+ >
+ Show only commercially licensed
+ </DropdownMenuCheckboxItem>
+ <div className="text-muted-foreground px-2 py-1.5 text-xs">
+ {showCommercialOnly
+ ? "Only showing sounds licensed for commercial use"
+ : "Showing all sounds regardless of license"}
+ </div>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea
+ className="h-full flex-1"
+ ref={scrollAreaRef}
+ onScrollCapture={handleScrollWithPosition}
+ >
+ <div className="flex flex-col gap-4">
+ {isLoading && !searchQuery && (
+ <div className="text-muted-foreground text-sm">
+ Loading sounds...
+ </div>
+ )}
+ {isSearching && searchQuery && (
+ <div className="text-muted-foreground text-sm">Searching...</div>
+ )}
+ {displayedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={sound}
+ isPlaying={playingId === sound.id}
+ onPlay={playSound}
+ />
+ ))}
+ {!isLoading && !isSearching && displayedSounds.length === 0 && (
+ <div className="text-muted-foreground text-sm">
+ {searchQuery ? "No sounds found" : "No sounds available"}
+ </div>
+ )}
+ {isLoadingMore && (
+ <div className="text-muted-foreground py-4 text-center text-sm">
+ Loading more sounds...
+ </div>
+ )}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SavedSoundsView() {
+ const {
+ savedSounds,
+ isLoadingSavedSounds,
+ savedSoundsError,
+ loadSavedSounds,
+ clearSavedSounds,
+ } = useSoundsStore();
+
+ const [playingId, setPlayingId] = useState<number | null>(null);
+ const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
+ null,
+ );
+
+ const [showClearDialog, setShowClearDialog] = useState(false);
+
+ useEffect(() => {
+ loadSavedSounds();
+ }, [loadSavedSounds]);
+
+ const playSound = ({ sound }: { sound: SoundEffect }) => {
+ if (playingId === sound.id) {
+ audioElement?.pause();
+ setPlayingId(null);
+ return;
+ }
+
+ audioElement?.pause();
+
+ if (sound.previewUrl) {
+ const audio = new Audio(sound.previewUrl);
+ audio.addEventListener("ended", () => {
+ setPlayingId(null);
+ });
+ audio.addEventListener("error", () => {
+ setPlayingId(null);
+ });
+ audio.play().catch((error) => {
+ console.error("Failed to play sound preview:", error);
+ setPlayingId(null);
+ });
+
+ setAudioElement(audio);
+ setPlayingId(sound.id);
+ }
+ };
+
+ const convertToSoundEffect = ({
+ savedSound,
+ }: {
+ savedSound: SavedSound;
+ }): SoundEffect => ({
+ id: savedSound.id,
+ name: savedSound.name,
+ description: "",
+ url: "",
+ previewUrl: savedSound.previewUrl,
+ downloadUrl: savedSound.downloadUrl,
+ duration: savedSound.duration,
+ filesize: 0,
+ type: "audio",
+ channels: 0,
+ bitrate: 0,
+ bitdepth: 0,
+ samplerate: 0,
+ username: savedSound.username,
+ tags: savedSound.tags,
+ license: savedSound.license,
+ created: savedSound.savedAt,
+ downloads: 0,
+ rating: 0,
+ ratingCount: 0,
+ });
+
+ if (isLoadingSavedSounds) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-muted-foreground text-sm">
+ Loading saved sounds...
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSoundsError) {
+ return (
+ <div className="flex h-full items-center justify-center">
+ <div className="text-destructive text-sm">
+ Error: {savedSoundsError}
+ </div>
+ </div>
+ );
+ }
+
+ if (savedSounds.length === 0) {
+ return (
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No saved sounds</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click the heart icon on any sound to save it here
+ </p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="mt-1 flex h-full flex-col gap-5">
+ <div className="flex items-center justify-between">
+ <p className="text-muted-foreground text-sm">
+ {savedSounds.length} saved{" "}
+ {savedSounds.length === 1 ? "sound" : "sounds"}
+ </p>
+ <Dialog open={showClearDialog} onOpenChange={setShowClearDialog}>
+ <DialogTrigger asChild>
+ <Button
+ variant="text"
+ size="sm"
+ className="text-muted-foreground hover:text-destructive h-auto !opacity-100"
+ >
+ Clear all
+ </Button>
+ </DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Clear all saved sounds?</DialogTitle>
+ <DialogDescription>
+ This will permanently remove all {savedSounds.length} saved
+ sounds from your collection. This action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="text" onClick={() => setShowClearDialog(false)}>
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={async ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await clearSavedSounds();
+ setShowClearDialog(false);
+ }}
+ >
+ Clear all sounds
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ <div className="relative h-full overflow-hidden">
+ <ScrollArea className="h-full flex-1">
+ <div className="flex flex-col gap-4">
+ {savedSounds.map((sound) => (
+ <AudioItem
+ key={sound.id}
+ sound={convertToSoundEffect({ savedSound: sound })}
+ isPlaying={playingId === sound.id}
+ onPlay={playSound}
+ />
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+ );
+}
+
+function SongsView() {
+ return <div>Songs</div>;
+}
+
+interface AudioItemProps {
+ sound: SoundEffect;
+ isPlaying: boolean;
+ onPlay: ({ sound }: { sound: SoundEffect }) => void;
+}
+
+function AudioItem({ sound, isPlaying, onPlay }: AudioItemProps) {
+ const { addSoundToTimeline, isSoundSaved, toggleSavedSound } =
+ useSoundsStore();
+ const isSaved = isSoundSaved({ soundId: sound.id });
+
+ const handleClick = () => {
+ onPlay({ sound });
+ };
+
+ const handleSaveClick = ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ toggleSavedSound({ soundEffect: sound });
+ };
+
+ const handleAddToTimeline = async ({
+ stopPropagation,
+ }: React.MouseEvent<HTMLButtonElement>) => {
+ stopPropagation();
+ await addSoundToTimeline({ sound });
+ };
+
+ return (
+ <div className="group flex items-center gap-3 opacity-100 hover:opacity-75">
+ <button
+ type="button"
+ className="flex min-w-0 flex-1 items-center gap-3 text-left"
+ onClick={handleClick}
+ >
+ <div className="bg-accent relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-md">
+ <div className="from-primary/20 absolute inset-0 bg-gradient-to-br to-transparent" />
+ {isPlaying ? (
+ <HugeiconsIcon icon={PauseIcon} className="size-5" />
+ ) : (
+ <HugeiconsIcon icon={PlayIcon} className="size-5" />
+ )}
+ </div>
+
+ <div className="min-w-0 flex-1 overflow-hidden">
+ <p className="truncate text-sm font-medium">{sound.name}</p>
+ <span className="text-muted-foreground block truncate text-xs">
+ {sound.username}
+ </span>
+ </div>
+ </button>
+
+ <div className="flex items-center gap-3 pr-2">
+ <Button
+ variant="text"
+ size="icon"
+ className="text-muted-foreground hover:text-foreground w-auto !opacity-100"
+ onClick={handleAddToTimeline}
+ title="Add to timeline"
+ >
+ <HugeiconsIcon icon={PlusSignIcon} />
+ </Button>
+ <Button
+ variant="text"
+ size="icon"
+ className={`hover:text-foreground w-auto !opacity-100 ${
+ isSaved
+ ? "text-red-500 hover:text-red-600"
+ : "text-muted-foreground"
+ }`}
+ onClick={handleSaveClick}
+ title={isSaved ? "Remove from saved" : "Save sound"}
+ >
+ <HugeiconsIcon
+ icon={FavouriteIcon}
+ className={`${isSaved ? "fill-current" : ""}`}
+ />
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx index af489630..285b37d3 100644 --- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx +++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx @@ -1,470 +1,470 @@ -"use client"; - -import Image from "next/image"; -import type { CSSProperties } from "react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Spinner } from "@/components/ui/spinner"; -import { useEditor } from "@/hooks/use-editor"; -import { resolveStickerIntrinsicSize } from "@/lib/stickers"; -import { - buildGraphicElement, - buildStickerElement, -} from "@/lib/timeline/element-utils"; -import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; -import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers"; -import { parseShapeStickerId } from "@/lib/stickers/providers/shapes"; -import type { TimelineDragData } from "@/lib/timeline/drag"; -import type { - StickerBrowseSection, - StickerCategory, - StickerItem as StickerData, -} from "@/lib/stickers"; -import { useStickersStore } from "@/stores/stickers-store"; -import { cn } from "@/utils/ui"; -import { - HappyIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function StickersView() { - const { - browseContent, - browseStickers, - searchQuery, - searchStickers, - selectedCategory, - setSearchQuery, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - useEffect(() => { - if (viewMode === "browse" && !browseContent) { - void browseStickers(); - } - }, [browseContent, browseStickers, viewMode]); - - return ( - <div className="flex h-full flex-col py-2"> - <div className="px-2"> - <Input - size="sm" - variant="default" - placeholder="Search..." - value={searchQuery} - onChange={(e) => { - setSearchQuery({ query: e.target.value }); - void searchStickers({ query: e.target.value }); - }} - showClearIcon - onClear={() => { - setSearchQuery({ query: "" }); - void searchStickers({ query: "" }); - }} - className="w-full" - containerClassName="w-full" - /> - </div> - - <div className="mt-2 flex min-h-0 flex-1 flex-col"> - <div className="border-b border-border px-2"> - <div - role="tablist" - aria-label="Sticker categories" - className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0" - > - {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { - const isActive = key === selectedCategory; - return ( - <button - key={key} - type="button" - role="tab" - aria-selected={isActive} - onClick={() => - setSelectedCategory({ - category: key as StickerCategory, - }) - } - className={cn( - "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px", - isActive && "text-primary border-primary", - )} - > - {label} - </button> - ); - })} - </div> - </div> - <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4"> - <StickersContentView /> - </div> - </div> - </div> - ); -} - -function StickerGrid({ - items, - shouldCapSize = false, -}: { - items: StickerData[]; - shouldCapSize?: boolean; -}) { - const gridStyle: CSSProperties & { - "--sticker-min": string; - "--sticker-max"?: string; - } = { - gridTemplateColumns: shouldCapSize - ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))" - : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))", - "--sticker-min": "80px", - ...(shouldCapSize ? { "--sticker-max": "140px" } : {}), - }; - - return ( - <div className="grid gap-2" style={gridStyle}> - {items.map((item) => ( - <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} /> - ))} - </div> - ); -} - -function StickerRow({ items }: { items: StickerData[] }) { - return ( - <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden"> - {items.map((item) => ( - <div key={item.id} className="w-20 shrink-0"> - <StickerItem item={item} shouldCapSize containerClassName="w-full" /> - </div> - ))} - </div> - ); -} - -function EmptyView({ message }: { message: string }) { - return ( - <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={HappyIcon} - className="text-muted-foreground size-10" - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium">No stickers found</p> - <p className="text-muted-foreground text-sm text-balance">{message}</p> - </div> - </div> - ); -} - -function RegionBanner({ region }: { region: string }) { - return ( - <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2"> - <svg - width="12" - height="12" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - className="shrink-0 text-sky-600" - aria-hidden="true" - > - <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /> - <circle cx="12" cy="10" r="3" /> - </svg> - <span className="text-xs font-semibold text-sky-600">{region}</span> - </div> - ); -} - -function StickersContentView() { - const { - browseContent, - clearRecentStickers, - isBrowsing, - isSearching, - searchQuery, - searchResults, - selectedCategory, - setSelectedCategory, - viewMode, - } = useStickersStore(); - - if (viewMode === "search") { - if (isSearching) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ); - } - - if (searchResults?.items.length) { - const normalizedQuery = searchQuery.trim().toLowerCase(); - const isRegionSearch = - selectedCategory === "flags" && - resolveQueryToRegions({ query: normalizedQuery }) !== null; - const regionLabel = getRegionLabel({ query: normalizedQuery }); - - return ( - <div className="flex flex-col gap-3 pb-4"> - {isRegionSearch && <RegionBanner region={regionLabel} />} - <div className="flex items-center justify-between"> - <span className="text-muted-foreground text-sm"> - {searchResults.total} results - </span> - </div> - <StickerGrid items={searchResults.items} /> - </div> - ); - } - - // "all" tab search — sections are in browseContent, fall through to section rendering below - if (selectedCategory !== "all" && searchQuery) { - return <EmptyView message={`No stickers found for "${searchQuery}"`} />; - } - } - - if (isBrowsing && !browseContent) { - return ( - <div className="flex items-center justify-center py-8"> - <Spinner className="text-muted-foreground size-6" /> - </div> - ); - } - - if (!browseContent?.sections.length) { - const categoryLabel = STICKER_CATEGORIES[selectedCategory]; - return ( - <EmptyView - message={ - viewMode === "search" - ? `No stickers found for "${searchQuery}"` - : selectedCategory === "all" - ? "No stickers available yet." - : `No stickers available in ${categoryLabel.toLowerCase()} yet.` - } - /> - ); - } - - return ( - <div className="flex flex-col gap-4 pb-4"> - {browseContent.sections.map((section) => ( - <StickerSection - key={section.id} - section={section} - onClearRecent={clearRecentStickers} - onSeeAll={(category) => { - setSelectedCategory({ category }); - }} - /> - ))} - </div> - ); -} - -function StickerSection({ - section, - onClearRecent, - onSeeAll, -}: { - section: StickerBrowseSection; - onClearRecent: () => void; - onSeeAll: (category: StickerCategory) => void; -}) { - const hasHeader = - Boolean(section.title) || section.id === "recent" || section.action; - - return ( - <div className="flex flex-col gap-2"> - {hasHeader && ( - <div className="flex items-center justify-between gap-2"> - {section.title ? ( - <p className="text-xs text-muted-foreground">{section.title}</p> - ) : ( - <div /> - )} - - <div className="ml-auto flex items-center gap-2"> - {section.id === "recent" && ( - <Button - onClick={onClearRecent} - variant="text" - size="sm" - className="h-auto gap-1 p-0 text-xs text-muted-foreground" - > - Clear - </Button> - )} - - {section.action?.type === "see-all" && section.action.category && ( - <Button - variant="text" - size="sm" - className="h-auto gap-1 p-0 text-xs text-primary" - onClick={() => { - onSeeAll(section.action?.category as StickerCategory); - }} - > - See all - </Button> - )} - </div> - </div> - )} - - {section.layout === "row" ? ( - <StickerRow items={section.items} /> - ) : ( - <StickerGrid items={section.items} /> - )} - </div> - ); -} - -interface StickerItemProps { - item: StickerData; - shouldCapSize?: boolean; - containerClassName?: string; -} - -function StickerItem({ - item, - shouldCapSize = false, - containerClassName, -}: StickerItemProps) { - const editor = useEditor(); - const { addToRecentStickers } = useStickersStore(); - const [isAdding, setIsAdding] = useState(false); - const [hasImageError, setHasImageError] = useState(false); - - useEffect(() => { - if (!item.id) { - return; - } - - setHasImageError(false); - }, [item.id]); - - const displayName = item.name; - const shapePreset = - item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null; - - const handleAdd = async () => { - setIsAdding(true); - try { - const currentTime = editor.playback.getCurrentTime(); - - let element: - | ReturnType<typeof buildGraphicElement> - | ReturnType<typeof buildStickerElement>; - if (shapePreset) { - element = buildGraphicElement({ - definitionId: shapePreset.definitionId, - name: shapePreset.name, - startTime: currentTime, - params: shapePreset.params, - }); - } else { - const { width: intrinsicWidth, height: intrinsicHeight } = - await resolveStickerIntrinsicSize({ stickerId: item.id }); - element = buildStickerElement({ - stickerId: item.id, - name: item.name, - startTime: currentTime, - intrinsicWidth, - intrinsicHeight, - }); - } - - editor.timeline.insertElement({ - placement: { mode: "auto" }, - element, - }); - - addToRecentStickers({ stickerId: item.id }); - } catch (error) { - console.error("Failed to add sticker:", error); - toast.error("Failed to add sticker to timeline"); - } finally { - setIsAdding(false); - } - }; - - const preview = ( - <div className="flex size-full items-center justify-center p-3"> - {hasImageError ? ( - <span className="text-muted-foreground text-center text-xs break-all"> - {displayName} - </span> - ) : ( - <Image - src={item.previewUrl} - alt={displayName} - width={64} - height={64} - className="size-full object-contain" - style={ - shouldCapSize - ? { - maxWidth: "var(--sticker-max, 160px)", - maxHeight: "var(--sticker-max, 160px)", - } - : undefined - } - onError={() => setHasImageError(true)} - loading="lazy" - unoptimized - /> - )} - </div> - ); - - const dragData: TimelineDragData = shapePreset - ? { - id: item.id, - type: "graphic", - name: displayName, - definitionId: shapePreset.definitionId, - params: shapePreset.params ?? {}, - } - : { - id: item.id, - type: "sticker", - name: displayName, - stickerId: item.id, - }; - - return ( - <div - className={cn("relative", isAdding && "pointer-events-none opacity-50")} - > - <DraggableItem - name={displayName} - preview={preview} - dragData={dragData} - onAddToTimeline={handleAdd} - aspectRatio={1} - shouldShowLabel={false} - isRounded - variant="card" - containerClassName={containerClassName ?? "w-full"} - /> - {isAdding && ( - <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60"> - <Spinner className="size-6 text-white" /> - </div> - )} - </div> - ); -} +"use client";
+
+import Image from "next/image";
+import type { CSSProperties } from "react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Spinner } from "@/components/ui/spinner";
+import { useEditor } from "@/hooks/use-editor";
+import { resolveStickerIntrinsicSize } from "@/lib/stickers";
+import {
+ buildGraphicElement,
+ buildStickerElement,
+} from "@/lib/timeline/element-utils";
+import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
+import { getRegionLabel, resolveQueryToRegions } from "@/lib/stickers";
+import { parseShapeStickerId } from "@/lib/stickers/providers/shapes";
+import type { TimelineDragData } from "@/lib/timeline/drag";
+import type {
+ StickerBrowseSection,
+ StickerCategory,
+ StickerItem as StickerData,
+} from "@/lib/stickers";
+import { useStickersStore } from "@/stores/stickers-store";
+import { cn } from "@/utils/ui";
+import {
+ HappyIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function StickersView() {
+ const {
+ browseContent,
+ browseStickers,
+ searchQuery,
+ searchStickers,
+ selectedCategory,
+ setSearchQuery,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ useEffect(() => {
+ if (viewMode === "browse" && !browseContent) {
+ void browseStickers();
+ }
+ }, [browseContent, browseStickers, viewMode]);
+
+ return (
+ <div className="flex h-full flex-col py-2">
+ <div className="px-2">
+ <Input
+ size="sm"
+ variant="default"
+ placeholder="Search..."
+ value={searchQuery}
+ onChange={(e) => {
+ setSearchQuery({ query: e.target.value });
+ void searchStickers({ query: e.target.value });
+ }}
+ showClearIcon
+ onClear={() => {
+ setSearchQuery({ query: "" });
+ void searchStickers({ query: "" });
+ }}
+ className="w-full"
+ containerClassName="w-full"
+ />
+ </div>
+
+ <div className="mt-2 flex min-h-0 flex-1 flex-col">
+ <div className="border-b border-border px-2">
+ <div
+ role="tablist"
+ aria-label="Sticker categories"
+ className="text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0"
+ >
+ {Object.entries(STICKER_CATEGORIES).map(([key, label]) => {
+ const isActive = key === selectedCategory;
+ return (
+ <button
+ key={key}
+ type="button"
+ role="tab"
+ aria-selected={isActive}
+ onClick={() =>
+ setSelectedCategory({
+ category: key as StickerCategory,
+ })
+ }
+ className={cn(
+ "text-muted-foreground rounded-none border-b-2 border-transparent px-3 py-2 text-sm font-medium whitespace-nowrap -mb-px",
+ isActive && "text-primary border-primary",
+ )}
+ >
+ {label}
+ </button>
+ );
+ })}
+ </div>
+ </div>
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 pt-4">
+ <StickersContentView />
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function StickerGrid({
+ items,
+ shouldCapSize = false,
+}: {
+ items: StickerData[];
+ shouldCapSize?: boolean;
+}) {
+ const gridStyle: CSSProperties & {
+ "--sticker-min": string;
+ "--sticker-max"?: string;
+ } = {
+ gridTemplateColumns: shouldCapSize
+ ? "repeat(auto-fill, minmax(var(--sticker-min, 80px), var(--sticker-max, 140px)))"
+ : "repeat(auto-fill, minmax(var(--sticker-min, 80px), 1fr))",
+ "--sticker-min": "80px",
+ ...(shouldCapSize ? { "--sticker-max": "140px" } : {}),
+ };
+
+ return (
+ <div className="grid gap-2" style={gridStyle}>
+ {items.map((item) => (
+ <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
+ ))}
+ </div>
+ );
+}
+
+function StickerRow({ items }: { items: StickerData[] }) {
+ return (
+ <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hidden">
+ {items.map((item) => (
+ <div key={item.id} className="w-20 shrink-0">
+ <StickerItem item={item} shouldCapSize containerClassName="w-full" />
+ </div>
+ ))}
+ </div>
+ );
+}
+
+function EmptyView({ message }: { message: string }) {
+ return (
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={HappyIcon}
+ className="text-muted-foreground size-10"
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium">No stickers found</p>
+ <p className="text-muted-foreground text-sm text-balance">{message}</p>
+ </div>
+ </div>
+ );
+}
+
+function RegionBanner({ region }: { region: string }) {
+ return (
+ <div className="flex h-7 items-center gap-1.5 rounded-lg border border-sky-100 bg-sky-50 px-2">
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ className="shrink-0 text-sky-600"
+ aria-hidden="true"
+ >
+ <path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
+ <circle cx="12" cy="10" r="3" />
+ </svg>
+ <span className="text-xs font-semibold text-sky-600">{region}</span>
+ </div>
+ );
+}
+
+function StickersContentView() {
+ const {
+ browseContent,
+ clearRecentStickers,
+ isBrowsing,
+ isSearching,
+ searchQuery,
+ searchResults,
+ selectedCategory,
+ setSelectedCategory,
+ viewMode,
+ } = useStickersStore();
+
+ if (viewMode === "search") {
+ if (isSearching) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (searchResults?.items.length) {
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+ const isRegionSearch =
+ selectedCategory === "flags" &&
+ resolveQueryToRegions({ query: normalizedQuery }) !== null;
+ const regionLabel = getRegionLabel({ query: normalizedQuery });
+
+ return (
+ <div className="flex flex-col gap-3 pb-4">
+ {isRegionSearch && <RegionBanner region={regionLabel} />}
+ <div className="flex items-center justify-between">
+ <span className="text-muted-foreground text-sm">
+ {searchResults.total} results
+ </span>
+ </div>
+ <StickerGrid items={searchResults.items} />
+ </div>
+ );
+ }
+
+ // "all" tab search — sections are in browseContent, fall through to section rendering below
+ if (selectedCategory !== "all" && searchQuery) {
+ return <EmptyView message={`No stickers found for "${searchQuery}"`} />;
+ }
+ }
+
+ if (isBrowsing && !browseContent) {
+ return (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ );
+ }
+
+ if (!browseContent?.sections.length) {
+ const categoryLabel = STICKER_CATEGORIES[selectedCategory];
+ return (
+ <EmptyView
+ message={
+ viewMode === "search"
+ ? `No stickers found for "${searchQuery}"`
+ : selectedCategory === "all"
+ ? "No stickers available yet."
+ : `No stickers available in ${categoryLabel.toLowerCase()} yet.`
+ }
+ />
+ );
+ }
+
+ return (
+ <div className="flex flex-col gap-4 pb-4">
+ {browseContent.sections.map((section) => (
+ <StickerSection
+ key={section.id}
+ section={section}
+ onClearRecent={clearRecentStickers}
+ onSeeAll={(category) => {
+ setSelectedCategory({ category });
+ }}
+ />
+ ))}
+ </div>
+ );
+}
+
+function StickerSection({
+ section,
+ onClearRecent,
+ onSeeAll,
+}: {
+ section: StickerBrowseSection;
+ onClearRecent: () => void;
+ onSeeAll: (category: StickerCategory) => void;
+}) {
+ const hasHeader =
+ Boolean(section.title) || section.id === "recent" || section.action;
+
+ return (
+ <div className="flex flex-col gap-2">
+ {hasHeader && (
+ <div className="flex items-center justify-between gap-2">
+ {section.title ? (
+ <p className="text-xs text-muted-foreground">{section.title}</p>
+ ) : (
+ <div />
+ )}
+
+ <div className="ml-auto flex items-center gap-2">
+ {section.id === "recent" && (
+ <Button
+ onClick={onClearRecent}
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-muted-foreground"
+ >
+ Clear
+ </Button>
+ )}
+
+ {section.action?.type === "see-all" && section.action.category && (
+ <Button
+ variant="text"
+ size="sm"
+ className="h-auto gap-1 p-0 text-xs text-primary"
+ onClick={() => {
+ onSeeAll(section.action?.category as StickerCategory);
+ }}
+ >
+ See all
+ </Button>
+ )}
+ </div>
+ </div>
+ )}
+
+ {section.layout === "row" ? (
+ <StickerRow items={section.items} />
+ ) : (
+ <StickerGrid items={section.items} />
+ )}
+ </div>
+ );
+}
+
+interface StickerItemProps {
+ item: StickerData;
+ shouldCapSize?: boolean;
+ containerClassName?: string;
+}
+
+function StickerItem({
+ item,
+ shouldCapSize = false,
+ containerClassName,
+}: StickerItemProps) {
+ const editor = useEditor();
+ const { addToRecentStickers } = useStickersStore();
+ const [isAdding, setIsAdding] = useState(false);
+ const [hasImageError, setHasImageError] = useState(false);
+
+ useEffect(() => {
+ if (!item.id) {
+ return;
+ }
+
+ setHasImageError(false);
+ }, [item.id]);
+
+ const displayName = item.name;
+ const shapePreset =
+ item.provider === "shapes" ? parseShapeStickerId({ stickerId: item.id }) : null;
+
+ const handleAdd = async () => {
+ setIsAdding(true);
+ try {
+ const currentTime = editor.playback.getCurrentTime();
+
+ let element:
+ | ReturnType<typeof buildGraphicElement>
+ | ReturnType<typeof buildStickerElement>;
+ if (shapePreset) {
+ element = buildGraphicElement({
+ definitionId: shapePreset.definitionId,
+ name: shapePreset.name,
+ startTime: currentTime,
+ params: shapePreset.params,
+ });
+ } else {
+ const { width: intrinsicWidth, height: intrinsicHeight } =
+ await resolveStickerIntrinsicSize({ stickerId: item.id });
+ element = buildStickerElement({
+ stickerId: item.id,
+ name: item.name,
+ startTime: currentTime,
+ intrinsicWidth,
+ intrinsicHeight,
+ });
+ }
+
+ editor.timeline.insertElement({
+ placement: { mode: "auto" },
+ element,
+ });
+
+ addToRecentStickers({ stickerId: item.id });
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ } finally {
+ setIsAdding(false);
+ }
+ };
+
+ const preview = (
+ <div className="flex size-full items-center justify-center p-3">
+ {hasImageError ? (
+ <span className="text-muted-foreground text-center text-xs break-all">
+ {displayName}
+ </span>
+ ) : (
+ <Image
+ src={item.previewUrl}
+ alt={displayName}
+ width={64}
+ height={64}
+ className="size-full object-contain"
+ style={
+ shouldCapSize
+ ? {
+ maxWidth: "var(--sticker-max, 160px)",
+ maxHeight: "var(--sticker-max, 160px)",
+ }
+ : undefined
+ }
+ onError={() => setHasImageError(true)}
+ loading="lazy"
+ unoptimized
+ />
+ )}
+ </div>
+ );
+
+ const dragData: TimelineDragData = shapePreset
+ ? {
+ id: item.id,
+ type: "graphic",
+ name: displayName,
+ definitionId: shapePreset.definitionId,
+ params: shapePreset.params ?? {},
+ }
+ : {
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ };
+
+ return (
+ <div
+ className={cn("relative", isAdding && "pointer-events-none opacity-50")}
+ >
+ <DraggableItem
+ name={displayName}
+ preview={preview}
+ dragData={dragData}
+ onAddToTimeline={handleAdd}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded
+ variant="card"
+ containerClassName={containerClassName ?? "w-full"}
+ />
+ {isAdding && (
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
+ <Spinner className="size-6 text-white" />
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx index 075cc25b..da295da3 100644 --- a/apps/web/src/components/editor/panels/assets/views/text.tsx +++ b/apps/web/src/components/editor/panels/assets/views/text.tsx @@ -1,46 +1,46 @@ -import { DraggableItem } from "@/components/editor/panels/assets/draggable-item"; -import { PanelView } from "@/components/editor/panels/assets/views/base-panel"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { buildTextElement } from "@/lib/timeline/element-utils"; - -export function TextView() { - const editor = useEditor(); - - const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => { - const activeScene = editor.scenes.getActiveScene(); - if (!activeScene) return; - - const element = buildTextElement({ - raw: DEFAULTS.text.element, - startTime: currentTime, - }); - - editor.timeline.insertElement({ - element, - placement: { mode: "auto" }, - }); - }; - - return ( - <PanelView title="Text"> - <DraggableItem - name="Default text" - preview={ - <div className="bg-accent flex size-full items-center justify-center rounded"> - <span className="text-xs select-none">Default text</span> - </div> - } - dragData={{ - id: "temp-text-id", - type: DEFAULTS.text.element.type, - name: DEFAULTS.text.element.name, - content: DEFAULTS.text.element.content, - }} - aspectRatio={1} - onAddToTimeline={handleAddToTimeline} - shouldShowLabel={false} - /> - </PanelView> - ); -} +import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
+import { PanelView } from "@/components/editor/panels/assets/views/base-panel";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { buildTextElement } from "@/lib/timeline/element-utils";
+
+export function TextView() {
+ const editor = useEditor();
+
+ const handleAddToTimeline = ({ currentTime }: { currentTime: number }) => {
+ const activeScene = editor.scenes.getActiveScene();
+ if (!activeScene) return;
+
+ const element = buildTextElement({
+ raw: DEFAULTS.text.element,
+ startTime: currentTime,
+ });
+
+ editor.timeline.insertElement({
+ element,
+ placement: { mode: "auto" },
+ });
+ };
+
+ return (
+ <PanelView title="Text">
+ <DraggableItem
+ name="Default text"
+ preview={
+ <div className="bg-accent flex size-full items-center justify-center rounded">
+ <span className="text-xs select-none">Default text</span>
+ </div>
+ }
+ dragData={{
+ id: "temp-text-id",
+ type: DEFAULTS.text.element.type,
+ name: DEFAULTS.text.element.name,
+ content: DEFAULTS.text.element.content,
+ }}
+ aspectRatio={1}
+ onAddToTimeline={handleAddToTimeline}
+ shouldShowLabel={false}
+ />
+ </PanelView>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx index 4ace25ac..3d96f762 100644 --- a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx @@ -1,59 +1,59 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { findCurrentScene } from "@/lib/scenes"; -import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks"; - -export function BookmarkNoteOverlay() { - const editor = useEditor(); - const currentTime = editor.playback.getCurrentTime(); - const activeProject = editor.project.getActive(); - - if (!activeProject) { - return null; - } - - const activeScene = findCurrentScene({ - scenes: activeProject.scenes, - currentSceneId: activeProject.currentSceneId, - }); - - if (!activeScene) { - return null; - } - - const bookmarks = activeScene.bookmarks; - const activeBookmarks = getBookmarksActiveAtTime({ - bookmarks, - time: currentTime, - }); - const bookmarksWithNotes = activeBookmarks.filter( - (bookmark) => bookmark.note != null && bookmark.note.trim() !== "", - ); - - if (bookmarksWithNotes.length === 0) { - return null; - } - - return ( - <div - className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5" - aria-live="polite" - > - {bookmarksWithNotes.map((bookmark) => ( - <div - key={bookmark.time} - className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm" - style={{ - backgroundColor: "rgb(0 0 0 / 0.5)", - borderLeft: bookmark.color - ? `3px solid ${bookmark.color}` - : "3px solid var(--primary)", - }} - > - {bookmark.note} - </div> - ))} - </div> - ); -} +"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import { findCurrentScene } from "@/lib/scenes";
+import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
+
+export function BookmarkNoteOverlay() {
+ const editor = useEditor();
+ const currentTime = editor.playback.getCurrentTime();
+ const activeProject = editor.project.getActive();
+
+ if (!activeProject) {
+ return null;
+ }
+
+ const activeScene = findCurrentScene({
+ scenes: activeProject.scenes,
+ currentSceneId: activeProject.currentSceneId,
+ });
+
+ if (!activeScene) {
+ return null;
+ }
+
+ const bookmarks = activeScene.bookmarks;
+ const activeBookmarks = getBookmarksActiveAtTime({
+ bookmarks,
+ time: currentTime,
+ });
+ const bookmarksWithNotes = activeBookmarks.filter(
+ (bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
+ );
+
+ if (bookmarksWithNotes.length === 0) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
+ aria-live="polite"
+ >
+ {bookmarksWithNotes.map((bookmark) => (
+ <div
+ key={bookmark.time}
+ className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
+ style={{
+ backgroundColor: "rgb(0 0 0 / 0.5)",
+ borderLeft: bookmark.color
+ ? `3px solid ${bookmark.color}`
+ : "3px solid var(--primary)",
+ }}
+ >
+ {bookmark.note}
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx index 13226378..677f35db 100644 --- a/apps/web/src/components/editor/panels/preview/context-menu.tsx +++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx @@ -1,73 +1,73 @@ -"use client"; - -import { - ContextMenuCheckboxItem, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, -} from "@/components/ui/context-menu"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useEditor } from "@/hooks/use-editor"; -import { usePreviewStore } from "@/stores/preview-store"; -import { toast } from "sonner"; - -export function PreviewContextMenu({ - onToggleFullscreen, - containerRef, -}: { - onToggleFullscreen: () => void; - containerRef: React.RefObject<HTMLElement | null>; -}) { - const editor = useEditor(); - const viewport = usePreviewViewport(); - const { overlays, setOverlayVisibility } = usePreviewStore(); - - const handleCopySnapshot = async () => { - const result = await editor.renderer.copySnapshot(); - - if (!result.success) { - toast.error("Failed to copy snapshot", { - description: result.error ?? "Please try again", - }); - return; - } - }; - - const handleSaveSnapshot = async () => { - const result = await editor.renderer.saveSnapshot(); - - if (!result.success) { - toast.error("Failed to save snapshot", { - description: result.error ?? "Please try again", - }); - return; - } - }; - - return ( - <ContextMenuContent className="w-56" container={containerRef.current}> - <ContextMenuItem onClick={viewport.fitToScreen} inset> - Fit to screen - </ContextMenuItem> - <ContextMenuSeparator /> - <ContextMenuItem onClick={onToggleFullscreen} inset> - Full screen - </ContextMenuItem> - <ContextMenuItem onClick={handleSaveSnapshot} inset> - Save snapshot - </ContextMenuItem> - <ContextMenuItem onClick={handleCopySnapshot} inset> - Copy snapshot - </ContextMenuItem> - <ContextMenuSeparator /> - <ContextMenuCheckboxItem - checked={overlays.bookmarks} - onCheckedChange={(checked) => - setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked }) - } - > - Show bookmark notes - </ContextMenuCheckboxItem> - </ContextMenuContent> - ); -} +"use client";
+
+import {
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+} from "@/components/ui/context-menu";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useEditor } from "@/hooks/use-editor";
+import { usePreviewStore } from "@/stores/preview-store";
+import { toast } from "sonner";
+
+export function PreviewContextMenu({
+ onToggleFullscreen,
+ containerRef,
+}: {
+ onToggleFullscreen: () => void;
+ containerRef: React.RefObject<HTMLElement | null>;
+}) {
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const { overlays, setOverlayVisibility } = usePreviewStore();
+
+ const handleCopySnapshot = async () => {
+ const result = await editor.renderer.copySnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to copy snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
+ const handleSaveSnapshot = async () => {
+ const result = await editor.renderer.saveSnapshot();
+
+ if (!result.success) {
+ toast.error("Failed to save snapshot", {
+ description: result.error ?? "Please try again",
+ });
+ return;
+ }
+ };
+
+ return (
+ <ContextMenuContent className="w-56" container={containerRef.current}>
+ <ContextMenuItem onClick={viewport.fitToScreen} inset>
+ Fit to screen
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuItem onClick={onToggleFullscreen} inset>
+ Full screen
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handleSaveSnapshot} inset>
+ Save snapshot
+ </ContextMenuItem>
+ <ContextMenuItem onClick={handleCopySnapshot} inset>
+ Copy snapshot
+ </ContextMenuItem>
+ <ContextMenuSeparator />
+ <ContextMenuCheckboxItem
+ checked={overlays.bookmarks}
+ onCheckedChange={(checked) =>
+ setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
+ }
+ >
+ Show bookmark notes
+ </ContextMenuCheckboxItem>
+ </ContextMenuContent>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx index ad7a621f..4270fcb3 100644 --- a/apps/web/src/components/editor/panels/preview/guide-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/guide-overlay.tsx @@ -1,32 +1,32 @@ -"use client"; - -import { getGuideById } from "@/lib/guides"; -import { usePreviewStore } from "@/stores/preview-store"; -import { usePreviewViewport } from "./preview-viewport"; - -export function GuideOverlay() { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const viewport = usePreviewViewport(); - const guide = getGuideById(activeGuide); - - if (!guide) { - return null; - } - - return ( - <div - className="pointer-events-none absolute" - style={{ - left: viewport.sceneLeft, - top: viewport.sceneTop, - width: viewport.sceneWidth, - height: viewport.sceneHeight, - }} - > - {guide.renderOverlay({ - width: viewport.sceneWidth, - height: viewport.sceneHeight, - })} - </div> - ); -} +"use client";
+
+import { getGuideById } from "@/lib/guides";
+import { usePreviewStore } from "@/stores/preview-store";
+import { usePreviewViewport } from "./preview-viewport";
+
+export function GuideOverlay() {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const viewport = usePreviewViewport();
+ const guide = getGuideById(activeGuide);
+
+ if (!guide) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ left: viewport.sceneLeft,
+ top: viewport.sceneTop,
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ }}
+ >
+ {guide.renderOverlay({
+ width: viewport.sceneWidth,
+ height: viewport.sceneHeight,
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/guide-popover.tsx b/apps/web/src/components/editor/panels/preview/guide-popover.tsx index e6d3df51..bcac8075 100644 --- a/apps/web/src/components/editor/panels/preview/guide-popover.tsx +++ b/apps/web/src/components/editor/panels/preview/guide-popover.tsx @@ -1,100 +1,100 @@ -"use client"; - -import { AnimatePresence, motion } from "motion/react"; -import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides"; -import { usePreviewStore } from "@/stores/preview-store"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/utils/ui"; - -export function GridPopover({ children }: { children: React.ReactNode }) { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const toggleGuide = usePreviewStore((state) => state.toggleGuide); - const activeGuideDef = getGuideById(activeGuide); - const options = activeGuideDef?.renderOptions?.(); - - return ( - <Popover> - <PopoverTrigger>{children}</PopoverTrigger> - <PopoverContent sideOffset={8} className="w-60 px-0"> - <div className="flex flex-col gap-2 px-4"> - <Label>Guides</Label> - <div className="grid grid-cols-3 gap-1"> - {GUIDE_REGISTRY.map((guide) => ( - <GridItem - key={guide.id} - label={guide.label} - preview={guide.renderPreview()} - isSelected={activeGuide === guide.id} - onClick={() => toggleGuide(guide.id)} - /> - ))} - </div> - </div> - <AnimatePresence> - {options && ( - <motion.div - initial={{ height: 0, opacity: 0 }} - animate={{ height: "auto", opacity: 1 }} - exit={{ height: 0, opacity: 0 }} - transition={{ - height: { type: "spring", duration: 0.2, bounce: 0 }, - opacity: { duration: 0.2 }, - }} - className="overflow-hidden" - > - <Separator className="my-3" /> - <div className="px-4">{options}</div> - </motion.div> - )} - </AnimatePresence> - </PopoverContent> - </Popover> - ); -} - -function GridItem({ - label, - preview, - isSelected, - onClick, -}: { - label: string; - preview: React.ReactNode; - isSelected?: boolean; - onClick: () => void; -}) { - return ( - <button - type="button" - onClick={onClick} - aria-pressed={isSelected} - className={cn( - "flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5", - isSelected && "bg-primary/5! text-primary", - )} - > - <div - className={cn( - "aspect-video bg-foreground/5 flex items-center justify-center rounded-sm", - isSelected && "bg-primary/5!", - )} - > - {preview} - </div> - <span - className={cn( - "text-xs", - isSelected ? "text-primary" : "text-muted-foreground", - )} - > - {label} - </span> - </button> - ); -} +"use client";
+
+import { AnimatePresence, motion } from "motion/react";
+import { GUIDE_REGISTRY, getGuideById } from "@/lib/guides";
+import { usePreviewStore } from "@/stores/preview-store";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Label } from "@/components/ui/label";
+import { Separator } from "@/components/ui/separator";
+import { cn } from "@/utils/ui";
+
+export function GridPopover({ children }: { children: React.ReactNode }) {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const toggleGuide = usePreviewStore((state) => state.toggleGuide);
+ const activeGuideDef = getGuideById(activeGuide);
+ const options = activeGuideDef?.renderOptions?.();
+
+ return (
+ <Popover>
+ <PopoverTrigger>{children}</PopoverTrigger>
+ <PopoverContent sideOffset={8} className="w-60 px-0">
+ <div className="flex flex-col gap-2 px-4">
+ <Label>Guides</Label>
+ <div className="grid grid-cols-3 gap-1">
+ {GUIDE_REGISTRY.map((guide) => (
+ <GridItem
+ key={guide.id}
+ label={guide.label}
+ preview={guide.renderPreview()}
+ isSelected={activeGuide === guide.id}
+ onClick={() => toggleGuide(guide.id)}
+ />
+ ))}
+ </div>
+ </div>
+ <AnimatePresence>
+ {options && (
+ <motion.div
+ initial={{ height: 0, opacity: 0 }}
+ animate={{ height: "auto", opacity: 1 }}
+ exit={{ height: 0, opacity: 0 }}
+ transition={{
+ height: { type: "spring", duration: 0.2, bounce: 0 },
+ opacity: { duration: 0.2 },
+ }}
+ className="overflow-hidden"
+ >
+ <Separator className="my-3" />
+ <div className="px-4">{options}</div>
+ </motion.div>
+ )}
+ </AnimatePresence>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function GridItem({
+ label,
+ preview,
+ isSelected,
+ onClick,
+}: {
+ label: string;
+ preview: React.ReactNode;
+ isSelected?: boolean;
+ onClick: () => void;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ aria-pressed={isSelected}
+ className={cn(
+ "flex cursor-pointer rounded-sm flex-col gap-2 px-1.5 py-1.5 hover:bg-foreground/5",
+ isSelected && "bg-primary/5! text-primary",
+ )}
+ >
+ <div
+ className={cn(
+ "aspect-video bg-foreground/5 flex items-center justify-center rounded-sm",
+ isSelected && "bg-primary/5!",
+ )}
+ >
+ {preview}
+ </div>
+ <span
+ className={cn(
+ "text-xs",
+ isSelected ? "text-primary" : "text-muted-foreground",
+ )}
+ >
+ {label}
+ </span>
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx index 44b8a7f1..d4de3332 100644 --- a/apps/web/src/components/editor/panels/preview/handle-primitives.tsx +++ b/apps/web/src/components/editor/panels/preview/handle-primitives.tsx @@ -1,332 +1,332 @@ -"use client"; - -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import type { IconSvgElement } from "@hugeicons/react"; - -export const HANDLE_SIZE = 10; -export const HANDLE_HIT_AREA_SIZE = 18; -export const ICON_HANDLE_RADIUS = 10; -export const EDGE_HANDLE_THIN_SIZE = 6; -export const EDGE_HANDLE_THICK_SIZE = 14; -export const LINE_HIT_AREA_SIZE = 48; - -export function getResizeCursor({ angleDeg }: { angleDeg: number }): string { - const normalized = ((angleDeg % 180) + 180) % 180; - if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize"; - if (normalized < 67.5) return "cursor-nwse-resize"; - if (normalized < 112.5) return "cursor-ns-resize"; - return "cursor-nesw-resize"; -} - -export function HandleButton({ - screen, - cursor, - hitAreaSize, - className, - onPointerDown, - onPointerMove, - onPointerUp, - children, -}: { - screen: { x: number; y: number }; - cursor?: string; - hitAreaSize: number; - className?: string; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; - children: React.ReactNode; -}) { - return ( - <button - type="button" - className={cn( - "absolute flex items-center justify-center outline-none", - cursor, - className, - )} - style={{ - left: screen.x - hitAreaSize / 2, - top: screen.y - hitAreaSize / 2, - width: hitAreaSize, - height: hitAreaSize, - pointerEvents: "auto", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - onKeyDown={(event) => event.key === "Enter" && event.preventDefault()} - onKeyUp={(event) => event.key === "Enter" && event.preventDefault()} - > - {children} - </button> - ); -} - -export function CornerHandle({ - cursor, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - cursor?: string; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - <HandleButton - screen={screen} - cursor={cursor} - hitAreaSize={HANDLE_HIT_AREA_SIZE} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <div - className="rounded-sm bg-white" - style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }} - /> - </HandleButton> - ); -} - -export function EdgeHandle({ - edge, - screen, - rotation, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - edge: "right" | "left" | "bottom"; - screen: { x: number; y: number }; - rotation: number; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - const isHorizontalEdge = edge === "right" || edge === "left"; - const width = isHorizontalEdge - ? EDGE_HANDLE_THIN_SIZE - : EDGE_HANDLE_THICK_SIZE; - const height = isHorizontalEdge - ? EDGE_HANDLE_THICK_SIZE - : EDGE_HANDLE_THIN_SIZE; - const cursor = getResizeCursor({ - angleDeg: isHorizontalEdge ? rotation : rotation + 90, - }); - - return ( - <HandleButton - screen={screen} - cursor={cursor} - hitAreaSize={HANDLE_HIT_AREA_SIZE} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <div - className="rounded-sm bg-white" - style={{ width, height, transform: `rotate(${rotation}deg)` }} - /> - </HandleButton> - ); -} - -export function IconHandle({ - icon, - screen, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - icon: IconSvgElement; - screen: { x: number; y: number }; - onPointerDown: (event: React.PointerEvent) => void; - onPointerMove: (event: React.PointerEvent) => void; - onPointerUp: (event: React.PointerEvent) => void; -}) { - return ( - <HandleButton - screen={screen} - hitAreaSize={ICON_HANDLE_RADIUS * 2} - className="rounded-full bg-white text-black shadow-sm" - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - > - <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} /> - </HandleButton> - ); -} - -export function BoundingBoxOutline({ - center, - outlineWidth, - outlineHeight, - rotation, - cursor, - dashed = false, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; - cursor?: string; - dashed?: boolean; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - <svg - className={cn("absolute overflow-visible", cursor)} - aria-hidden="true" - focusable="false" - style={{ - left: center.x - outlineWidth / 2, - top: center.y - outlineHeight / 2, - width: outlineWidth, - height: outlineHeight, - transform: `rotate(${rotation}deg)`, - transformOrigin: "center center", - pointerEvents: onPointerDown ? "auto" : "none", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - > - <rect - x={0.5} - y={0.5} - width={Math.max(outlineWidth - 1, 0)} - height={Math.max(outlineHeight - 1, 0)} - fill="transparent" - stroke="white" - strokeDasharray={dashed ? "4 4" : undefined} - strokeOpacity={0.75} - vectorEffect="non-scaling-stroke" - style={{ pointerEvents: onPointerDown ? "all" : "none" }} - /> - </svg> - ); -} - -export function ShapeOutline({ - center, - outlineWidth, - outlineHeight, - rotation, - pathData, - cursor, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - center: { x: number; y: number }; - outlineWidth: number; - outlineHeight: number; - rotation: number; - pathData: string; - cursor?: string; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - return ( - <svg - className={cn("absolute overflow-visible", cursor)} - aria-hidden="true" - focusable="false" - style={{ - left: center.x - outlineWidth / 2, - top: center.y - outlineHeight / 2, - width: outlineWidth, - height: outlineHeight, - transform: `rotate(${rotation}deg)`, - transformOrigin: "center center", - pointerEvents: onPointerDown ? "auto" : "none", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - > - <path - d={pathData} - fill="transparent" - stroke="white" - strokeOpacity={0.75} - vectorEffect="non-scaling-stroke" - style={{ pointerEvents: onPointerDown ? "all" : "none" }} - /> - </svg> - ); -} - -export function LineOverlay({ - start, - end, - onPointerDown, - onPointerMove, - onPointerUp, -}: { - start: { x: number; y: number }; - end: { x: number; y: number }; - onPointerDown?: (event: React.PointerEvent) => void; - onPointerMove?: (event: React.PointerEvent) => void; - onPointerUp?: (event: React.PointerEvent) => void; -}) { - const dx = end.x - start.x; - const dy = end.y - start.y; - const length = Math.sqrt(dx * dx + dy * dy); - const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; - const cx = (start.x + end.x) / 2; - const cy = (start.y + end.y) / 2; - - const sharedStyle = { - left: cx - length / 2, - width: length, - transform: `rotate(${angleDeg}deg)`, - transformOrigin: "center center", - }; - - return ( - <> - {onPointerDown && ( - <div - className="absolute" - style={{ - ...sharedStyle, - top: cy - LINE_HIT_AREA_SIZE / 2, - height: LINE_HIT_AREA_SIZE, - pointerEvents: "auto", - }} - onPointerDown={onPointerDown} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - onPointerLeave={onPointerUp} - /> - )} - <div - className="pointer-events-none absolute" - style={{ - ...sharedStyle, - top: cy - 0.5, - height: 1, - backgroundColor: "white", - opacity: 0.75, - }} - /> - </> - ); -} +"use client";
+
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import type { IconSvgElement } from "@hugeicons/react";
+
+export const HANDLE_SIZE = 10;
+export const HANDLE_HIT_AREA_SIZE = 18;
+export const ICON_HANDLE_RADIUS = 10;
+export const EDGE_HANDLE_THIN_SIZE = 6;
+export const EDGE_HANDLE_THICK_SIZE = 14;
+export const LINE_HIT_AREA_SIZE = 48;
+
+export function getResizeCursor({ angleDeg }: { angleDeg: number }): string {
+ const normalized = ((angleDeg % 180) + 180) % 180;
+ if (normalized < 22.5 || normalized >= 157.5) return "cursor-ew-resize";
+ if (normalized < 67.5) return "cursor-nwse-resize";
+ if (normalized < 112.5) return "cursor-ns-resize";
+ return "cursor-nesw-resize";
+}
+
+export function HandleButton({
+ screen,
+ cursor,
+ hitAreaSize,
+ className,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ children,
+}: {
+ screen: { x: number; y: number };
+ cursor?: string;
+ hitAreaSize: number;
+ className?: string;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+ children: React.ReactNode;
+}) {
+ return (
+ <button
+ type="button"
+ className={cn(
+ "absolute flex items-center justify-center outline-none",
+ cursor,
+ className,
+ )}
+ style={{
+ left: screen.x - hitAreaSize / 2,
+ top: screen.y - hitAreaSize / 2,
+ width: hitAreaSize,
+ height: hitAreaSize,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ {children}
+ </button>
+ );
+}
+
+export function CornerHandle({
+ cursor,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ cursor?: string;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
+ />
+ </HandleButton>
+ );
+}
+
+export function EdgeHandle({
+ edge,
+ screen,
+ rotation,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ edge: "right" | "left" | "bottom";
+ screen: { x: number; y: number };
+ rotation: number;
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ const isHorizontalEdge = edge === "right" || edge === "left";
+ const width = isHorizontalEdge
+ ? EDGE_HANDLE_THIN_SIZE
+ : EDGE_HANDLE_THICK_SIZE;
+ const height = isHorizontalEdge
+ ? EDGE_HANDLE_THICK_SIZE
+ : EDGE_HANDLE_THIN_SIZE;
+ const cursor = getResizeCursor({
+ angleDeg: isHorizontalEdge ? rotation : rotation + 90,
+ });
+
+ return (
+ <HandleButton
+ screen={screen}
+ cursor={cursor}
+ hitAreaSize={HANDLE_HIT_AREA_SIZE}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width, height, transform: `rotate(${rotation}deg)` }}
+ />
+ </HandleButton>
+ );
+}
+
+export function IconHandle({
+ icon,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ icon: IconSvgElement;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <HandleButton
+ screen={screen}
+ hitAreaSize={ICON_HANDLE_RADIUS * 2}
+ className="rounded-full bg-white text-black shadow-sm"
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ >
+ <HugeiconsIcon icon={icon} className="size-3" strokeWidth={2.5} />
+ </HandleButton>
+ );
+}
+
+export function BoundingBoxOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ cursor,
+ dashed = false,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ cursor?: string;
+ dashed?: boolean;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className={cn("absolute overflow-visible", cursor)}
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <rect
+ x={0.5}
+ y={0.5}
+ width={Math.max(outlineWidth - 1, 0)}
+ height={Math.max(outlineHeight - 1, 0)}
+ fill="transparent"
+ stroke="white"
+ strokeDasharray={dashed ? "4 4" : undefined}
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function ShapeOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+ pathData,
+ cursor,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+ pathData: string;
+ cursor?: string;
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <svg
+ className={cn("absolute overflow-visible", cursor)}
+ aria-hidden="true"
+ focusable="false"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ pointerEvents: onPointerDown ? "auto" : "none",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ >
+ <path
+ d={pathData}
+ fill="transparent"
+ stroke="white"
+ strokeOpacity={0.75}
+ vectorEffect="non-scaling-stroke"
+ style={{ pointerEvents: onPointerDown ? "all" : "none" }}
+ />
+ </svg>
+ );
+}
+
+export function LineOverlay({
+ start,
+ end,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ start: { x: number; y: number };
+ end: { x: number; y: number };
+ onPointerDown?: (event: React.PointerEvent) => void;
+ onPointerMove?: (event: React.PointerEvent) => void;
+ onPointerUp?: (event: React.PointerEvent) => void;
+}) {
+ const dx = end.x - start.x;
+ const dy = end.y - start.y;
+ const length = Math.sqrt(dx * dx + dy * dy);
+ const angleDeg = (Math.atan2(dy, dx) * 180) / Math.PI;
+ const cx = (start.x + end.x) / 2;
+ const cy = (start.y + end.y) / 2;
+
+ const sharedStyle = {
+ left: cx - length / 2,
+ width: length,
+ transform: `rotate(${angleDeg}deg)`,
+ transformOrigin: "center center",
+ };
+
+ return (
+ <>
+ {onPointerDown && (
+ <div
+ className="absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - LINE_HIT_AREA_SIZE / 2,
+ height: LINE_HIT_AREA_SIZE,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ />
+ )}
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ ...sharedStyle,
+ top: cy - 0.5,
+ height: 1,
+ backgroundColor: "white",
+ opacity: 0.75,
+ }}
+ />
+ </>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index c625b9bf..8cfe7f49 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -9,7 +9,6 @@ import { useFullscreen } from "@/hooks/use-fullscreen"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; -import { getLastFrameTime } from "@/lib/time"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; import { BookmarkNoteOverlay } from "./bookmark-note-overlay"; import { GuideOverlay } from "./guide-overlay"; @@ -134,12 +133,7 @@ function PreviewCanvas({ const render = useCallback(() => { if (canvasRef.current && renderTree && !renderingRef.current) { - const time = editor.playback.getCurrentTime(); - const lastFrameTime = getLastFrameTime({ - duration: renderTree.duration, - fps: renderer.fps, - }); - const renderTime = Math.min(time, lastFrameTime); + const renderTime = editor.playback.getCurrentTime(); const frame = Math.floor(renderTime * renderer.fps); if ( diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index 65c11726..515cb91a 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,117 +1,117 @@ -import { useState } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; -import type { SnapLine } from "@/lib/preview/preview-snap"; -import { TransformHandles } from "./transform-handles"; -import { MaskHandles } from "./mask-handles"; -import { SnapGuides } from "./snap-guides"; -import { TextEditOverlay } from "./text-edit-overlay"; -import { usePropertiesStore } from "../properties/stores/properties-store"; -import { useEditor } from "@/hooks/use-editor"; - -export function PreviewInteractionOverlay() { - const [snapLines, setSnapLines] = useState<SnapLine[]>([]); - const editor = useEditor(); - const viewport = usePreviewViewport(); - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType); - - const selectedRef = - selectedElements.length === 1 ? selectedElements[0] : null; - const activeTrack = selectedRef - ? editor.timeline.getTrackById({ trackId: selectedRef.trackId }) - : null; - const activeElement = - activeTrack?.elements.find( - (element) => element.id === selectedRef?.elementId, - ) ?? null; - const isMaskMode = activeElement - ? activeTabPerType[activeElement.type] === "masks" - : false; - - const { - onPointerDown, - onPointerMove, - onPointerUp, - onDoubleClick, - editingText, - commitTextEdit, - } = usePreviewInteraction({ - onSnapLinesChange: setSnapLines, - isMaskMode, - }); - - const handlePointerDown = (event: React.PointerEvent) => { - if (viewport.handlePanPointerDown({ event })) { - return; - } - - onPointerDown(event); - }; - - const handlePointerMove = (event: React.PointerEvent) => { - if (viewport.handlePanPointerMove({ event })) { - return; - } - - onPointerMove(event); - }; - - const handlePointerUp = (event: React.PointerEvent) => { - if (viewport.handlePanPointerUp({ event })) { - return; - } - - onPointerUp(event); - }; - - const handleContextMenu = (e: React.MouseEvent) => { - const x = e.nativeEvent.offsetX; - const y = e.nativeEvent.offsetY; - const outsideCanvas = - x < viewport.sceneLeft || - x > viewport.sceneLeft + viewport.sceneWidth || - y < viewport.sceneTop || - y > viewport.sceneTop + viewport.sceneHeight; - if (outsideCanvas) { - e.stopPropagation(); - } - }; - - return ( - <div className="absolute inset-0"> - <div - className="absolute inset-0 pointer-events-auto" - role="application" - aria-label="Preview canvas" - style={{ - cursor: viewport.isPanning - ? "grabbing" - : viewport.canPan - ? "default" - : undefined, - }} - onPointerDown={handlePointerDown} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerUp} - onDoubleClick={onDoubleClick} - onDragStart={(e) => e.preventDefault()} - onContextMenu={handleContextMenu} - /> - {editingText ? ( - <TextEditOverlay - trackId={editingText.trackId} - elementId={editingText.elementId} - element={editingText.element} - onCommit={commitTextEdit} - /> - ) : isMaskMode ? ( - <MaskHandles onSnapLinesChange={setSnapLines} /> - ) : ( - <TransformHandles onSnapLinesChange={setSnapLines} /> - )} - <SnapGuides lines={snapLines} /> - </div> - ); -} +import { useState } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import { TransformHandles } from "./transform-handles";
+import { MaskHandles } from "./mask-handles";
+import { SnapGuides } from "./snap-guides";
+import { TextEditOverlay } from "./text-edit-overlay";
+import { usePropertiesStore } from "../properties/stores/properties-store";
+import { useEditor } from "@/hooks/use-editor";
+
+export function PreviewInteractionOverlay() {
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const selectedElements = useEditor((e) => e.selection.getSelectedElements());
+ const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
+
+ const selectedRef =
+ selectedElements.length === 1 ? selectedElements[0] : null;
+ const activeTrack = selectedRef
+ ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
+ : null;
+ const activeElement =
+ activeTrack?.elements.find(
+ (element) => element.id === selectedRef?.elementId,
+ ) ?? null;
+ const isMaskMode = activeElement
+ ? activeTabPerType[activeElement.type] === "masks"
+ : false;
+
+ const {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onDoubleClick,
+ editingText,
+ commitTextEdit,
+ } = usePreviewInteraction({
+ onSnapLinesChange: setSnapLines,
+ isMaskMode,
+ });
+
+ const handlePointerDown = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerDown({ event })) {
+ return;
+ }
+
+ onPointerDown(event);
+ };
+
+ const handlePointerMove = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerMove({ event })) {
+ return;
+ }
+
+ onPointerMove(event);
+ };
+
+ const handlePointerUp = (event: React.PointerEvent) => {
+ if (viewport.handlePanPointerUp({ event })) {
+ return;
+ }
+
+ onPointerUp(event);
+ };
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ const x = e.nativeEvent.offsetX;
+ const y = e.nativeEvent.offsetY;
+ const outsideCanvas =
+ x < viewport.sceneLeft ||
+ x > viewport.sceneLeft + viewport.sceneWidth ||
+ y < viewport.sceneTop ||
+ y > viewport.sceneTop + viewport.sceneHeight;
+ if (outsideCanvas) {
+ e.stopPropagation();
+ }
+ };
+
+ return (
+ <div className="absolute inset-0">
+ <div
+ className="absolute inset-0 pointer-events-auto"
+ role="application"
+ aria-label="Preview canvas"
+ style={{
+ cursor: viewport.isPanning
+ ? "grabbing"
+ : viewport.canPan
+ ? "default"
+ : undefined,
+ }}
+ onPointerDown={handlePointerDown}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerUp}
+ onPointerCancel={handlePointerUp}
+ onDoubleClick={onDoubleClick}
+ onDragStart={(e) => e.preventDefault()}
+ onContextMenu={handleContextMenu}
+ />
+ {editingText ? (
+ <TextEditOverlay
+ trackId={editingText.trackId}
+ elementId={editingText.elementId}
+ element={editingText.element}
+ onCommit={commitTextEdit}
+ />
+ ) : isMaskMode ? (
+ <MaskHandles onSnapLinesChange={setSnapLines} />
+ ) : (
+ <TransformHandles onSnapLinesChange={setSnapLines} />
+ )}
+ <SnapGuides lines={snapLines} />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx index e38183af..8fd38768 100644 --- a/apps/web/src/components/editor/panels/preview/preview-viewport.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-viewport.tsx @@ -1,576 +1,576 @@ -"use client"; - -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { - canvasToOverlay, - getDisplayScale, - positionToOverlay, - screenPixelsToLogicalThreshold, - screenToCanvas, -} from "@/lib/preview/preview-coords"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import { PREVIEW_ZOOM } from "@/constants/editor-constants"; - -const MIDDLE_MOUSE_BUTTON = 1; -const IS_AT_FIT_EPSILON = 0.001; -const IS_AT_ACTUAL_SIZE_EPSILON = 0.01; - -interface PreviewViewportContextValue { - canPan: boolean; - isAtFit: boolean; - isAtActualSize: boolean; - isPanning: boolean; - sceneHeight: number; - sceneLeft: number; - sceneTop: number; - sceneWidth: number; - zoomPercent: number; - canvasToOverlay: ({ - canvasX, - canvasY, - }: { - canvasX: number; - canvasY: number; - }) => { x: number; y: number }; - fitToScreen: () => void; - getDisplayScale: () => { x: number; y: number }; - handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean; - handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean; - handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean; - positionToOverlay: ({ - positionX, - positionY, - }: { - positionX: number; - positionY: number; - }) => { x: number; y: number }; - panByScreenDelta: ({ - deltaX, - deltaY, - }: { - deltaX: number; - deltaY: number; - }) => void; - resetPan: () => void; - scaleZoom: ({ factor }: { factor: number }) => void; - screenPixelsToLogicalThreshold: ({ - screenPixels, - }: { - screenPixels: number; - }) => { x: number; y: number }; - screenToCanvas: ({ - clientX, - clientY, - }: { - clientX: number; - clientY: number; - }) => { x: number; y: number } | null; - setActualSize: () => void; - setViewportPercent: ({ percent }: { percent: number }) => void; - zoomIn: () => void; - zoomOut: () => void; -} - -const PreviewViewportContext = - createContext<PreviewViewportContextValue | null>(null); - -interface PreviewViewportProviderProps { - children: React.ReactNode; - value: PreviewViewportContextValue; -} - -interface PreviewViewportStateOptions { - canvasHeight: number; - canvasWidth: number; - viewportHeight: number; - viewportRef: React.RefObject<HTMLDivElement | null>; - viewportWidth: number; -} - -interface PanSession { - centerX: number; - centerY: number; - clientX: number; - clientY: number; - pointerId: number; -} - -function getClampedCenterAxis({ - axisSize, - center, - scale, - viewportSize, -}: { - axisSize: number; - center: number; - scale: number; - viewportSize: number; -}): number { - if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) { - return axisSize / 2; - } - - const visibleHalfSpan = viewportSize / (2 * scale); - if (visibleHalfSpan >= axisSize / 2) { - return axisSize / 2; - } - - return clamp({ - value: center, - min: visibleHalfSpan, - max: axisSize - visibleHalfSpan, - }); -} - -function clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX, - centerY, - scale, - viewportHeight, - viewportWidth, -}: { - canvasHeight: number; - canvasWidth: number; - centerX: number; - centerY: number; - scale: number; - viewportHeight: number; - viewportWidth: number; -}): { x: number; y: number } { - return { - x: getClampedCenterAxis({ - center: centerX, - axisSize: canvasWidth, - scale, - viewportSize: viewportWidth, - }), - y: getClampedCenterAxis({ - center: centerY, - axisSize: canvasHeight, - scale, - viewportSize: viewportHeight, - }), - }; -} - -function getFitScale({ - canvasHeight, - canvasWidth, - viewportHeight, - viewportWidth, -}: { - canvasHeight: number; - canvasWidth: number; - viewportHeight: number; - viewportWidth: number; -}): number { - if ( - canvasHeight <= 0 || - canvasWidth <= 0 || - viewportHeight <= 0 || - viewportWidth <= 0 - ) { - return 1; - } - - return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight); -} - -function getClampedZoom({ zoom }: { zoom: number }): number { - return clamp({ - value: zoom, - min: PREVIEW_ZOOM.min, - max: PREVIEW_ZOOM.max, - }); -} - -export function PreviewViewportProvider({ - children, - value, -}: PreviewViewportProviderProps) { - return ( - <PreviewViewportContext.Provider value={value}> - {children} - </PreviewViewportContext.Provider> - ); -} - -export function usePreviewViewportState({ - canvasHeight, - canvasWidth, - viewportHeight, - viewportRef, - viewportWidth, -}: PreviewViewportStateOptions): PreviewViewportContextValue { - const [zoom, setZoomState] = useState(1); - const [center, setCenter] = useState(() => ({ - x: canvasWidth / 2, - y: canvasHeight / 2, - })); - const [isPanning, setIsPanning] = useState(false); - const panSessionRef = useRef<PanSession | null>(null); - const centerRef = useRef(center); - centerRef.current = center; - - const fitScale = useMemo( - () => - getFitScale({ - canvasHeight, - canvasWidth, - viewportHeight, - viewportWidth, - }), - [canvasHeight, canvasWidth, viewportHeight, viewportWidth], - ); - const viewportScale = fitScale * zoom; - const geometry = useMemo( - () => ({ - canvasHeight, - canvasWidth, - centerX: center.x, - centerY: center.y, - scale: viewportScale, - viewportHeight, - viewportWidth, - }), - [ - canvasHeight, - canvasWidth, - center.x, - center.y, - viewportHeight, - viewportScale, - viewportWidth, - ], - ); - - const scaleZoom = useCallback(({ factor }: { factor: number }) => { - setZoomState((previousZoom) => - getClampedZoom({ - zoom: previousZoom * factor, - }), - ); - }, []); - - const panByScreenDelta = useCallback( - ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => { - if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) { - return; - } - - setCenter((previousCenter) => - clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX: previousCenter.x + deltaX / viewportScale, - centerY: previousCenter.y + deltaY / viewportScale, - scale: viewportScale, - viewportHeight, - viewportWidth, - }), - ); - }, - [ - canvasHeight, - canvasWidth, - viewportHeight, - viewportScale, - viewportWidth, - zoom, - ], - ); - - const resetPan = useCallback(() => { - setCenter({ - x: canvasWidth / 2, - y: canvasHeight / 2, - }); - }, [canvasHeight, canvasWidth]); - - const fitToScreen = useCallback(() => { - setZoomState(1); - setCenter({ - x: canvasWidth / 2, - y: canvasHeight / 2, - }); - }, [canvasHeight, canvasWidth]); - - const zoomIn = useCallback(() => { - setZoomState((previousZoom) => - getClampedZoom({ - zoom: previousZoom * PREVIEW_ZOOM.step, - }), - ); - }, []); - - const zoomOut = useCallback(() => { - setZoomState((previousZoom) => - getClampedZoom({ - zoom: previousZoom / PREVIEW_ZOOM.step, - }), - ); - }, []); - - const setActualSize = useCallback(() => { - const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1; - setZoomState( - getClampedZoom({ - zoom: actualSizeZoom, - }), - ); - }, [fitScale]); - - const setViewportPercent = useCallback( - ({ percent }: { percent: number }) => { - const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1; - setZoomState(getClampedZoom({ zoom: targetZoom })); - }, - [fitScale], - ); - - const getViewportRect = useCallback( - () => viewportRef.current?.getBoundingClientRect() ?? null, - [viewportRef], - ); - - const screenToCanvasWithViewport = useCallback( - ({ clientX, clientY }: { clientX: number; clientY: number }) => { - const viewportRect = getViewportRect(); - if (!viewportRect) return null; - - return screenToCanvas({ - clientX, - clientY, - geometry, - viewportRect, - }); - }, - [getViewportRect, geometry], - ); - - const canvasToOverlayWithViewport = useCallback( - ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) => - canvasToOverlay({ - canvasX, - canvasY, - geometry, - }), - [geometry], - ); - - const positionToOverlayWithViewport = useCallback( - ({ positionX, positionY }: { positionX: number; positionY: number }) => - positionToOverlay({ - geometry, - positionX, - positionY, - }), - [geometry], - ); - - const getDisplayScaleWithViewport = useCallback( - () => getDisplayScale({ geometry }), - [geometry], - ); - - const getLogicalThresholdForScreenPixels = useCallback( - ({ screenPixels }: { screenPixels: number }) => - screenPixelsToLogicalThreshold({ - geometry, - screenPixels, - }), - [geometry], - ); - - const handlePanPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) { - return false; - } - - event.preventDefault(); - event.stopPropagation(); - - panSessionRef.current = { - centerX: centerRef.current.x, - centerY: centerRef.current.y, - clientX: event.clientX, - clientY: event.clientY, - pointerId: event.pointerId, - }; - setIsPanning(true); - (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); - return true; - }, - [zoom], - ); - - const handlePanPointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - const panSession = panSessionRef.current; - if (!panSession) { - return false; - } - - event.preventDefault(); - event.stopPropagation(); - - const deltaX = event.clientX - panSession.clientX; - const deltaY = event.clientY - panSession.clientY; - const nextCenter = clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX: panSession.centerX - deltaX / viewportScale, - centerY: panSession.centerY - deltaY / viewportScale, - scale: viewportScale, - viewportHeight, - viewportWidth, - }); - - setCenter(nextCenter); - return true; - }, - [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth], - ); - - const handlePanPointerUp = useCallback( - ({ event }: { event: React.PointerEvent }) => { - const panSession = panSessionRef.current; - if (!panSession) { - return false; - } - - if ( - (event.currentTarget as HTMLElement).hasPointerCapture( - panSession.pointerId, - ) - ) { - (event.currentTarget as HTMLElement).releasePointerCapture( - panSession.pointerId, - ); - } - - panSessionRef.current = null; - setIsPanning(false); - return true; - }, - [], - ); - - useEffect(() => { - setZoomState(1); - setCenter({ - x: canvasWidth / 2, - y: canvasHeight / 2, - }); - panSessionRef.current = null; - setIsPanning(false); - }, [canvasHeight, canvasWidth]); - - useEffect(() => { - setCenter((previousCenter) => - clampViewportCenter({ - canvasHeight, - canvasWidth, - centerX: previousCenter.x, - centerY: previousCenter.y, - scale: viewportScale, - viewportHeight, - viewportWidth, - }), - ); - }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]); - - const sceneWidth = canvasWidth * viewportScale; - const sceneHeight = canvasHeight * viewportScale; - const sceneLeft = viewportWidth / 2 - center.x * viewportScale; - const sceneTop = viewportHeight / 2 - center.y * viewportScale; - const canPan = zoom > 1; - const zoomPercent = Math.round(viewportScale * 100); - - return useMemo( - () => ({ - canPan, - isAtActualSize: isNearlyEqual({ - leftValue: viewportScale, - rightValue: 1, - epsilon: IS_AT_ACTUAL_SIZE_EPSILON, - }), - isAtFit: isNearlyEqual({ - leftValue: zoom, - rightValue: 1, - epsilon: IS_AT_FIT_EPSILON, - }), - isPanning, - sceneHeight, - sceneLeft, - sceneTop, - sceneWidth, - zoomPercent, - canvasToOverlay: canvasToOverlayWithViewport, - fitToScreen, - getDisplayScale: getDisplayScaleWithViewport, - handlePanPointerDown, - handlePanPointerMove, - handlePanPointerUp, - panByScreenDelta, - positionToOverlay: positionToOverlayWithViewport, - resetPan, - scaleZoom, - screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels, - screenToCanvas: screenToCanvasWithViewport, - setActualSize, - setViewportPercent, - zoomIn, - zoomOut, - }), - [ - canPan, - viewportScale, - zoom, - isPanning, - sceneHeight, - sceneLeft, - sceneTop, - sceneWidth, - zoomPercent, - canvasToOverlayWithViewport, - fitToScreen, - getDisplayScaleWithViewport, - handlePanPointerDown, - handlePanPointerMove, - handlePanPointerUp, - panByScreenDelta, - positionToOverlayWithViewport, - resetPan, - scaleZoom, - getLogicalThresholdForScreenPixels, - screenToCanvasWithViewport, - setActualSize, - setViewportPercent, - zoomIn, - zoomOut, - ], - ); -} - -export function usePreviewViewport() { - const context = useContext(PreviewViewportContext); - if (!context) { - throw new Error( - "usePreviewViewport must be used within PreviewViewportProvider", - ); - } - - return context; -} +"use client";
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ canvasToOverlay,
+ getDisplayScale,
+ positionToOverlay,
+ screenPixelsToLogicalThreshold,
+ screenToCanvas,
+} from "@/lib/preview/preview-coords";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import { PREVIEW_ZOOM } from "@/constants/editor-constants";
+
+const MIDDLE_MOUSE_BUTTON = 1;
+const IS_AT_FIT_EPSILON = 0.001;
+const IS_AT_ACTUAL_SIZE_EPSILON = 0.01;
+
+interface PreviewViewportContextValue {
+ canPan: boolean;
+ isAtFit: boolean;
+ isAtActualSize: boolean;
+ isPanning: boolean;
+ sceneHeight: number;
+ sceneLeft: number;
+ sceneTop: number;
+ sceneWidth: number;
+ zoomPercent: number;
+ canvasToOverlay: ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) => { x: number; y: number };
+ fitToScreen: () => void;
+ getDisplayScale: () => { x: number; y: number };
+ handlePanPointerDown: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerMove: ({ event }: { event: React.PointerEvent }) => boolean;
+ handlePanPointerUp: ({ event }: { event: React.PointerEvent }) => boolean;
+ positionToOverlay: ({
+ positionX,
+ positionY,
+ }: {
+ positionX: number;
+ positionY: number;
+ }) => { x: number; y: number };
+ panByScreenDelta: ({
+ deltaX,
+ deltaY,
+ }: {
+ deltaX: number;
+ deltaY: number;
+ }) => void;
+ resetPan: () => void;
+ scaleZoom: ({ factor }: { factor: number }) => void;
+ screenPixelsToLogicalThreshold: ({
+ screenPixels,
+ }: {
+ screenPixels: number;
+ }) => { x: number; y: number };
+ screenToCanvas: ({
+ clientX,
+ clientY,
+ }: {
+ clientX: number;
+ clientY: number;
+ }) => { x: number; y: number } | null;
+ setActualSize: () => void;
+ setViewportPercent: ({ percent }: { percent: number }) => void;
+ zoomIn: () => void;
+ zoomOut: () => void;
+}
+
+const PreviewViewportContext =
+ createContext<PreviewViewportContextValue | null>(null);
+
+interface PreviewViewportProviderProps {
+ children: React.ReactNode;
+ value: PreviewViewportContextValue;
+}
+
+interface PreviewViewportStateOptions {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportRef: React.RefObject<HTMLDivElement | null>;
+ viewportWidth: number;
+}
+
+interface PanSession {
+ centerX: number;
+ centerY: number;
+ clientX: number;
+ clientY: number;
+ pointerId: number;
+}
+
+function getClampedCenterAxis({
+ axisSize,
+ center,
+ scale,
+ viewportSize,
+}: {
+ axisSize: number;
+ center: number;
+ scale: number;
+ viewportSize: number;
+}): number {
+ if (axisSize <= 0 || scale <= 0 || viewportSize <= 0) {
+ return axisSize / 2;
+ }
+
+ const visibleHalfSpan = viewportSize / (2 * scale);
+ if (visibleHalfSpan >= axisSize / 2) {
+ return axisSize / 2;
+ }
+
+ return clamp({
+ value: center,
+ min: visibleHalfSpan,
+ max: axisSize - visibleHalfSpan,
+ });
+}
+
+function clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX,
+ centerY,
+ scale,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ centerX: number;
+ centerY: number;
+ scale: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): { x: number; y: number } {
+ return {
+ x: getClampedCenterAxis({
+ center: centerX,
+ axisSize: canvasWidth,
+ scale,
+ viewportSize: viewportWidth,
+ }),
+ y: getClampedCenterAxis({
+ center: centerY,
+ axisSize: canvasHeight,
+ scale,
+ viewportSize: viewportHeight,
+ }),
+ };
+}
+
+function getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+}: {
+ canvasHeight: number;
+ canvasWidth: number;
+ viewportHeight: number;
+ viewportWidth: number;
+}): number {
+ if (
+ canvasHeight <= 0 ||
+ canvasWidth <= 0 ||
+ viewportHeight <= 0 ||
+ viewportWidth <= 0
+ ) {
+ return 1;
+ }
+
+ return Math.min(viewportWidth / canvasWidth, viewportHeight / canvasHeight);
+}
+
+function getClampedZoom({ zoom }: { zoom: number }): number {
+ return clamp({
+ value: zoom,
+ min: PREVIEW_ZOOM.min,
+ max: PREVIEW_ZOOM.max,
+ });
+}
+
+export function PreviewViewportProvider({
+ children,
+ value,
+}: PreviewViewportProviderProps) {
+ return (
+ <PreviewViewportContext.Provider value={value}>
+ {children}
+ </PreviewViewportContext.Provider>
+ );
+}
+
+export function usePreviewViewportState({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportRef,
+ viewportWidth,
+}: PreviewViewportStateOptions): PreviewViewportContextValue {
+ const [zoom, setZoomState] = useState(1);
+ const [center, setCenter] = useState(() => ({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ }));
+ const [isPanning, setIsPanning] = useState(false);
+ const panSessionRef = useRef<PanSession | null>(null);
+ const centerRef = useRef(center);
+ centerRef.current = center;
+
+ const fitScale = useMemo(
+ () =>
+ getFitScale({
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [canvasHeight, canvasWidth, viewportHeight, viewportWidth],
+ );
+ const viewportScale = fitScale * zoom;
+ const geometry = useMemo(
+ () => ({
+ canvasHeight,
+ canvasWidth,
+ centerX: center.x,
+ centerY: center.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ [
+ canvasHeight,
+ canvasWidth,
+ center.x,
+ center.y,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ ],
+ );
+
+ const scaleZoom = useCallback(({ factor }: { factor: number }) => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * factor,
+ }),
+ );
+ }, []);
+
+ const panByScreenDelta = useCallback(
+ ({ deltaX, deltaY }: { deltaX: number; deltaY: number }) => {
+ if (zoom <= 1 || (deltaX === 0 && deltaY === 0)) {
+ return;
+ }
+
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x + deltaX / viewportScale,
+ centerY: previousCenter.y + deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ },
+ [
+ canvasHeight,
+ canvasWidth,
+ viewportHeight,
+ viewportScale,
+ viewportWidth,
+ zoom,
+ ],
+ );
+
+ const resetPan = useCallback(() => {
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const fitToScreen = useCallback(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ }, [canvasHeight, canvasWidth]);
+
+ const zoomIn = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom * PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const zoomOut = useCallback(() => {
+ setZoomState((previousZoom) =>
+ getClampedZoom({
+ zoom: previousZoom / PREVIEW_ZOOM.step,
+ }),
+ );
+ }, []);
+
+ const setActualSize = useCallback(() => {
+ const actualSizeZoom = fitScale > 0 ? 1 / fitScale : 1;
+ setZoomState(
+ getClampedZoom({
+ zoom: actualSizeZoom,
+ }),
+ );
+ }, [fitScale]);
+
+ const setViewportPercent = useCallback(
+ ({ percent }: { percent: number }) => {
+ const targetZoom = fitScale > 0 ? percent / 100 / fitScale : 1;
+ setZoomState(getClampedZoom({ zoom: targetZoom }));
+ },
+ [fitScale],
+ );
+
+ const getViewportRect = useCallback(
+ () => viewportRef.current?.getBoundingClientRect() ?? null,
+ [viewportRef],
+ );
+
+ const screenToCanvasWithViewport = useCallback(
+ ({ clientX, clientY }: { clientX: number; clientY: number }) => {
+ const viewportRect = getViewportRect();
+ if (!viewportRect) return null;
+
+ return screenToCanvas({
+ clientX,
+ clientY,
+ geometry,
+ viewportRect,
+ });
+ },
+ [getViewportRect, geometry],
+ );
+
+ const canvasToOverlayWithViewport = useCallback(
+ ({ canvasX, canvasY }: { canvasX: number; canvasY: number }) =>
+ canvasToOverlay({
+ canvasX,
+ canvasY,
+ geometry,
+ }),
+ [geometry],
+ );
+
+ const positionToOverlayWithViewport = useCallback(
+ ({ positionX, positionY }: { positionX: number; positionY: number }) =>
+ positionToOverlay({
+ geometry,
+ positionX,
+ positionY,
+ }),
+ [geometry],
+ );
+
+ const getDisplayScaleWithViewport = useCallback(
+ () => getDisplayScale({ geometry }),
+ [geometry],
+ );
+
+ const getLogicalThresholdForScreenPixels = useCallback(
+ ({ screenPixels }: { screenPixels: number }) =>
+ screenPixelsToLogicalThreshold({
+ geometry,
+ screenPixels,
+ }),
+ [geometry],
+ );
+
+ const handlePanPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (event.button !== MIDDLE_MOUSE_BUTTON || zoom <= 1) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ panSessionRef.current = {
+ centerX: centerRef.current.x,
+ centerY: centerRef.current.y,
+ clientX: event.clientX,
+ clientY: event.clientY,
+ pointerId: event.pointerId,
+ };
+ setIsPanning(true);
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ return true;
+ },
+ [zoom],
+ );
+
+ const handlePanPointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const deltaX = event.clientX - panSession.clientX;
+ const deltaY = event.clientY - panSession.clientY;
+ const nextCenter = clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: panSession.centerX - deltaX / viewportScale,
+ centerY: panSession.centerY - deltaY / viewportScale,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ });
+
+ setCenter(nextCenter);
+ return true;
+ },
+ [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth],
+ );
+
+ const handlePanPointerUp = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ const panSession = panSessionRef.current;
+ if (!panSession) {
+ return false;
+ }
+
+ if (
+ (event.currentTarget as HTMLElement).hasPointerCapture(
+ panSession.pointerId,
+ )
+ ) {
+ (event.currentTarget as HTMLElement).releasePointerCapture(
+ panSession.pointerId,
+ );
+ }
+
+ panSessionRef.current = null;
+ setIsPanning(false);
+ return true;
+ },
+ [],
+ );
+
+ useEffect(() => {
+ setZoomState(1);
+ setCenter({
+ x: canvasWidth / 2,
+ y: canvasHeight / 2,
+ });
+ panSessionRef.current = null;
+ setIsPanning(false);
+ }, [canvasHeight, canvasWidth]);
+
+ useEffect(() => {
+ setCenter((previousCenter) =>
+ clampViewportCenter({
+ canvasHeight,
+ canvasWidth,
+ centerX: previousCenter.x,
+ centerY: previousCenter.y,
+ scale: viewportScale,
+ viewportHeight,
+ viewportWidth,
+ }),
+ );
+ }, [canvasHeight, canvasWidth, viewportHeight, viewportScale, viewportWidth]);
+
+ const sceneWidth = canvasWidth * viewportScale;
+ const sceneHeight = canvasHeight * viewportScale;
+ const sceneLeft = viewportWidth / 2 - center.x * viewportScale;
+ const sceneTop = viewportHeight / 2 - center.y * viewportScale;
+ const canPan = zoom > 1;
+ const zoomPercent = Math.round(viewportScale * 100);
+
+ return useMemo(
+ () => ({
+ canPan,
+ isAtActualSize: isNearlyEqual({
+ leftValue: viewportScale,
+ rightValue: 1,
+ epsilon: IS_AT_ACTUAL_SIZE_EPSILON,
+ }),
+ isAtFit: isNearlyEqual({
+ leftValue: zoom,
+ rightValue: 1,
+ epsilon: IS_AT_FIT_EPSILON,
+ }),
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlay: canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScale: getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlay: positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ screenPixelsToLogicalThreshold: getLogicalThresholdForScreenPixels,
+ screenToCanvas: screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ }),
+ [
+ canPan,
+ viewportScale,
+ zoom,
+ isPanning,
+ sceneHeight,
+ sceneLeft,
+ sceneTop,
+ sceneWidth,
+ zoomPercent,
+ canvasToOverlayWithViewport,
+ fitToScreen,
+ getDisplayScaleWithViewport,
+ handlePanPointerDown,
+ handlePanPointerMove,
+ handlePanPointerUp,
+ panByScreenDelta,
+ positionToOverlayWithViewport,
+ resetPan,
+ scaleZoom,
+ getLogicalThresholdForScreenPixels,
+ screenToCanvasWithViewport,
+ setActualSize,
+ setViewportPercent,
+ zoomIn,
+ zoomOut,
+ ],
+ );
+}
+
+export function usePreviewViewport() {
+ const context = useContext(PreviewViewportContext);
+ if (!context) {
+ throw new Error(
+ "usePreviewViewport must be used within PreviewViewportProvider",
+ );
+ }
+
+ return context;
+}
diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx index 744304ea..ced5e921 100644 --- a/apps/web/src/components/editor/panels/preview/snap-guides.tsx +++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx @@ -1,47 +1,47 @@ -"use client"; - -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import type { SnapLine } from "@/lib/preview/preview-snap"; - -export function SnapGuides({ lines }: { lines: SnapLine[] }) { - const viewport = usePreviewViewport(); - - if (lines.length === 0) { - return null; - } - - const toOverlayX = (logicalX: number) => - viewport.positionToOverlay({ - positionX: logicalX, - positionY: 0, - }).x; - - const toOverlayY = (logicalY: number) => - viewport.positionToOverlay({ - positionX: 0, - positionY: logicalY, - }).y; - - return ( - <div className="pointer-events-none absolute inset-0" aria-hidden> - {lines.map((line) => { - if (line.type === "vertical") { - return ( - <div - key={`vertical-${line.position}`} - className="absolute top-0 bottom-0 w-px bg-white/70" - style={{ left: toOverlayX(line.position) }} - /> - ); - } - return ( - <div - key={`horizontal-${line.position}`} - className="absolute left-0 right-0 h-px bg-white/70" - style={{ top: toOverlayY(line.position) }} - /> - ); - })} - </div> - ); -} +"use client";
+
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+
+export function SnapGuides({ lines }: { lines: SnapLine[] }) {
+ const viewport = usePreviewViewport();
+
+ if (lines.length === 0) {
+ return null;
+ }
+
+ const toOverlayX = (logicalX: number) =>
+ viewport.positionToOverlay({
+ positionX: logicalX,
+ positionY: 0,
+ }).x;
+
+ const toOverlayY = (logicalY: number) =>
+ viewport.positionToOverlay({
+ positionX: 0,
+ positionY: logicalY,
+ }).y;
+
+ return (
+ <div className="pointer-events-none absolute inset-0" aria-hidden>
+ {lines.map((line) => {
+ if (line.type === "vertical") {
+ return (
+ <div
+ key={`vertical-${line.position}`}
+ className="absolute top-0 bottom-0 w-px bg-white/70"
+ style={{ left: toOverlayX(line.position) }}
+ />
+ );
+ }
+ return (
+ <div
+ key={`horizontal-${line.position}`}
+ className="absolute left-0 right-0 h-px bg-white/70"
+ style={{ top: toOverlayY(line.position) }}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx index d3c61a24..aff77f0f 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -1,212 +1,212 @@ -"use client"; - -import { useCallback, useEffect, useRef } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useEditor } from "@/hooks/use-editor"; -import type { TextElement } from "@/lib/timeline"; -import { - FONT_SIZE_SCALE_REFERENCE, -} from "@/constants/text-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { - getMetricAscent, - getMetricDescent, - setCanvasLetterSpacing, -} from "@/lib/text/layout"; - -let cachedCanvas: HTMLCanvasElement | null = null; - -function getMeasurementContext(): CanvasRenderingContext2D | null { - if (!cachedCanvas) cachedCanvas = document.createElement("canvas"); - return cachedCanvas.getContext("2d"); -} - -/** - * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than - * actualBoundingBox to model where the browser places the baseline within the - * line box, then measures actual glyph bounds to find the visual center. - */ -function measureCSSVisualCenterOffset({ - lines, - fontString, - letterSpacingPx, - lineHeightPx, - displayFontSize, -}: { - lines: string[]; - fontString: string; - letterSpacingPx: number; - lineHeightPx: number; - displayFontSize: number; -}): number { - const ctx = getMeasurementContext(); - if (!ctx) return 0; - - ctx.font = fontString; - ctx.textBaseline = "alphabetic"; - setCanvasLetterSpacing({ ctx, letterSpacingPx }); - - const probe = ctx.measureText("M"); - const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8; - const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2; - const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2; - - let visualTop = Number.POSITIVE_INFINITY; - let visualBottom = Number.NEGATIVE_INFINITY; - - for (let i = 0; i < lines.length; i++) { - const metrics = ctx.measureText(lines[i] || " "); - const baseline = i * lineHeightPx + halfLeading + fontAscent; - visualTop = Math.min( - visualTop, - baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }), - ); - visualBottom = Math.max( - visualBottom, - baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }), - ); - } - - const cssBlockHeight = lines.length * lineHeightPx; - return (visualTop + visualBottom) / 2 - cssBlockHeight / 2; -} - -export function TextEditOverlay({ - trackId, - elementId, - element, - onCommit, -}: { - trackId: string; - elementId: string; - element: TextElement; - onCommit: () => void; -}) { - const editor = useEditor(); - const viewport = usePreviewViewport(); - const divRef = useRef<HTMLDivElement>(null); - - useEffect(() => { - const div = divRef.current; - if (!div) return; - div.focus(); - const range = document.createRange(); - range.selectNodeContents(div); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - }, []); - - const handleInput = useCallback(() => { - const div = divRef.current; - if (!div) return; - const text = div.innerText; - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: { content: text } }], - }); - }, [editor.timeline, trackId, elementId]); - - const handleKeyDown = useCallback( - ({ event }: { event: React.KeyboardEvent }) => { - const { key } = event; - if (key === "Escape") { - event.preventDefault(); - onCommit(); - return; - } - }, - [onCommit], - ); - - const canvasSize = editor.project.getActive().settings.canvasSize; - - if (!canvasSize) return null; - - const { x: posX, y: posY } = viewport.positionToOverlay({ - positionX: element.transform.position.x, - positionY: element.transform.position.y, - }); - - const { x: displayScaleX } = viewport.getDisplayScale(); - - const displayFontSize = - element.fontSize * - (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) * - displayScaleX; - - const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; - const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; - const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; - const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX; - const lineHeightPx = displayFontSize * lineHeight; - const lines = (element.content || "").split("\n"); - const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`; - - const cssVisualCenterOffset = measureCSSVisualCenterOffset({ - lines, - fontString, - letterSpacingPx: displayLetterSpacing, - lineHeightPx, - displayFontSize, - }); - - const bg = element.background; - const shouldShowBackground = - bg.enabled && bg.color && bg.color !== "transparent"; - const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; - const displayPaddingX = shouldShowBackground - ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * - fontSizeRatio * - displayScaleX - : 0; - const displayPaddingY = shouldShowBackground - ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * - fontSizeRatio * - displayScaleX - : 0; - - return ( - <div - className="absolute" - style={{ - left: posX, - top: posY - cssVisualCenterOffset, - transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`, - transformOrigin: "center center", - }} - > - {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} - <div - ref={divRef} - contentEditable - suppressContentEditableWarning - tabIndex={0} - role="textbox" - aria-label="Edit text" - className="cursor-text select-text outline-none whitespace-pre" - style={{ - fontSize: displayFontSize, - fontFamily: element.fontFamily, - fontWeight, - fontStyle, - textAlign: element.textAlign, - letterSpacing: `${displayLetterSpacing}px`, - lineHeight, - color: element.color, - backgroundColor: shouldShowBackground ? bg.color : "transparent", - minHeight: lineHeightPx, - textDecoration: element.textDecoration ?? "none", - padding: shouldShowBackground - ? `${displayPaddingY}px ${displayPaddingX}px` - : 0, - minWidth: 1, - }} - onInput={handleInput} - onBlur={onCommit} - onKeyDown={(event) => handleKeyDown({ event })} - > - {element.content || ""} - </div> - </div> - ); -} +"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useEditor } from "@/hooks/use-editor";
+import type { TextElement } from "@/lib/timeline";
+import {
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import {
+ getMetricAscent,
+ getMetricDescent,
+ setCanvasLetterSpacing,
+} from "@/lib/text/layout";
+
+let cachedCanvas: HTMLCanvasElement | null = null;
+
+function getMeasurementContext(): CanvasRenderingContext2D | null {
+ if (!cachedCanvas) cachedCanvas = document.createElement("canvas");
+ return cachedCanvas.getContext("2d");
+}
+
+/**
+ * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than
+ * actualBoundingBox to model where the browser places the baseline within the
+ * line box, then measures actual glyph bounds to find the visual center.
+ */
+function measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx,
+ lineHeightPx,
+ displayFontSize,
+}: {
+ lines: string[];
+ fontString: string;
+ letterSpacingPx: number;
+ lineHeightPx: number;
+ displayFontSize: number;
+}): number {
+ const ctx = getMeasurementContext();
+ if (!ctx) return 0;
+
+ ctx.font = fontString;
+ ctx.textBaseline = "alphabetic";
+ setCanvasLetterSpacing({ ctx, letterSpacingPx });
+
+ const probe = ctx.measureText("M");
+ const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8;
+ const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2;
+ const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2;
+
+ let visualTop = Number.POSITIVE_INFINITY;
+ let visualBottom = Number.NEGATIVE_INFINITY;
+
+ for (let i = 0; i < lines.length; i++) {
+ const metrics = ctx.measureText(lines[i] || " ");
+ const baseline = i * lineHeightPx + halfLeading + fontAscent;
+ visualTop = Math.min(
+ visualTop,
+ baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ visualBottom = Math.max(
+ visualBottom,
+ baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }),
+ );
+ }
+
+ const cssBlockHeight = lines.length * lineHeightPx;
+ return (visualTop + visualBottom) / 2 - cssBlockHeight / 2;
+}
+
+export function TextEditOverlay({
+ trackId,
+ elementId,
+ element,
+ onCommit,
+}: {
+ trackId: string;
+ elementId: string;
+ element: TextElement;
+ onCommit: () => void;
+}) {
+ const editor = useEditor();
+ const viewport = usePreviewViewport();
+ const divRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const div = divRef.current;
+ if (!div) return;
+ div.focus();
+ const range = document.createRange();
+ range.selectNodeContents(div);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }, []);
+
+ const handleInput = useCallback(() => {
+ const div = divRef.current;
+ if (!div) return;
+ const text = div.innerText;
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: { content: text } }],
+ });
+ }, [editor.timeline, trackId, elementId]);
+
+ const handleKeyDown = useCallback(
+ ({ event }: { event: React.KeyboardEvent }) => {
+ const { key } = event;
+ if (key === "Escape") {
+ event.preventDefault();
+ onCommit();
+ return;
+ }
+ },
+ [onCommit],
+ );
+
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ if (!canvasSize) return null;
+
+ const { x: posX, y: posY } = viewport.positionToOverlay({
+ positionX: element.transform.position.x,
+ positionY: element.transform.position.y,
+ });
+
+ const { x: displayScaleX } = viewport.getDisplayScale();
+
+ const displayFontSize =
+ element.fontSize *
+ (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
+ displayScaleX;
+
+ const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
+ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
+ const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX;
+ const lineHeightPx = displayFontSize * lineHeight;
+ const lines = (element.content || "").split("\n");
+ const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`;
+
+ const cssVisualCenterOffset = measureCSSVisualCenterOffset({
+ lines,
+ fontString,
+ letterSpacingPx: displayLetterSpacing,
+ lineHeightPx,
+ displayFontSize,
+ });
+
+ const bg = element.background;
+ const shouldShowBackground =
+ bg.enabled && bg.color && bg.color !== "transparent";
+ const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
+ const displayPaddingX = shouldShowBackground
+ ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
+ const displayPaddingY = shouldShowBackground
+ ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) *
+ fontSizeRatio *
+ displayScaleX
+ : 0;
+
+ return (
+ <div
+ className="absolute"
+ style={{
+ left: posX,
+ top: posY - cssVisualCenterOffset,
+ transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`,
+ transformOrigin: "center center",
+ }}
+ >
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
+ <div
+ ref={divRef}
+ contentEditable
+ suppressContentEditableWarning
+ tabIndex={0}
+ role="textbox"
+ aria-label="Edit text"
+ className="cursor-text select-text outline-none whitespace-pre"
+ style={{
+ fontSize: displayFontSize,
+ fontFamily: element.fontFamily,
+ fontWeight,
+ fontStyle,
+ textAlign: element.textAlign,
+ letterSpacing: `${displayLetterSpacing}px`,
+ lineHeight,
+ color: element.color,
+ backgroundColor: shouldShowBackground ? bg.color : "transparent",
+ minHeight: lineHeightPx,
+ textDecoration: element.textDecoration ?? "none",
+ padding: shouldShowBackground
+ ? `${displayPaddingY}px ${displayPaddingX}px`
+ : 0,
+ minWidth: 1,
+ }}
+ onInput={handleInput}
+ onBlur={onCommit}
+ onKeyDown={(event) => handleKeyDown({ event })}
+ >
+ {element.content || ""}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx index 56cb784a..5c183bbb 100644 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -1,151 +1,151 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { formatTimeCode } from "@/lib/time"; -import { invokeAction } from "@/lib/actions"; -import { EditableTimecode } from "@/components/editable-timecode"; -import { Button } from "@/components/ui/button"; -import { - FullScreenIcon, - GridTableIcon, - PauseIcon, - PlayIcon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { getGuideById } from "@/lib/guides"; -import { Separator } from "@/components/ui/separator"; -import { - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectSeparator, -} from "@/components/ui/select"; -import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants"; -import { usePreviewViewport } from "./preview-viewport"; -import { GridPopover } from "./guide-popover"; -import { usePreviewStore } from "@/stores/preview-store"; - -export function PreviewToolbar({ - onToggleFullscreen, -}: { - onToggleFullscreen: () => void; -}) { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const activeGuideDefinition = getGuideById(activeGuide); - - return ( - <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5"> - <TimecodeDisplay /> - <PlayPauseButton /> - <div className="justify-self-end flex items-center gap-2.5"> - <ZoomSelect /> - <Separator orientation="vertical" className="h-4" /> - <GridPopover> - <Button - variant={activeGuideDefinition ? "secondary" : "text"} - size="icon" - > - {activeGuideDefinition ? ( - activeGuideDefinition.renderTriggerIcon() - ) : ( - <HugeiconsIcon icon={GridTableIcon} /> - )} - </Button> - </GridPopover> - <Button variant="text" onClick={onToggleFullscreen}> - <HugeiconsIcon icon={FullScreenIcon} /> - </Button> - </div> - </div> - ); -} - -function TimecodeDisplay() { - const editor = useEditor(); - const totalDuration = useEditor((e) => e.timeline.getTotalDuration()); - const fps = useEditor((e) => e.project.getActive().settings.fps); - const [currentTime, setCurrentTime] = useState(() => - editor.playback.getCurrentTime(), - ); - - useEffect(() => { - const handler = (e: Event) => - setCurrentTime((e as CustomEvent<{ time: number }>).detail.time); - window.addEventListener("playback-update", handler); - window.addEventListener("playback-seek", handler); - return () => { - window.removeEventListener("playback-update", handler); - window.removeEventListener("playback-seek", handler); - }; - }, []); - - return ( - <div className="flex items-center"> - <EditableTimecode - time={currentTime} - duration={totalDuration} - format="HH:MM:SS:FF" - fps={fps} - onTimeChange={({ time }) => editor.playback.seek({ time })} - className="text-center" - /> - <span className="text-muted-foreground px-2 font-mono text-xs">/</span> - <span className="text-muted-foreground font-mono text-xs"> - {formatTimeCode({ - timeInSeconds: totalDuration, - format: "HH:MM:SS:FF", - fps, - })} - </span> - </div> - ); -} - -function ZoomSelect() { - const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } = - usePreviewViewport(); - - const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`; - - const onValueChange = (value: string) => { - if (value === "fit") { - fitToScreen(); - } else { - setViewportPercent({ percent: Number(value) }); - } - }; - - return ( - <Select - value={isAtFit ? "fit" : String(zoomPercent)} - onValueChange={onValueChange} - > - <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger> - <SelectContent> - <SelectItem value="fit">Fit</SelectItem> - <SelectSeparator /> - {PREVIEW_ZOOM_PRESETS.map((preset) => ( - <SelectItem key={preset} value={String(preset)}> - {preset}% - </SelectItem> - ))} - </SelectContent> - </Select> - ); -} - -function PlayPauseButton() { - const isPlaying = useEditor((e) => e.playback.getIsPlaying()); - - return ( - <Button - variant="text" - size="icon" - onClick={() => invokeAction("toggle-play")} - > - <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} /> - </Button> - ); -} +"use client";
+
+import { useState, useEffect } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { formatTimeCode } from "@/lib/time";
+import { invokeAction } from "@/lib/actions";
+import { EditableTimecode } from "@/components/editable-timecode";
+import { Button } from "@/components/ui/button";
+import {
+ FullScreenIcon,
+ GridTableIcon,
+ PauseIcon,
+ PlayIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { getGuideById } from "@/lib/guides";
+import { Separator } from "@/components/ui/separator";
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+} from "@/components/ui/select";
+import { PREVIEW_ZOOM_PRESETS } from "@/constants/editor-constants";
+import { usePreviewViewport } from "./preview-viewport";
+import { GridPopover } from "./guide-popover";
+import { usePreviewStore } from "@/stores/preview-store";
+
+export function PreviewToolbar({
+ onToggleFullscreen,
+}: {
+ onToggleFullscreen: () => void;
+}) {
+ const activeGuide = usePreviewStore((state) => state.activeGuide);
+ const activeGuideDefinition = getGuideById(activeGuide);
+
+ return (
+ <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
+ <TimecodeDisplay />
+ <PlayPauseButton />
+ <div className="justify-self-end flex items-center gap-2.5">
+ <ZoomSelect />
+ <Separator orientation="vertical" className="h-4" />
+ <GridPopover>
+ <Button
+ variant={activeGuideDefinition ? "secondary" : "text"}
+ size="icon"
+ >
+ {activeGuideDefinition ? (
+ activeGuideDefinition.renderTriggerIcon()
+ ) : (
+ <HugeiconsIcon icon={GridTableIcon} />
+ )}
+ </Button>
+ </GridPopover>
+ <Button variant="text" onClick={onToggleFullscreen}>
+ <HugeiconsIcon icon={FullScreenIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+function TimecodeDisplay() {
+ const editor = useEditor();
+ const totalDuration = useEditor((e) => e.timeline.getTotalDuration());
+ const fps = useEditor((e) => e.project.getActive().settings.fps);
+ const [currentTime, setCurrentTime] = useState(() =>
+ editor.playback.getCurrentTime(),
+ );
+
+ useEffect(() => {
+ const handler = (e: Event) =>
+ setCurrentTime((e as CustomEvent<{ time: number }>).detail.time);
+ window.addEventListener("playback-update", handler);
+ window.addEventListener("playback-seek", handler);
+ return () => {
+ window.removeEventListener("playback-update", handler);
+ window.removeEventListener("playback-seek", handler);
+ };
+ }, []);
+
+ return (
+ <div className="flex items-center">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
+ <span className="text-muted-foreground font-mono text-xs">
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+ </span>
+ </div>
+ );
+}
+
+function ZoomSelect() {
+ const { isAtFit, zoomPercent, fitToScreen, setViewportPercent } =
+ usePreviewViewport();
+
+ const displayLabel = isAtFit ? "Fit" : `${zoomPercent}%`;
+
+ const onValueChange = (value: string) => {
+ if (value === "fit") {
+ fitToScreen();
+ } else {
+ setViewportPercent({ percent: Number(value) });
+ }
+ };
+
+ return (
+ <Select
+ value={isAtFit ? "fit" : String(zoomPercent)}
+ onValueChange={onValueChange}
+ >
+ <SelectTrigger className="tabular-nums">{displayLabel}</SelectTrigger>
+ <SelectContent>
+ <SelectItem value="fit">Fit</SelectItem>
+ <SelectSeparator />
+ {PREVIEW_ZOOM_PRESETS.map((preset) => (
+ <SelectItem key={preset} value={String(preset)}>
+ {preset}%
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ );
+}
+
+function PlayPauseButton() {
+ const isPlaying = useEditor((e) => e.playback.getIsPlaying());
+
+ return (
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => invokeAction("toggle-play")}
+ >
+ <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx index 88332625..141359c8 100644 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -1,140 +1,140 @@ -"use client"; - -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import { useTransformHandles } from "@/hooks/use-transform-handles"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { - getCornerPosition, - getEdgeHandlePosition, - getRotationHandlePosition, - type Corner, - type Edge, -} from "@/lib/preview/element-bounds"; -import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; -import { - BoundingBoxOutline, - CornerHandle, - EdgeHandle, - IconHandle, - getResizeCursor, -} from "./handle-primitives"; -import { Rotate01Icon } from "@hugeicons/core-free-icons"; - -const CORNERS: Corner[] = [ - "top-left", - "top-right", - "bottom-left", - "bottom-right", -]; -const EDGES: Edge[] = ["right", "left", "bottom"]; - -export function TransformHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: OnSnapLinesChange; -}) { - const viewport = usePreviewViewport(); - const { - selectedWithBounds, - hasVisualSelection, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, - } = useTransformHandles({ onSnapLinesChange }); - - if (!hasVisualSelection || !selectedWithBounds) return null; - - const { bounds, element } = selectedWithBounds; - if (!isVisualElement(element)) return null; - - const displayScale = viewport.getDisplayScale(); - - const toOverlay = ({ - canvasX, - canvasY, - }: { - canvasX: number; - canvasY: number; - }) => - viewport.canvasToOverlay({ - canvasX, - canvasY, - }); - - const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); - const outlineWidth = Math.abs(bounds.width) * displayScale.x; - const outlineHeight = Math.abs(bounds.height) * displayScale.y; - - const rotationHandleCanvas = getRotationHandlePosition({ bounds }); - const rotationHandleScreen = toOverlay({ - canvasX: rotationHandleCanvas.x, - canvasY: rotationHandleCanvas.y, - }); - - const onPointerMove = (event: React.PointerEvent) => - handlePointerMove({ event }); - const onPointerUp = () => handlePointerUp(); - - return ( - <div - className="pointer-events-none absolute inset-0 overflow-hidden" - aria-hidden - > - <BoundingBoxOutline - center={center} - outlineWidth={outlineWidth} - outlineHeight={outlineHeight} - rotation={bounds.rotation} - /> - {CORNERS.map((corner) => { - const cornerPosition = getCornerPosition({ bounds, corner }); - const screen = toOverlay({ - canvasX: cornerPosition.x, - canvasY: cornerPosition.y, - }); - const angleDeg = - Math.atan2(screen.y - center.y, screen.x - center.x) * - (180 / Math.PI); - return ( - <CornerHandle - key={corner} - cursor={getResizeCursor({ angleDeg })} - screen={screen} - onPointerDown={(event) => - handleCornerPointerDown({ event, corner }) - } - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} - {EDGES.map((edge) => { - const edgePosition = getEdgeHandlePosition({ bounds, edge }); - const screen = toOverlay({ - canvasX: edgePosition.x, - canvasY: edgePosition.y, - }); - return ( - <EdgeHandle - key={edge} - edge={edge} - screen={screen} - rotation={bounds.rotation} - onPointerDown={(event) => handleEdgePointerDown({ event, edge })} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - ); - })} - <IconHandle - icon={Rotate01Icon} - screen={rotationHandleScreen} - onPointerDown={(event) => handleRotationPointerDown({ event })} - onPointerMove={onPointerMove} - onPointerUp={onPointerUp} - /> - </div> - ); -} +"use client";
+
+import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
+import { useTransformHandles } from "@/hooks/use-transform-handles";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import {
+ getCornerPosition,
+ getEdgeHandlePosition,
+ getRotationHandlePosition,
+ type Corner,
+ type Edge,
+} from "@/lib/preview/element-bounds";
+import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
+import {
+ BoundingBoxOutline,
+ CornerHandle,
+ EdgeHandle,
+ IconHandle,
+ getResizeCursor,
+} from "./handle-primitives";
+import { Rotate01Icon } from "@hugeicons/core-free-icons";
+
+const CORNERS: Corner[] = [
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+];
+const EDGES: Edge[] = ["right", "left", "bottom"];
+
+export function TransformHandles({
+ onSnapLinesChange,
+}: {
+ onSnapLinesChange?: OnSnapLinesChange;
+}) {
+ const viewport = usePreviewViewport();
+ const {
+ selectedWithBounds,
+ hasVisualSelection,
+ handleCornerPointerDown,
+ handleEdgePointerDown,
+ handleRotationPointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ } = useTransformHandles({ onSnapLinesChange });
+
+ if (!hasVisualSelection || !selectedWithBounds) return null;
+
+ const { bounds, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return null;
+
+ const displayScale = viewport.getDisplayScale();
+
+ const toOverlay = ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) =>
+ viewport.canvasToOverlay({
+ canvasX,
+ canvasY,
+ });
+
+ const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
+ const outlineWidth = Math.abs(bounds.width) * displayScale.x;
+ const outlineHeight = Math.abs(bounds.height) * displayScale.y;
+
+ const rotationHandleCanvas = getRotationHandlePosition({ bounds });
+ const rotationHandleScreen = toOverlay({
+ canvasX: rotationHandleCanvas.x,
+ canvasY: rotationHandleCanvas.y,
+ });
+
+ const onPointerMove = (event: React.PointerEvent) =>
+ handlePointerMove({ event });
+ const onPointerUp = () => handlePointerUp();
+
+ return (
+ <div
+ className="pointer-events-none absolute inset-0 overflow-hidden"
+ aria-hidden
+ >
+ <BoundingBoxOutline
+ center={center}
+ outlineWidth={outlineWidth}
+ outlineHeight={outlineHeight}
+ rotation={bounds.rotation}
+ />
+ {CORNERS.map((corner) => {
+ const cornerPosition = getCornerPosition({ bounds, corner });
+ const screen = toOverlay({
+ canvasX: cornerPosition.x,
+ canvasY: cornerPosition.y,
+ });
+ const angleDeg =
+ Math.atan2(screen.y - center.y, screen.x - center.x) *
+ (180 / Math.PI);
+ return (
+ <CornerHandle
+ key={corner}
+ cursor={getResizeCursor({ angleDeg })}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleCornerPointerDown({ event, corner })
+ }
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ {EDGES.map((edge) => {
+ const edgePosition = getEdgeHandlePosition({ bounds, edge });
+ const screen = toOverlay({
+ canvasX: edgePosition.x,
+ canvasY: edgePosition.y,
+ });
+ return (
+ <EdgeHandle
+ key={edge}
+ edge={edge}
+ screen={screen}
+ rotation={bounds.rotation}
+ onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ );
+ })}
+ <IconHandle
+ icon={Rotate01Icon}
+ screen={rotationHandleScreen}
+ onPointerDown={(event) => handleRotationPointerDown({ event })}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx index b5b8685f..00cea82b 100644 --- a/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx +++ b/apps/web/src/components/editor/panels/properties/components/keyframe-toggle.tsx @@ -1,32 +1,32 @@ -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { KeyframeIcon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -export function KeyframeToggle({ - isActive, - isDisabled = false, - title, - onToggle, -}: { - isActive: boolean; - isDisabled?: boolean; - title: string; - onToggle: () => void; -}) { - return ( - <Button - variant="text" - aria-pressed={isActive} - disabled={isDisabled} - title={title} - onClick={onToggle} - className="[&>svg]:size-3.5 mb-0.5" - > - <HugeiconsIcon - icon={KeyframeIcon} - className={cn(isActive && "text-primary fill-primary")} - /> - </Button> - ); -} +import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { KeyframeIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+export function KeyframeToggle({
+ isActive,
+ isDisabled = false,
+ title,
+ onToggle,
+}: {
+ isActive: boolean;
+ isDisabled?: boolean;
+ title: string;
+ onToggle: () => void;
+}) {
+ return (
+ <Button
+ variant="text"
+ aria-pressed={isActive}
+ disabled={isDisabled}
+ title={title}
+ onClick={onToggle}
+ className="[&>svg]:size-3.5 mb-0.5"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(isActive && "text-primary fill-primary")}
+ />
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx index c5576dda..547c0883 100644 --- a/apps/web/src/components/editor/panels/properties/empty-view.tsx +++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx @@ -1,20 +1,20 @@ -import { HugeiconsIcon } from "@hugeicons/react"; -import { Settings05Icon } from "@hugeicons/core-free-icons"; - -export function EmptyView() { - return ( - <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4"> - <HugeiconsIcon - icon={Settings05Icon} - className="text-muted-foreground/75 size-10" - strokeWidth={1} - /> - <div className="flex flex-col gap-2 text-center"> - <p className="text-lg font-medium ">It's empty here</p> - <p className="text-muted-foreground text-sm text-balance"> - Click an element on the timeline to edit its properties - </p> - </div> - </div> - ); -} +import { HugeiconsIcon } from "@hugeicons/react";
+import { Settings05Icon } from "@hugeicons/core-free-icons";
+
+export function EmptyView() {
+ return (
+ <div className="bg-background flex h-full flex-col items-center justify-center gap-3 p-4">
+ <HugeiconsIcon
+ icon={Settings05Icon}
+ className="text-muted-foreground/75 size-10"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2 text-center">
+ <p className="text-lg font-medium ">It's empty here</p>
+ <p className="text-muted-foreground text-sm text-balance">
+ Click an element on the timeline to edit its properties
+ </p>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 46e6cb6d..95f338e1 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,24 +1,24 @@ -import { useEditor } from "@/hooks/use-editor"; -import { getElementLocalTime } from "@/lib/animation"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; - -export function useElementPlayhead({ - startTime, - duration, -}: { - startTime: number; - duration: number; -}) { - const editor = useEditor(); - const playheadTime = editor.playback.getCurrentTime(); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: startTime, - elementDuration: duration, - }); - const isPlayheadWithinElementRange = - playheadTime >= startTime - TIME_EPSILON_SECONDS && - playheadTime <= startTime + duration + TIME_EPSILON_SECONDS; - - return { localTime, isPlayheadWithinElementRange }; -} +import { useEditor } from "@/hooks/use-editor";
+import { getElementLocalTime } from "@/lib/animation";
+import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
+
+export function useElementPlayhead({
+ startTime,
+ duration,
+}: {
+ startTime: number;
+ duration: number;
+}) {
+ const editor = useEditor();
+ const playheadTime = editor.playback.getCurrentTime();
+ const localTime = getElementLocalTime({
+ timelineTime: playheadTime,
+ elementStartTime: startTime,
+ elementDuration: duration,
+ });
+ const isPlayheadWithinElementRange =
+ playheadTime >= startTime - TIME_EPSILON_SECONDS &&
+ playheadTime <= startTime + duration + TIME_EPSILON_SECONDS;
+
+ return { localTime, isPlayheadWithinElementRange };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts index 957ee1c9..a42e422f 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-color-property.ts @@ -1,95 +1,95 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; - -export function useKeyframedColorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedColor, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedColor: string; - buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>; -}) { - const editor = useEditor(); - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const onChange = ({ color }: { color: string }) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: color, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }], - }); - }; - - const onChangeEnd = () => editor.timeline.commitPreview(); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: resolvedColor }, - ], - }); - }; - - return { - isKeyframedAtTime, - hasAnimatedKeyframes, - keyframeIdAtTime, - onChange, - onChangeEnd, - toggleKeyframe, - }; -} +import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+
+export function useKeyframedColorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedColor: string;
+ buildBaseUpdates: ({ value }: { value: string }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const onChange = ({ color }: { color: string }) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: color,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: color }) }],
+ });
+ };
+
+ const onChangeEnd = () => editor.timeline.commitPreview();
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [{ trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: resolvedColor },
+ ],
+ });
+ };
+
+ return {
+ isKeyframedAtTime,
+ hasAnimatedKeyframes,
+ keyframeIdAtTime,
+ onChange,
+ onChangeEnd,
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index 0f0168f8..ddd3d2e4 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -1,161 +1,161 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedNumberProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - displayValue, - parse, - valueAtPlayhead, - step, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - displayValue: string; - parse: (input: string) => number | null; - valueAtPlayhead: number; - step?: number; - buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>; -}) { - const editor = useEditor(); - const snapValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: nextValue, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - const propertyDraft = usePropertyDraft({ - displayValue, - parse: (input) => { - const parsedValue = parse(input); - return parsedValue === null ? null : snapValue(parsedValue); - }, - onPreview: (value) => previewValue({ value }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: snapValue(valueAtPlayhead), - }, - ], - }); - }; - - const commitValue = ({ value }: { value: number }) => { - const nextValue = snapValue(value); - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: nextValue, - }, - ], - }); - return; - } - - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value: nextValue }), - }, - ], - }); - }; - - return { - ...propertyDraft, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitValue, - }; -} +import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type { AnimationPropertyPath, ElementAnimations } from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedNumberProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue,
+ parse,
+ valueAtPlayhead,
+ step,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ displayValue: string;
+ parse: (input: string) => number | null;
+ valueAtPlayhead: number;
+ step?: number;
+ buildBaseUpdates: ({ value }: { value: number }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+ const snapValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({ animations, propertyPath });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: nextValue,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value: nextValue }),
+ },
+ ],
+ });
+ };
+
+ const propertyDraft = usePropertyDraft({
+ displayValue,
+ parse: (input) => {
+ const parsedValue = parse(input);
+ return parsedValue === null ? null : snapValue(parsedValue);
+ },
+ onPreview: (value) => previewValue({ value }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: snapValue(valueAtPlayhead),
+ },
+ ],
+ });
+ };
+
+ const commitValue = ({ value }: { value: number }) => {
+ const nextValue = snapValue(value);
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: nextValue,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value: nextValue }),
+ },
+ ],
+ });
+ };
+
+ return {
+ ...propertyDraft,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitValue,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 335e3fb6..92b5d634 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -1,148 +1,148 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { - buildGraphicParamPath, - getKeyframeAtTime, - getParamDefaultInterpolation, - getParamValueKind, - hasKeyframesForPath, - upsertPathKeyframe, -} from "@/lib/animation"; -import type { - ElementAnimations, -} from "@/lib/animation/types"; -import type { ParamDefinition } from "@/lib/params"; -import type { TimelineElement } from "@/lib/timeline"; - -export interface KeyframedParamPropertyResult { - hasAnimatedKeyframes: boolean; - isKeyframedAtTime: boolean; - keyframeIdAtTime: string | null; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; - toggleKeyframe: () => void; -} - -export function useKeyframedParamProperty({ - param, - trackId, - elementId, - animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - buildBaseUpdates, -}: { - param: ParamDefinition; - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: number | string | boolean; - buildBaseUpdates: ({ - value, - }: { - value: number | string | boolean; - }) => Partial<TimelineElement>; -}): KeyframedParamPropertyResult { - const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ - animations, - propertyPath, - time: localTime, - }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertPathKeyframe({ - animations, - propertyPath, - time: localTime, - value, - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ - param, - }), - numericRange: - param.type === "number" - ? { min: param.min, max: param.max, step: param.step } - : undefined, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value }), - }, - ], - }); - }; - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - return { - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - onPreview: previewValue, - onCommit: () => editor.timeline.commitPreview(), - toggleKeyframe, - }; -} +"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import {
+ buildGraphicParamPath,
+ getKeyframeAtTime,
+ getParamDefaultInterpolation,
+ getParamValueKind,
+ hasKeyframesForPath,
+ upsertPathKeyframe,
+} from "@/lib/animation";
+import type {
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { ParamDefinition } from "@/lib/params";
+import type { TimelineElement } from "@/lib/timeline";
+
+export interface KeyframedParamPropertyResult {
+ hasAnimatedKeyframes: boolean;
+ isKeyframedAtTime: boolean;
+ keyframeIdAtTime: string | null;
+ onPreview: (value: number | string | boolean) => void;
+ onCommit: () => void;
+ toggleKeyframe: () => void;
+}
+
+export function useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId,
+ animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ buildBaseUpdates,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number | string | boolean;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: number | string | boolean;
+ }) => Partial<TimelineElement>;
+}): KeyframedParamPropertyResult {
+ const editor = useEditor();
+ const propertyPath = buildGraphicParamPath({ paramKey: param.key });
+ const hasAnimatedKeyframes = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time: localTime,
+ })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value,
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({
+ param,
+ }),
+ numericRange:
+ param.type === "number"
+ ? { min: param.min, max: param.max, step: param.step }
+ : undefined,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: buildBaseUpdates({ value }),
+ },
+ ],
+ });
+ };
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) {
+ return;
+ }
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ keyframeId: keyframeIdAtTime,
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ return {
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ onPreview: previewValue,
+ onCommit: () => editor.timeline.commitPreview(),
+ toggleKeyframe,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts index 188f05cb..a342859c 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts @@ -1,190 +1,190 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { - AnimationPropertyPath, - ElementAnimations, - VectorValue, -} from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedVectorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - displayX, - displayY, - parseComponent, - step, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: VectorValue; - displayX: string; - displayY: string; - parseComponent: (input: string) => number | null; - step?: number; - buildBaseUpdates: ({ - value, - }: { - value: VectorValue; - }) => Partial<TimelineElement>; -}) { - const editor = useEditor(); - const snapComponentValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewVector = ({ value }: { value: VectorValue }) => { - const nextValue = { - x: snapComponentValue(value.x), - y: snapComponentValue(value.y), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: nextValue, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }], - }); - }; - - const x = usePropertyDraft({ - displayValue: displayX, - parse: (input) => { - const parsedValue = parseComponent(input); - return parsedValue === null ? null : snapComponentValue(parsedValue); - }, - onPreview: (xVal) => - previewVector({ value: { x: xVal, y: resolvedValue.y } }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const y = usePropertyDraft({ - displayValue: displayY, - parse: (input) => { - const parsedValue = parseComponent(input); - return parsedValue === null ? null : snapComponentValue(parsedValue); - }, - onPreview: (yVal) => - previewVector({ value: { x: resolvedValue.x, y: yVal } }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - const commitX = ({ value }: { value: number }) => { - const vector: VectorValue = { - x: snapComponentValue(value), - y: snapComponentValue(resolvedValue.y), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: vector }, - ], - }); - return; - } - editor.timeline.updateElements({ - updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, - ], - }); - }; - - const commitY = ({ value }: { value: number }) => { - const vector: VectorValue = { - x: snapComponentValue(resolvedValue.x), - y: snapComponentValue(value), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: vector }, - ], - }); - return; - } - editor.timeline.updateElements({ - updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, - ], - }); - }; - - return { - x, - y, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitX, - commitY, - }; -} +import { useEditor } from "@/hooks/use-editor";
+import {
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+ upsertElementKeyframe,
+} from "@/lib/animation";
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ VectorValue,
+} from "@/lib/animation/types";
+import type { TimelineElement } from "@/lib/timeline";
+import { snapToStep } from "@/utils/math";
+import { usePropertyDraft } from "./use-property-draft";
+
+export function useKeyframedVectorProperty({
+ trackId,
+ elementId,
+ animations,
+ propertyPath,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ displayX,
+ displayY,
+ parseComponent,
+ step,
+ buildBaseUpdates,
+}: {
+ trackId: string;
+ elementId: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: VectorValue;
+ displayX: string;
+ displayY: string;
+ parseComponent: (input: string) => number | null;
+ step?: number;
+ buildBaseUpdates: ({
+ value,
+ }: {
+ value: VectorValue;
+ }) => Partial<TimelineElement>;
+}) {
+ const editor = useEditor();
+ const snapComponentValue = (value: number) =>
+ step != null ? snapToStep({ value, step }) : value;
+
+ const hasAnimatedKeyframes = hasKeyframesForPath({
+ animations,
+ propertyPath,
+ });
+ const keyframeAtTime = isPlayheadWithinElementRange
+ ? getKeyframeAtTime({ animations, propertyPath, time: localTime })
+ : null;
+ const keyframeIdAtTime = keyframeAtTime?.id ?? null;
+ const isKeyframedAtTime = keyframeAtTime !== null;
+ const shouldUseAnimatedChannel =
+ hasAnimatedKeyframes && isPlayheadWithinElementRange;
+
+ const previewVector = ({ value }: { value: VectorValue }) => {
+ const nextValue = {
+ x: snapComponentValue(value.x),
+ y: snapComponentValue(value.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ animations: upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time: localTime,
+ value: nextValue,
+ }),
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }],
+ });
+ };
+
+ const x = usePropertyDraft({
+ displayValue: displayX,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (xVal) =>
+ previewVector({ value: { x: xVal, y: resolvedValue.y } }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const y = usePropertyDraft({
+ displayValue: displayY,
+ parse: (input) => {
+ const parsedValue = parseComponent(input);
+ return parsedValue === null ? null : snapComponentValue(parsedValue);
+ },
+ onPreview: (yVal) =>
+ previewVector({ value: { x: resolvedValue.x, y: yVal } }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const toggleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+
+ if (keyframeIdAtTime) {
+ editor.timeline.removeKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime },
+ ],
+ });
+ return;
+ }
+
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId,
+ propertyPath,
+ time: localTime,
+ value: resolvedValue,
+ },
+ ],
+ });
+ };
+
+ const commitX = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(value),
+ y: snapComponentValue(resolvedValue.y),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ const commitY = ({ value }: { value: number }) => {
+ const vector: VectorValue = {
+ x: snapComponentValue(resolvedValue.x),
+ y: snapComponentValue(value),
+ };
+ if (shouldUseAnimatedChannel) {
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ { trackId, elementId, propertyPath, time: localTime, value: vector },
+ ],
+ });
+ return;
+ }
+ editor.timeline.updateElements({
+ updates: [
+ { trackId, elementId, updates: buildBaseUpdates({ value: vector }) },
+ ],
+ });
+ };
+
+ return {
+ x,
+ y,
+ hasAnimatedKeyframes,
+ isKeyframedAtTime,
+ keyframeIdAtTime,
+ toggleKeyframe,
+ commitX,
+ commitY,
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts index 395666cb..0e7ae6a7 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts @@ -1,72 +1,72 @@ -import { useReducer, useRef } from "react"; -import { evaluateMathExpression } from "@/utils/math"; - -function looksLikeExpression({ input }: { input: string }): boolean { - const trimmed = input.trim(); - if (!trimmed) return false; - if (/[+*/]/.test(input)) return true; - const minusIndex = trimmed.indexOf("-"); - return minusIndex > 0; -} - -export function usePropertyDraft<T>({ - displayValue: sourceDisplay, - parse, - onPreview, - onCommit, - supportsExpressions = true, -}: { - displayValue: string; - parse: (input: string) => T | null; - onPreview: (value: T) => void; - onCommit: () => void; - supportsExpressions?: boolean; -}) { - const [, forceRender] = useReducer( - (renderVersion: number) => renderVersion + 1, - 0, - ); - const isEditing = useRef(false); - const draft = useRef(""); - - return { - displayValue: isEditing.current ? draft.current : sourceDisplay, - scrubTo: (value: number) => { - const parsed = parse(String(value)); - if (parsed !== null) onPreview(parsed); - }, - commitScrub: onCommit, - onFocus: () => { - isEditing.current = true; - draft.current = sourceDisplay; - forceRender(); - }, - onChange: ( - event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, - ) => { - draft.current = event.target.value; - forceRender(); - - const parsed = parse(event.target.value); - if (parsed !== null) { - onPreview(parsed); - } - }, - onBlur: () => { - if ( - supportsExpressions && - looksLikeExpression({ input: draft.current }) - ) { - const evaluated = evaluateMathExpression({ input: draft.current }); - if (evaluated !== null) { - const parsed = parse(String(evaluated)); - if (parsed !== null) onPreview(parsed); - } - } - onCommit(); - isEditing.current = false; - draft.current = ""; - forceRender(); - }, - }; -} +import { useReducer, useRef } from "react";
+import { evaluateMathExpression } from "@/utils/math";
+
+function looksLikeExpression({ input }: { input: string }): boolean {
+ const trimmed = input.trim();
+ if (!trimmed) return false;
+ if (/[+*/]/.test(input)) return true;
+ const minusIndex = trimmed.indexOf("-");
+ return minusIndex > 0;
+}
+
+export function usePropertyDraft<T>({
+ displayValue: sourceDisplay,
+ parse,
+ onPreview,
+ onCommit,
+ supportsExpressions = true,
+}: {
+ displayValue: string;
+ parse: (input: string) => T | null;
+ onPreview: (value: T) => void;
+ onCommit: () => void;
+ supportsExpressions?: boolean;
+}) {
+ const [, forceRender] = useReducer(
+ (renderVersion: number) => renderVersion + 1,
+ 0,
+ );
+ const isEditing = useRef(false);
+ const draft = useRef("");
+
+ return {
+ displayValue: isEditing.current ? draft.current : sourceDisplay,
+ scrubTo: (value: number) => {
+ const parsed = parse(String(value));
+ if (parsed !== null) onPreview(parsed);
+ },
+ commitScrub: onCommit,
+ onFocus: () => {
+ isEditing.current = true;
+ draft.current = sourceDisplay;
+ forceRender();
+ },
+ onChange: (
+ event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
+ ) => {
+ draft.current = event.target.value;
+ forceRender();
+
+ const parsed = parse(event.target.value);
+ if (parsed !== null) {
+ onPreview(parsed);
+ }
+ },
+ onBlur: () => {
+ if (
+ supportsExpressions &&
+ looksLikeExpression({ input: draft.current })
+ ) {
+ const evaluated = evaluateMathExpression({ input: draft.current });
+ if (evaluated !== null) {
+ const parsed = parse(String(evaluated));
+ if (parsed !== null) onPreview(parsed);
+ }
+ }
+ onCommit();
+ isEditing.current = false;
+ draft.current = "";
+ forceRender();
+ },
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/registry.tsx b/apps/web/src/components/editor/panels/properties/registry.tsx index 706faef4..38d0a189 100644 --- a/apps/web/src/components/editor/panels/properties/registry.tsx +++ b/apps/web/src/components/editor/panels/properties/registry.tsx @@ -1,303 +1,303 @@ -import type { ReactNode } from "react"; -import type { - EffectElement, - GraphicElement, - ImageElement, - MaskableElement, - RetimableElement, - StickerElement, - TextElement, - VisualElement, - VideoElement, - AudioElement, - TimelineElement, -} from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - TextFontIcon, - ArrowExpandIcon, - RainDropIcon, - MusicNote03Icon, - MagicWand05Icon, - DashboardSpeed02Icon, -} from "@hugeicons/core-free-icons"; -import { TransformTab } from "./tabs/transform-tab"; -import { BlendingTab } from "./tabs/blending-tab"; -import { AudioTab } from "./tabs/audio-tab"; -import { TextTab } from "./tabs/text-tab"; -import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab"; -import { MasksTab } from "./tabs/masks-tab"; -import { SpeedTab } from "./tabs/speed-tab"; -import { GraphicTab } from "./tabs/graphic-tab"; -import { OcShapesIcon } from "@opencut/ui/icons"; - -export type TabContentProps = { - trackId: string; -}; - -export type PropertiesTabDef = { - id: string; - label: string; - icon: ReactNode; - content: (props: TabContentProps) => ReactNode; -}; - -export type ElementPropertiesConfig = { - defaultTab: string; - tabs: PropertiesTabDef[]; -}; - -function buildTransformTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "transform", - label: "Transform", - icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />, - content: ({ trackId }) => ( - <TransformTab element={element} trackId={trackId} /> - ), - }; -} - -function buildBlendingTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "blending", - label: "Blending", - icon: <HugeiconsIcon icon={RainDropIcon} size={16} />, - content: ({ trackId }) => ( - <BlendingTab element={element} trackId={trackId} /> - ), - }; -} - -function buildAudioTab({ - element, -}: { - element: AudioElement | VideoElement; -}): PropertiesTabDef { - return { - id: "audio", - label: "Audio", - icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />, - content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />, - }; -} - -function buildSpeedTab({ - element, -}: { - element: RetimableElement; -}): PropertiesTabDef { - return { - id: "speed", - label: "Speed", - icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />, - content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />, - }; -} - -function buildMasksTab({ - element, -}: { - element: MaskableElement; -}): PropertiesTabDef { - return { - id: "masks", - label: "Masks", - icon: <OcShapesIcon size={16} />, - content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />, - }; -} - -function buildClipEffectsTab({ - element, -}: { - element: VisualElement; -}): PropertiesTabDef { - return { - id: "effects", - label: "Effects", - icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, - content: ({ trackId }) => ( - <ClipEffectsTab element={element} trackId={trackId} /> - ), - }; -} - -function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef { - return { - id: "text", - label: "Text", - icon: <HugeiconsIcon icon={TextFontIcon} size={16} />, - content: ({ trackId }) => <TextTab element={element} trackId={trackId} />, - }; -} - -function buildGraphicTab({ - element, -}: { - element: GraphicElement; -}): PropertiesTabDef { - return { - id: "graphic", - label: "Graphic", - icon: <OcShapesIcon size={16} />, - content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />, - }; -} - -function buildStandaloneEffectTab({ - element, -}: { - element: EffectElement; -}): PropertiesTabDef { - return { - id: "effects", - label: "Effects", - icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />, - content: ({ trackId }) => ( - <StandaloneEffectTab element={element} trackId={trackId} /> - ), - }; -} - -function getTextConfig({ - element, -}: { - element: TextElement; -}): ElementPropertiesConfig { - return { - defaultTab: "text", - tabs: [ - buildTextTab({ element }), - buildTransformTab({ element }), - buildBlendingTab({ element }), - ], - }; -} - -function getVideoConfig({ - element, - mediaAsset, -}: { - element: VideoElement; - mediaAsset: MediaAsset | undefined; -}): ElementPropertiesConfig { - const showAudioTab = mediaAsset?.hasAudio !== false; - return { - defaultTab: "transform", - tabs: [ - buildTransformTab({ element }), - ...(showAudioTab ? [buildAudioTab({ element })] : []), - buildSpeedTab({ element }), - buildBlendingTab({ element }), - buildMasksTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getImageConfig({ - element, -}: { - element: ImageElement; -}): ElementPropertiesConfig { - return { - defaultTab: "transform", - tabs: [ - buildTransformTab({ element }), - buildBlendingTab({ element }), - buildMasksTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getStickerConfig({ - element, -}: { - element: StickerElement; -}): ElementPropertiesConfig { - return { - defaultTab: "transform", - tabs: [ - buildTransformTab({ element }), - buildBlendingTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getGraphicConfig({ - element, -}: { - element: GraphicElement; -}): ElementPropertiesConfig { - return { - defaultTab: "graphic", - tabs: [ - buildGraphicTab({ element }), - buildTransformTab({ element }), - buildBlendingTab({ element }), - buildMasksTab({ element }), - buildClipEffectsTab({ element }), - ], - }; -} - -function getAudioConfig({ - element, -}: { - element: AudioElement; -}): ElementPropertiesConfig { - return { - defaultTab: "audio", - tabs: [buildAudioTab({ element }), buildSpeedTab({ element })], - }; -} - -function getEffectConfig({ - element, -}: { - element: EffectElement; -}): ElementPropertiesConfig { - return { - defaultTab: "effects", - tabs: [buildStandaloneEffectTab({ element })], - }; -} - -export function getPropertiesConfig({ - element, - mediaAssets, -}: { - element: TimelineElement; - mediaAssets: MediaAsset[]; -}): ElementPropertiesConfig { - switch (element.type) { - case "text": - return getTextConfig({ element }); - case "video": { - const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId); - return getVideoConfig({ element, mediaAsset }); - } - case "image": - return getImageConfig({ element }); - case "sticker": - return getStickerConfig({ element }); - case "graphic": - return getGraphicConfig({ element }); - case "audio": - return getAudioConfig({ element }); - case "effect": - return getEffectConfig({ element }); - } -} +import type { ReactNode } from "react";
+import type {
+ EffectElement,
+ GraphicElement,
+ ImageElement,
+ MaskableElement,
+ RetimableElement,
+ StickerElement,
+ TextElement,
+ VisualElement,
+ VideoElement,
+ AudioElement,
+ TimelineElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ TextFontIcon,
+ ArrowExpandIcon,
+ RainDropIcon,
+ MusicNote03Icon,
+ MagicWand05Icon,
+ DashboardSpeed02Icon,
+} from "@hugeicons/core-free-icons";
+import { TransformTab } from "./tabs/transform-tab";
+import { BlendingTab } from "./tabs/blending-tab";
+import { AudioTab } from "./tabs/audio-tab";
+import { TextTab } from "./tabs/text-tab";
+import { ClipEffectsTab, StandaloneEffectTab } from "./tabs/effects-tab";
+import { MasksTab } from "./tabs/masks-tab";
+import { SpeedTab } from "./tabs/speed-tab";
+import { GraphicTab } from "./tabs/graphic-tab";
+import { OcShapesIcon } from "@opencut/ui/icons";
+
+export type TabContentProps = {
+ trackId: string;
+};
+
+export type PropertiesTabDef = {
+ id: string;
+ label: string;
+ icon: ReactNode;
+ content: (props: TabContentProps) => ReactNode;
+};
+
+export type ElementPropertiesConfig = {
+ defaultTab: string;
+ tabs: PropertiesTabDef[];
+};
+
+function buildTransformTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "transform",
+ label: "Transform",
+ icon: <HugeiconsIcon icon={ArrowExpandIcon} size={16} />,
+ content: ({ trackId }) => (
+ <TransformTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildBlendingTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "blending",
+ label: "Blending",
+ icon: <HugeiconsIcon icon={RainDropIcon} size={16} />,
+ content: ({ trackId }) => (
+ <BlendingTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildAudioTab({
+ element,
+}: {
+ element: AudioElement | VideoElement;
+}): PropertiesTabDef {
+ return {
+ id: "audio",
+ label: "Audio",
+ icon: <HugeiconsIcon icon={MusicNote03Icon} size={16} />,
+ content: ({ trackId }) => <AudioTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildSpeedTab({
+ element,
+}: {
+ element: RetimableElement;
+}): PropertiesTabDef {
+ return {
+ id: "speed",
+ label: "Speed",
+ icon: <HugeiconsIcon icon={DashboardSpeed02Icon} size={16} />,
+ content: ({ trackId }) => <SpeedTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildMasksTab({
+ element,
+}: {
+ element: MaskableElement;
+}): PropertiesTabDef {
+ return {
+ id: "masks",
+ label: "Masks",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <MasksTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildClipEffectsTab({
+ element,
+}: {
+ element: VisualElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <ClipEffectsTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function buildTextTab({ element }: { element: TextElement }): PropertiesTabDef {
+ return {
+ id: "text",
+ label: "Text",
+ icon: <HugeiconsIcon icon={TextFontIcon} size={16} />,
+ content: ({ trackId }) => <TextTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildGraphicTab({
+ element,
+}: {
+ element: GraphicElement;
+}): PropertiesTabDef {
+ return {
+ id: "graphic",
+ label: "Graphic",
+ icon: <OcShapesIcon size={16} />,
+ content: ({ trackId }) => <GraphicTab element={element} trackId={trackId} />,
+ };
+}
+
+function buildStandaloneEffectTab({
+ element,
+}: {
+ element: EffectElement;
+}): PropertiesTabDef {
+ return {
+ id: "effects",
+ label: "Effects",
+ icon: <HugeiconsIcon icon={MagicWand05Icon} size={16} />,
+ content: ({ trackId }) => (
+ <StandaloneEffectTab element={element} trackId={trackId} />
+ ),
+ };
+}
+
+function getTextConfig({
+ element,
+}: {
+ element: TextElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "text",
+ tabs: [
+ buildTextTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ ],
+ };
+}
+
+function getVideoConfig({
+ element,
+ mediaAsset,
+}: {
+ element: VideoElement;
+ mediaAsset: MediaAsset | undefined;
+}): ElementPropertiesConfig {
+ const showAudioTab = mediaAsset?.hasAudio !== false;
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ ...(showAudioTab ? [buildAudioTab({ element })] : []),
+ buildSpeedTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getImageConfig({
+ element,
+}: {
+ element: ImageElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getStickerConfig({
+ element,
+}: {
+ element: StickerElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "transform",
+ tabs: [
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getGraphicConfig({
+ element,
+}: {
+ element: GraphicElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "graphic",
+ tabs: [
+ buildGraphicTab({ element }),
+ buildTransformTab({ element }),
+ buildBlendingTab({ element }),
+ buildMasksTab({ element }),
+ buildClipEffectsTab({ element }),
+ ],
+ };
+}
+
+function getAudioConfig({
+ element,
+}: {
+ element: AudioElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "audio",
+ tabs: [buildAudioTab({ element }), buildSpeedTab({ element })],
+ };
+}
+
+function getEffectConfig({
+ element,
+}: {
+ element: EffectElement;
+}): ElementPropertiesConfig {
+ return {
+ defaultTab: "effects",
+ tabs: [buildStandaloneEffectTab({ element })],
+ };
+}
+
+export function getPropertiesConfig({
+ element,
+ mediaAssets,
+}: {
+ element: TimelineElement;
+ mediaAssets: MediaAsset[];
+}): ElementPropertiesConfig {
+ switch (element.type) {
+ case "text":
+ return getTextConfig({ element });
+ case "video": {
+ const mediaAsset = mediaAssets.find((a) => a.id === element.mediaId);
+ return getVideoConfig({ element, mediaAsset });
+ }
+ case "image":
+ return getImageConfig({ element });
+ case "sticker":
+ return getStickerConfig({ element });
+ case "graphic":
+ return getGraphicConfig({ element });
+ case "audio":
+ return getAudioConfig({ element });
+ case "effect":
+ return getEffectConfig({ element });
+ }
+}
diff --git a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts index 1783e3fc..bd57d4b0 100644 --- a/apps/web/src/components/editor/panels/properties/stores/properties-store.ts +++ b/apps/web/src/components/editor/panels/properties/stores/properties-store.ts @@ -1,18 +1,18 @@ -import { create } from "zustand"; - -interface PropertiesState { - activeTabPerType: Record<string, string>; - setActiveTab: (elementType: string, tabId: string) => void; - isTransformScaleLocked: boolean; - setTransformScaleLocked: (locked: boolean) => void; -} - -export const usePropertiesStore = create<PropertiesState>()((set) => ({ - activeTabPerType: {}, - setActiveTab: (elementType, tabId) => - set((state) => ({ - activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId }, - })), - isTransformScaleLocked: false, - setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }), -})); +import { create } from "zustand";
+
+interface PropertiesState {
+ activeTabPerType: Record<string, string>;
+ setActiveTab: (elementType: string, tabId: string) => void;
+ isTransformScaleLocked: boolean;
+ setTransformScaleLocked: (locked: boolean) => void;
+}
+
+export const usePropertiesStore = create<PropertiesState>()((set) => ({
+ activeTabPerType: {},
+ setActiveTab: (elementType, tabId) =>
+ set((state) => ({
+ activeTabPerType: { ...state.activeTabPerType, [elementType]: tabId },
+ })),
+ isTransformScaleLocked: false,
+ setTransformScaleLocked: (locked) => set({ isTransformScaleLocked: locked }),
+}));
diff --git a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx index 41b6ec6d..a92f7be2 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/audio-tab.tsx @@ -1,109 +1,109 @@ -import { NumberField } from "@/components/ui/number-field"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; -import { resolveNumberAtTime } from "@/lib/animation"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { VolumeHighIcon } from "@hugeicons/core-free-icons"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; - -export function AudioTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedVolume = resolveNumberAtTime({ - baseValue: element.volume ?? DEFAULTS.element.volume, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - const volume = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "volume", - localTime, - isPlayheadWithinElementRange, - displayValue: resolvedVolume.toFixed(1), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) { - return null; - } - - return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }); - }, - valueAtPlayhead: resolvedVolume, - step: 0.1, - buildBaseUpdates: ({ value }) => ({ - volume: value, - }), - }); - const isDefault = - volume.hasAnimatedKeyframes && isPlayheadWithinElementRange - ? isNearlyEqual({ - leftValue: resolvedVolume, - rightValue: DEFAULTS.element.volume, - }) - : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume; - - return ( - <Section collapsible sectionKey={`${element.id}:audio`}> - <SectionHeader> - <SectionTitle>Audio</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField - label="Volume" - beforeLabel={ - <KeyframeToggle - isActive={volume.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle volume keyframe" - onToggle={volume.toggleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={VolumeHighIcon} />} - value={volume.displayValue} - onFocus={volume.onFocus} - onChange={volume.onChange} - onBlur={volume.onBlur} - dragSensitivity="slow" - scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }} - onScrub={volume.scrubTo} - onScrubEnd={volume.commitScrub} - onReset={() => - volume.commitValue({ - value: DEFAULTS.element.volume, - }) - } - isDefault={isDefault} - suffix="dB" - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} +import { NumberField } from "@/components/ui/number-field";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import { resolveNumberAtTime } from "@/lib/animation";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { VolumeHighIcon } from "@hugeicons/core-free-icons";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+
+export function AudioTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedVolume = resolveNumberAtTime({
+ baseValue: element.volume ?? DEFAULTS.element.volume,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ });
+ const volume = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "volume",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: resolvedVolume.toFixed(1),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) {
+ return null;
+ }
+
+ return clamp({ value: parsed, min: VOLUME_DB_MIN, max: VOLUME_DB_MAX });
+ },
+ valueAtPlayhead: resolvedVolume,
+ step: 0.1,
+ buildBaseUpdates: ({ value }) => ({
+ volume: value,
+ }),
+ });
+ const isDefault =
+ volume.hasAnimatedKeyframes && isPlayheadWithinElementRange
+ ? isNearlyEqual({
+ leftValue: resolvedVolume,
+ rightValue: DEFAULTS.element.volume,
+ })
+ : (element.volume ?? DEFAULTS.element.volume) === DEFAULTS.element.volume;
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:audio`}>
+ <SectionHeader>
+ <SectionTitle>Audio</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField
+ label="Volume"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={volume.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle volume keyframe"
+ onToggle={volume.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={VolumeHighIcon} />}
+ value={volume.displayValue}
+ onFocus={volume.onFocus}
+ onChange={volume.onChange}
+ onBlur={volume.onBlur}
+ dragSensitivity="slow"
+ scrubClamp={{ min: VOLUME_DB_MIN, max: VOLUME_DB_MAX }}
+ onScrub={volume.scrubTo}
+ onScrubEnd={volume.commitScrub}
+ onReset={() =>
+ volume.commitValue({
+ value: DEFAULTS.element.volume,
+ })
+ }
+ isDefault={isDefault}
+ suffix="dB"
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx index c360817c..fdcde148 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx @@ -1,230 +1,230 @@ -import { useEditor } from "@/hooks/use-editor"; -import { clamp } from "@/utils/math"; -import { NumberField } from "@/components/ui/number-field"; -import { OcCheckerboardIcon } from "@opencut/ui/icons"; -import { Fragment, useRef } from "react"; -import { useMenuPreview } from "@/hooks/use-menu-preview"; -import { - Section, - SectionContent, - SectionField, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { - Select, - SelectContent, - SelectItem, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { BlendMode } from "@/lib/rendering"; -import type { ElementType } from "@/lib/timeline"; -import type { ElementAnimations } from "@/lib/animation/types"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { RainDropIcon } from "@hugeicons/core-free-icons"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { resolveOpacityAtTime } from "@/lib/animation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { isPropertyAtDefault } from "./transform-tab"; - -type BlendingElement = { - id: string; - opacity: number; - type: ElementType; - blendMode?: BlendMode; - startTime: number; - duration: number; - animations?: ElementAnimations; -}; - -const BLEND_MODE_GROUPS = [ - [{ value: "normal", label: "Normal" }], - [ - { value: "darken", label: "Darken" }, - { value: "multiply", label: "Multiply" }, - { value: "color-burn", label: "Color Burn" }, - ], - [ - { value: "lighten", label: "Lighten" }, - { value: "screen", label: "Screen" }, - { value: "plus-lighter", label: "Plus Lighter" }, - { value: "color-dodge", label: "Color Dodge" }, - ], - [ - { value: "overlay", label: "Overlay" }, - { value: "soft-light", label: "Soft Light" }, - { value: "hard-light", label: "Hard Light" }, - ], - [ - { value: "difference", label: "Difference" }, - { value: "exclusion", label: "Exclusion" }, - ], - [ - { value: "hue", label: "Hue" }, - { value: "saturation", label: "Saturation" }, - { value: "color", label: "Color" }, - { value: "luminosity", label: "Luminosity" }, - ], -]; - -export function BlendingTab({ - element, - trackId, -}: { - element: BlendingElement; - trackId: string; -}) { - const editor = useEditor(); - const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive()); - const blendMode = element.blendMode ?? DEFAULTS.element.blendMode; - const committedBlendModeRef = useRef(blendMode); - if (!isPreviewActive) { - committedBlendModeRef.current = blendMode; - } - - const { - onPointerLeave, - onOpenChange: handleBlendModeOpenChange, - markCommitted, - } = useMenuPreview(); - - const previewBlendMode = ({ value }: { value: BlendMode }) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { blendMode: value } }, - ], - }); - - const commitBlendMode = (value: string) => { - if (editor.timeline.isPreviewActive()) { - editor.timeline.commitPreview(); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { blendMode: value as BlendMode }, - }, - ], - }); - } - markCommitted(); - }; - - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedOpacity = resolveOpacityAtTime({ - baseOpacity: element.opacity, - animations: element.animations, - localTime, - }); - - const opacity = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "opacity", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOpacity * 100).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ value: parsed, min: 0, max: 100 }) / 100; - }, - valueAtPlayhead: resolvedOpacity, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ opacity: value }), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:blending`}> - <SectionHeader> - <SectionTitle>Blending</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField - label="Opacity" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={opacity.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle opacity keyframe" - onToggle={opacity.toggleKeyframe} - /> - } - > - <NumberField - className="w-full" - icon={ - <OcCheckerboardIcon className="size-3.5 text-muted-foreground" /> - } - value={opacity.displayValue} - min={0} - max={100} - onFocus={opacity.onFocus} - onChange={opacity.onChange} - onBlur={opacity.onBlur} - onScrub={opacity.scrubTo} - onScrubEnd={opacity.commitScrub} - onReset={() => - opacity.commitValue({ value: DEFAULTS.element.opacity }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: opacity.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOpacity, - staticValue: element.opacity, - defaultValue: DEFAULTS.element.opacity, - })} - dragSensitivity="slow" - /> - </SectionField> - <SectionField label="Blend mode" className="w-1/2"> - <Select - value={committedBlendModeRef.current} - onOpenChange={handleBlendModeOpenChange} - onValueChange={commitBlendMode} - > - <SelectTrigger - icon={<HugeiconsIcon icon={RainDropIcon} />} - className="w-full" - > - <SelectValue placeholder="Select blend mode" /> - </SelectTrigger> - <SelectContent className="w-36" onPointerLeave={onPointerLeave}> - {BLEND_MODE_GROUPS.map((group, groupIndex) => ( - <Fragment key={group[0]?.value ?? `group-${groupIndex}`}> - {group.map((option) => ( - <SelectItem - key={option.value} - value={option.value} - onPointerEnter={() => - previewBlendMode({ value: option.value as BlendMode }) - } - > - {option.label} - </SelectItem> - ))} - {groupIndex < BLEND_MODE_GROUPS.length - 1 ? ( - <SelectSeparator /> - ) : null} - </Fragment> - ))} - </SelectContent> - </Select> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} +import { useEditor } from "@/hooks/use-editor";
+import { clamp } from "@/utils/math";
+import { NumberField } from "@/components/ui/number-field";
+import { OcCheckerboardIcon } from "@opencut/ui/icons";
+import { Fragment, useRef } from "react";
+import { useMenuPreview } from "@/hooks/use-menu-preview";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { BlendMode } from "@/lib/rendering";
+import type { ElementType } from "@/lib/timeline";
+import type { ElementAnimations } from "@/lib/animation/types";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { RainDropIcon } from "@hugeicons/core-free-icons";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { resolveOpacityAtTime } from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { isPropertyAtDefault } from "./transform-tab";
+
+type BlendingElement = {
+ id: string;
+ opacity: number;
+ type: ElementType;
+ blendMode?: BlendMode;
+ startTime: number;
+ duration: number;
+ animations?: ElementAnimations;
+};
+
+const BLEND_MODE_GROUPS = [
+ [{ value: "normal", label: "Normal" }],
+ [
+ { value: "darken", label: "Darken" },
+ { value: "multiply", label: "Multiply" },
+ { value: "color-burn", label: "Color Burn" },
+ ],
+ [
+ { value: "lighten", label: "Lighten" },
+ { value: "screen", label: "Screen" },
+ { value: "plus-lighter", label: "Plus Lighter" },
+ { value: "color-dodge", label: "Color Dodge" },
+ ],
+ [
+ { value: "overlay", label: "Overlay" },
+ { value: "soft-light", label: "Soft Light" },
+ { value: "hard-light", label: "Hard Light" },
+ ],
+ [
+ { value: "difference", label: "Difference" },
+ { value: "exclusion", label: "Exclusion" },
+ ],
+ [
+ { value: "hue", label: "Hue" },
+ { value: "saturation", label: "Saturation" },
+ { value: "color", label: "Color" },
+ { value: "luminosity", label: "Luminosity" },
+ ],
+];
+
+export function BlendingTab({
+ element,
+ trackId,
+}: {
+ element: BlendingElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isPreviewActive = useEditor((e) => e.timeline.isPreviewActive());
+ const blendMode = element.blendMode ?? DEFAULTS.element.blendMode;
+ const committedBlendModeRef = useRef(blendMode);
+ if (!isPreviewActive) {
+ committedBlendModeRef.current = blendMode;
+ }
+
+ const {
+ onPointerLeave,
+ onOpenChange: handleBlendModeOpenChange,
+ markCommitted,
+ } = useMenuPreview();
+
+ const previewBlendMode = ({ value }: { value: BlendMode }) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { blendMode: value } },
+ ],
+ });
+
+ const commitBlendMode = (value: string) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { blendMode: value as BlendMode },
+ },
+ ],
+ });
+ }
+ markCommitted();
+ };
+
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedOpacity = resolveOpacityAtTime({
+ baseOpacity: element.opacity,
+ animations: element.animations,
+ localTime,
+ });
+
+ const opacity = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "opacity",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOpacity * 100).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min: 0, max: 100 }) / 100;
+ },
+ valueAtPlayhead: resolvedOpacity,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({ opacity: value }),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:blending`}>
+ <SectionHeader>
+ <SectionTitle>Blending</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="Opacity"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={opacity.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle opacity keyframe"
+ onToggle={opacity.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ className="w-full"
+ icon={
+ <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ }
+ value={opacity.displayValue}
+ min={0}
+ max={100}
+ onFocus={opacity.onFocus}
+ onChange={opacity.onChange}
+ onBlur={opacity.onBlur}
+ onScrub={opacity.scrubTo}
+ onScrubEnd={opacity.commitScrub}
+ onReset={() =>
+ opacity.commitValue({ value: DEFAULTS.element.opacity })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: opacity.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOpacity,
+ staticValue: element.opacity,
+ defaultValue: DEFAULTS.element.opacity,
+ })}
+ dragSensitivity="slow"
+ />
+ </SectionField>
+ <SectionField label="Blend mode" className="w-1/2">
+ <Select
+ value={committedBlendModeRef.current}
+ onOpenChange={handleBlendModeOpenChange}
+ onValueChange={commitBlendMode}
+ >
+ <SelectTrigger
+ icon={<HugeiconsIcon icon={RainDropIcon} />}
+ className="w-full"
+ >
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36" onPointerLeave={onPointerLeave}>
+ {BLEND_MODE_GROUPS.map((group, groupIndex) => (
+ <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
+ {group.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ onPointerEnter={() =>
+ previewBlendMode({ value: option.value as BlendMode })
+ }
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+ <SelectSeparator />
+ ) : null}
+ </Fragment>
+ ))}
+ </SelectContent>
+ </Select>
+ </SectionField>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx index 1b94ee2d..759ced60 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/effects-tab.tsx @@ -1,309 +1,309 @@ -"use client"; - -import { useState } from "react"; -import type { ParamValues } from "@/lib/params"; -import type { Effect } from "@/lib/effects/types"; -import type { EffectElement, VisualElement } from "@/lib/timeline"; -import { effectsRegistry } from "@/lib/effects"; -import { useEditor } from "@/hooks/use-editor"; -import { useElementPreview } from "@/hooks/use-element-preview"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, - SectionFields, -} from "@/components/section"; -import { PropertyParamField } from "../components/property-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - Delete02Icon, - ViewIcon, - ViewOffSlashIcon, - MagicWand05Icon, -} from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; -import { Separator } from "@/components/ui/separator"; -import { useAssetsPanelStore } from "@/stores/assets-panel-store"; - -export function StandaloneEffectTab({ - element, - trackId, -}: { - element: EffectElement; - trackId: string; -}) { - const { renderElement, previewUpdates, commit } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const effect: Effect = { - id: element.id, - type: element.effectType, - params: element.params, - enabled: true, - }; - - const previewParam = (key: string) => (value: number | string | boolean) => { - previewUpdates({ - params: { ...(renderElement as EffectElement).params, [key]: value }, - }); - }; - - return ( - <div className="flex flex-col h-full"> - <div className="border-b px-3.5 h-11 shrink-0 flex items-center"> - <SectionTitle>Effect</SectionTitle> - </div> - <EffectSection - effect={effect} - renderParams={(renderElement as EffectElement).params} - previewParam={previewParam} - onCommit={commit} - /> - </div> - ); -} - -export function ClipEffectsTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const [dragIndex, setDragIndex] = useState<number | null>(null); - const [dropIndex, setDropIndex] = useState<number | null>(null); - const editor = useEditor(); - const { renderElement, previewUpdates, commit } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const effects: Effect[] = element.effects ?? []; - - const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => { - return ( - (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId) - ?.params ?? - effects.find((ef) => ef.id === effectId)?.params ?? - {} - ); - }; - - const buildPreviewParam = - (effectId: string) => - (key: string) => - (value: number | string | boolean) => { - const updatedEffects = ( - (renderElement as VisualElement).effects ?? [] - ).map((existing) => - existing.id !== effectId - ? existing - : { ...existing, params: { ...existing.params, [key]: value } }, - ); - previewUpdates({ effects: updatedEffects }); - }; - - const handleDragStart = ({ index }: { index: number }) => setDragIndex(index); - - const handleDragOver = ({ - event, - index, - }: { - event: React.DragEvent; - index: number; - }) => { - event.preventDefault(); - if (index !== dropIndex) setDropIndex(index); - }; - - const handleDrop = ({ toIndex }: { toIndex: number }) => { - if (dragIndex !== null && dragIndex !== toIndex) { - editor.timeline.reorderClipEffects({ - trackId, - elementId: element.id, - fromIndex: dragIndex, - toIndex, - }); - } - setDragIndex(null); - setDropIndex(null); - }; - - const handleDragEnd = () => { - setDragIndex(null); - setDropIndex(null); - }; - - return ( - <div className="flex flex-col h-full"> - <div className="border-b px-3.5 h-11 shrink-0 flex items-center"> - <SectionTitle>Effects</SectionTitle> - </div> - {effects.length === 0 ? ( - <EmptyView /> - ) : ( - <ul className="flex flex-col"> - {effects.map((effect, index) => { - const resolvedDragIndex = dragIndex ?? -1; - const isDragging = dragIndex === index; - const isDropTarget = - dropIndex === index && dragIndex !== null && dragIndex !== index; - const showTopDropIndicator = - isDropTarget && index < resolvedDragIndex; - const showBottomDropIndicator = - isDropTarget && index > resolvedDragIndex; - - return ( - <li - key={effect.id} - draggable - onDragStart={() => handleDragStart({ index })} - onDragOver={(event) => handleDragOver({ event, index })} - onDrop={() => handleDrop({ toIndex: index })} - onDragEnd={handleDragEnd} - className={cn( - "group list-none", - isDragging && "opacity-40", - showTopDropIndicator && "border-t-2 border-primary", - showBottomDropIndicator && "border-b-2 border-primary", - )} - > - <EffectSection - effect={effect} - renderParams={getRenderParams({ effectId: effect.id })} - previewParam={buildPreviewParam(effect.id)} - onCommit={commit} - onToggle={() => - editor.timeline.toggleClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - onRemove={() => - editor.timeline.removeClipEffect({ - trackId, - elementId: element.id, - effectId: effect.id, - }) - } - /> - </li> - ); - })} - </ul> - )} - </div> - ); -} - -function EmptyView() { - const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab); - - return ( - <div className="flex flex-col h-full items-center justify-center gap-4 text-center"> - <HugeiconsIcon - icon={MagicWand05Icon} - className="size-10 text-muted-foreground" - strokeWidth={1} - /> - <div className="flex flex-col gap-2"> - <h3 className="font-medium text-foreground">No effects</h3> - <p className="text-muted-foreground text-sm text-balance max-w-44"> - Add effects to this layer from the Assets panel. - </p> - </div> - <Button - variant="default" - size="sm" - onClick={() => setActiveTab("effects")} - > - Open effects - </Button> - </div> - ); -} - -function EffectSection({ - effect, - renderParams, - previewParam, - onCommit, - onToggle, - onRemove, -}: { - effect: Effect; - renderParams: ParamValues; - previewParam: (key: string) => (value: number | string | boolean) => void; - onCommit: () => void; - onToggle?: () => void; - onRemove?: () => void; -}) { - const definition = effectsRegistry.get(effect.type); - - return ( - <Section - sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined} - showTopBorder={false} - > - <SectionHeader - className={cn(onToggle && "cursor-move")} - trailing={ - onToggle && ( - <div className="flex items-center gap-1"> - <Button - variant={effect.enabled ? "secondary" : "ghost"} - size="icon" - aria-label={`Toggle ${definition.name}`} - onClick={onToggle} - > - <HugeiconsIcon - icon={effect.enabled ? ViewIcon : ViewOffSlashIcon} - /> - </Button> - <Button - variant="ghost" - size="icon" - aria-label={`Remove ${definition.name}`} - onClick={onRemove} - > - <HugeiconsIcon icon={Delete02Icon} /> - </Button> - </div> - ) - } - > - <SectionTitle - className={cn(onToggle && !effect.enabled && "text-muted-foreground")} - > - {definition.name} - </SectionTitle> - </SectionHeader> - <SectionContent - className={cn("p-0", onToggle && !effect.enabled && "opacity-50")} - > - <SectionFields> - {definition.params.map((param) => ( - <div key={param.key} className="flex flex-col gap-3.5"> - <div className="px-4"> - <PropertyParamField - param={param} - value={renderParams[param.key] ?? param.default} - onPreview={previewParam(param.key)} - onCommit={onCommit} - /> - </div> - <Separator /> - </div> - ))} - </SectionFields> - </SectionContent> - </Section> - ); -} +"use client";
+
+import { useState } from "react";
+import type { ParamValues } from "@/lib/params";
+import type { Effect } from "@/lib/effects/types";
+import type { EffectElement, VisualElement } from "@/lib/timeline";
+import { effectsRegistry } from "@/lib/effects";
+import { useEditor } from "@/hooks/use-editor";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTitle,
+ SectionFields,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Delete02Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ MagicWand05Icon,
+} from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+import { Separator } from "@/components/ui/separator";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+
+export function StandaloneEffectTab({
+ element,
+ trackId,
+}: {
+ element: EffectElement;
+ trackId: string;
+}) {
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effect: Effect = {
+ id: element.id,
+ type: element.effectType,
+ params: element.params,
+ enabled: true,
+ };
+
+ const previewParam = (key: string) => (value: number | string | boolean) => {
+ previewUpdates({
+ params: { ...(renderElement as EffectElement).params, [key]: value },
+ });
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effect</SectionTitle>
+ </div>
+ <EffectSection
+ effect={effect}
+ renderParams={(renderElement as EffectElement).params}
+ previewParam={previewParam}
+ onCommit={commit}
+ />
+ </div>
+ );
+}
+
+export function ClipEffectsTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const [dragIndex, setDragIndex] = useState<number | null>(null);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+ const editor = useEditor();
+ const { renderElement, previewUpdates, commit } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const effects: Effect[] = element.effects ?? [];
+
+ const getRenderParams = ({ effectId }: { effectId: string }): ParamValues => {
+ return (
+ (renderElement as VisualElement).effects?.find((ef) => ef.id === effectId)
+ ?.params ??
+ effects.find((ef) => ef.id === effectId)?.params ??
+ {}
+ );
+ };
+
+ const buildPreviewParam =
+ (effectId: string) =>
+ (key: string) =>
+ (value: number | string | boolean) => {
+ const updatedEffects = (
+ (renderElement as VisualElement).effects ?? []
+ ).map((existing) =>
+ existing.id !== effectId
+ ? existing
+ : { ...existing, params: { ...existing.params, [key]: value } },
+ );
+ previewUpdates({ effects: updatedEffects });
+ };
+
+ const handleDragStart = ({ index }: { index: number }) => setDragIndex(index);
+
+ const handleDragOver = ({
+ event,
+ index,
+ }: {
+ event: React.DragEvent;
+ index: number;
+ }) => {
+ event.preventDefault();
+ if (index !== dropIndex) setDropIndex(index);
+ };
+
+ const handleDrop = ({ toIndex }: { toIndex: number }) => {
+ if (dragIndex !== null && dragIndex !== toIndex) {
+ editor.timeline.reorderClipEffects({
+ trackId,
+ elementId: element.id,
+ fromIndex: dragIndex,
+ toIndex,
+ });
+ }
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ const handleDragEnd = () => {
+ setDragIndex(null);
+ setDropIndex(null);
+ };
+
+ return (
+ <div className="flex flex-col h-full">
+ <div className="border-b px-3.5 h-11 shrink-0 flex items-center">
+ <SectionTitle>Effects</SectionTitle>
+ </div>
+ {effects.length === 0 ? (
+ <EmptyView />
+ ) : (
+ <ul className="flex flex-col">
+ {effects.map((effect, index) => {
+ const resolvedDragIndex = dragIndex ?? -1;
+ const isDragging = dragIndex === index;
+ const isDropTarget =
+ dropIndex === index && dragIndex !== null && dragIndex !== index;
+ const showTopDropIndicator =
+ isDropTarget && index < resolvedDragIndex;
+ const showBottomDropIndicator =
+ isDropTarget && index > resolvedDragIndex;
+
+ return (
+ <li
+ key={effect.id}
+ draggable
+ onDragStart={() => handleDragStart({ index })}
+ onDragOver={(event) => handleDragOver({ event, index })}
+ onDrop={() => handleDrop({ toIndex: index })}
+ onDragEnd={handleDragEnd}
+ className={cn(
+ "group list-none",
+ isDragging && "opacity-40",
+ showTopDropIndicator && "border-t-2 border-primary",
+ showBottomDropIndicator && "border-b-2 border-primary",
+ )}
+ >
+ <EffectSection
+ effect={effect}
+ renderParams={getRenderParams({ effectId: effect.id })}
+ previewParam={buildPreviewParam(effect.id)}
+ onCommit={commit}
+ onToggle={() =>
+ editor.timeline.toggleClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ onRemove={() =>
+ editor.timeline.removeClipEffect({
+ trackId,
+ elementId: element.id,
+ effectId: effect.id,
+ })
+ }
+ />
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+function EmptyView() {
+ const setActiveTab = useAssetsPanelStore((s) => s.setActiveTab);
+
+ return (
+ <div className="flex flex-col h-full items-center justify-center gap-4 text-center">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-10 text-muted-foreground"
+ strokeWidth={1}
+ />
+ <div className="flex flex-col gap-2">
+ <h3 className="font-medium text-foreground">No effects</h3>
+ <p className="text-muted-foreground text-sm text-balance max-w-44">
+ Add effects to this layer from the Assets panel.
+ </p>
+ </div>
+ <Button
+ variant="default"
+ size="sm"
+ onClick={() => setActiveTab("effects")}
+ >
+ Open effects
+ </Button>
+ </div>
+ );
+}
+
+function EffectSection({
+ effect,
+ renderParams,
+ previewParam,
+ onCommit,
+ onToggle,
+ onRemove,
+}: {
+ effect: Effect;
+ renderParams: ParamValues;
+ previewParam: (key: string) => (value: number | string | boolean) => void;
+ onCommit: () => void;
+ onToggle?: () => void;
+ onRemove?: () => void;
+}) {
+ const definition = effectsRegistry.get(effect.type);
+
+ return (
+ <Section
+ sectionKey={onToggle ? `clip-effect:${effect.id}` : undefined}
+ showTopBorder={false}
+ >
+ <SectionHeader
+ className={cn(onToggle && "cursor-move")}
+ trailing={
+ onToggle && (
+ <div className="flex items-center gap-1">
+ <Button
+ variant={effect.enabled ? "secondary" : "ghost"}
+ size="icon"
+ aria-label={`Toggle ${definition.name}`}
+ onClick={onToggle}
+ >
+ <HugeiconsIcon
+ icon={effect.enabled ? ViewIcon : ViewOffSlashIcon}
+ />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={`Remove ${definition.name}`}
+ onClick={onRemove}
+ >
+ <HugeiconsIcon icon={Delete02Icon} />
+ </Button>
+ </div>
+ )
+ }
+ >
+ <SectionTitle
+ className={cn(onToggle && !effect.enabled && "text-muted-foreground")}
+ >
+ {definition.name}
+ </SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn("p-0", onToggle && !effect.enabled && "opacity-50")}
+ >
+ <SectionFields>
+ {definition.params.map((param) => (
+ <div key={param.key} className="flex flex-col gap-3.5">
+ <div className="px-4">
+ <PropertyParamField
+ param={param}
+ value={renderParams[param.key] ?? param.default}
+ onPreview={previewParam(param.key)}
+ onCommit={onCommit}
+ />
+ </div>
+ <Separator />
+ </div>
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx index 22dae8cc..4593050e 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx @@ -1,238 +1,238 @@ -"use client"; - -import { useRef } from "react"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { - useKeyframedParamProperty, - type KeyframedParamPropertyResult, -} from "../hooks/use-keyframed-param-property"; -import { resolveGraphicParamsAtTime } from "@/lib/animation"; -import type { ParamDefinition, ParamValues } from "@/lib/params"; -import type { GraphicElement } from "@/lib/timeline"; -import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; -import { useElementPreview } from "@/hooks/use-element-preview"; -import { useEditor } from "@/hooks/use-editor"; -import { - Section, - SectionContent, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { PropertyParamField } from "../components/property-param-field"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -registerDefaultGraphics(); - -const DEFAULT_STROKE_WIDTH = 2; - -export function GraphicTab({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - - const shapeParams = definition.params.filter((p) => p.group !== "stroke"); - const hasStrokeParams = definition.params.some((p) => p.group === "stroke"); - - return ( - <div className="flex flex-col"> - <Section collapsible sectionKey={`${element.id}:graphic`}> - <SectionHeader> - <SectionTitle>{definition.name}</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - {shapeParams.map((param) => ( - <AnimatedGraphicParamField - key={param.key} - param={param} - trackId={trackId} - element={liveElement} - localTime={localTime} - isPlayheadWithinElementRange={isPlayheadWithinElementRange} - resolvedParams={resolvedParams} - /> - ))} - </SectionFields> - </SectionContent> - </Section> - {hasStrokeParams && ( - <StrokeSection element={element} trackId={trackId} /> - )} - </div> - ); -} - -function StrokeSection({ - element, - trackId, -}: { - element: GraphicElement; - trackId: string; -}) { - const editor = useEditor(); - const definition = graphicsRegistry.get(element.definitionId); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const { renderElement } = useElementPreview({ - trackId, - elementId: element.id, - fallback: element, - }); - - const liveElement = renderElement as GraphicElement; - const resolvedParams = resolveGraphicParamsAtTime({ - element: liveElement, - localTime, - }); - const strokeParams = definition.params.filter((p) => p.group === "stroke"); - const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH); - const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0; - - const toggleStroke = () => { - if (isStrokeEnabled) { - lastStrokeWidth.current = Number( - element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH, - ); - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { params: { ...element.params, strokeWidth: 0 } }, - }, - ], - }); - } else { - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - params: { - ...element.params, - strokeWidth: lastStrokeWidth.current, - }, - }, - }, - ], - }); - } - }; - - return ( - <Section - collapsible - defaultOpen={isStrokeEnabled} - sectionKey={`${element.id}:stroke`} - > - <SectionHeader - trailing={ - <Button - variant="ghost" - size="icon" - onClick={(event) => { - event.stopPropagation(); - toggleStroke(); - }} - > - <HugeiconsIcon - icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon} - strokeWidth={1} - /> - </Button> - } - > - <SectionTitle>Stroke</SectionTitle> - </SectionHeader> - <SectionContent - className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")} - > - <SectionFields> - {strokeParams.map((param) => ( - <AnimatedGraphicParamField - key={param.key} - param={param} - trackId={trackId} - element={liveElement} - localTime={localTime} - isPlayheadWithinElementRange={isPlayheadWithinElementRange} - resolvedParams={resolvedParams} - /> - ))} - </SectionFields> - </SectionContent> - </Section> - ); -} - -function AnimatedGraphicParamField({ - param, - trackId, - element, - localTime, - isPlayheadWithinElementRange, - resolvedParams, -}: { - param: ParamDefinition; - trackId: string; - element: GraphicElement; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedParams: ParamValues; -}) { - const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({ - param, - trackId, - elementId: element.id, - animations: element.animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue: resolvedParams[param.key] ?? param.default, - buildBaseUpdates: ({ value }) => ({ - params: { - ...element.params, - [param.key]: value, - }, - }), - }); - - return ( - <PropertyParamField - param={param} - value={resolvedParams[param.key] ?? param.default} - onPreview={animatedParam.onPreview} - onCommit={animatedParam.onCommit} - keyframe={{ - isActive: animatedParam.isKeyframedAtTime, - isDisabled: !isPlayheadWithinElementRange, - onToggle: animatedParam.toggleKeyframe, - }} - /> - ); -} +"use client";
+
+import { useRef } from "react";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import {
+ useKeyframedParamProperty,
+ type KeyframedParamPropertyResult,
+} from "../hooks/use-keyframed-param-property";
+import { resolveGraphicParamsAtTime } from "@/lib/animation";
+import type { ParamDefinition, ParamValues } from "@/lib/params";
+import type { GraphicElement } from "@/lib/timeline";
+import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics";
+import { useElementPreview } from "@/hooks/use-element-preview";
+import { useEditor } from "@/hooks/use-editor";
+import {
+ Section,
+ SectionContent,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { PropertyParamField } from "../components/property-param-field";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+registerDefaultGraphics();
+
+const DEFAULT_STROKE_WIDTH = 2;
+
+export function GraphicTab({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+
+ const shapeParams = definition.params.filter((p) => p.group !== "stroke");
+ const hasStrokeParams = definition.params.some((p) => p.group === "stroke");
+
+ return (
+ <div className="flex flex-col">
+ <Section collapsible sectionKey={`${element.id}:graphic`}>
+ <SectionHeader>
+ <SectionTitle>{definition.name}</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ {shapeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ {hasStrokeParams && (
+ <StrokeSection element={element} trackId={trackId} />
+ )}
+ </div>
+ );
+}
+
+function StrokeSection({
+ element,
+ trackId,
+}: {
+ element: GraphicElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const definition = graphicsRegistry.get(element.definitionId);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const { renderElement } = useElementPreview({
+ trackId,
+ elementId: element.id,
+ fallback: element,
+ });
+
+ const liveElement = renderElement as GraphicElement;
+ const resolvedParams = resolveGraphicParamsAtTime({
+ element: liveElement,
+ localTime,
+ });
+ const strokeParams = definition.params.filter((p) => p.group === "stroke");
+ const lastStrokeWidth = useRef(DEFAULT_STROKE_WIDTH);
+ const isStrokeEnabled = Number(element.params.strokeWidth ?? 0) > 0;
+
+ const toggleStroke = () => {
+ if (isStrokeEnabled) {
+ lastStrokeWidth.current = Number(
+ element.params.strokeWidth ?? DEFAULT_STROKE_WIDTH,
+ );
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { params: { ...element.params, strokeWidth: 0 } },
+ },
+ ],
+ });
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ params: {
+ ...element.params,
+ strokeWidth: lastStrokeWidth.current,
+ },
+ },
+ },
+ ],
+ });
+ }
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={isStrokeEnabled}
+ sectionKey={`${element.id}:stroke`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleStroke();
+ }}
+ >
+ <HugeiconsIcon
+ icon={isStrokeEnabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Stroke</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(!isStrokeEnabled && "pointer-events-none opacity-50")}
+ >
+ <SectionFields>
+ {strokeParams.map((param) => (
+ <AnimatedGraphicParamField
+ key={param.key}
+ param={param}
+ trackId={trackId}
+ element={liveElement}
+ localTime={localTime}
+ isPlayheadWithinElementRange={isPlayheadWithinElementRange}
+ resolvedParams={resolvedParams}
+ />
+ ))}
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function AnimatedGraphicParamField({
+ param,
+ trackId,
+ element,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedParams,
+}: {
+ param: ParamDefinition;
+ trackId: string;
+ element: GraphicElement;
+ localTime: number;
+ isPlayheadWithinElementRange: boolean;
+ resolvedParams: ParamValues;
+}) {
+ const animatedParam: KeyframedParamPropertyResult = useKeyframedParamProperty({
+ param,
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedParams[param.key] ?? param.default,
+ buildBaseUpdates: ({ value }) => ({
+ params: {
+ ...element.params,
+ [param.key]: value,
+ },
+ }),
+ });
+
+ return (
+ <PropertyParamField
+ param={param}
+ value={resolvedParams[param.key] ?? param.default}
+ onPreview={animatedParam.onPreview}
+ onCommit={animatedParam.onCommit}
+ keyframe={{
+ isActive: animatedParam.isKeyframedAtTime,
+ isDisabled: !isPlayheadWithinElementRange,
+ onToggle: animatedParam.toggleKeyframe,
+ }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx index 44f5dd74..4434890e 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/speed-tab.tsx @@ -1,137 +1,137 @@ -import { useRef } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { NumberField } from "@/components/ui/number-field"; -import { Switch } from "@/components/ui/switch"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons"; -import { buildConstantRetime } from "@/lib/retime"; -import { - DEFAULT_RETIME_RATE, - MIN_RETIME_RATE, - MAX_RETIME_RATE, - clampRetimeRate, - canMaintainPitch, -} from "@/constants/retime-constants"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { usePropertyDraft } from "../hooks/use-property-draft"; - -function rateToDisplay({ rate }: { rate: number }): string { - return rate.toFixed(2); -} - -function parseSpeedInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - const rounded = Math.round(parsed * 100) / 100; - return clampRetimeRate({ rate: rounded }); -} - -function buildRetime({ - rate, - maintainPitch, -}: { - rate: number; - maintainPitch: boolean; -}) { - if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined; - return buildConstantRetime({ rate, maintainPitch }); -} - -export function SpeedTab({ - element, - trackId, -}: { - element: AudioElement | VideoElement; - trackId: string; -}) { - const editor = useEditor(); - const rate = clampRetimeRate({ - rate: element.retime?.rate ?? DEFAULT_RETIME_RATE, - }); - const maintainPitchAvailable = canMaintainPitch({ rate }); - const maintainPitch = element.retime?.maintainPitch ?? false; - const pendingRateRef = useRef(rate); - - const applyRetime = ({ - rate: nextRate, - maintainPitch: nextMaintainPitch, - pushHistory = true, - }: { - rate: number; - maintainPitch: boolean; - pushHistory?: boolean; - }) => { - editor.timeline.updateElementRetime({ - trackId, - elementId: element.id, - retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }), - pushHistory, - }); - }; - - const speedDraft = usePropertyDraft({ - displayValue: rateToDisplay({ rate }), - parse: (input) => parseSpeedInput({ input }), - onPreview: (nextRate) => { - pendingRateRef.current = nextRate; - applyRetime({ rate: nextRate, maintainPitch, pushHistory: false }); - }, - onCommit: () => { - applyRetime({ rate: pendingRateRef.current, maintainPitch }); - }, - }); - - return ( - <Section collapsible sectionKey={`${element.id}:speed`}> - <SectionHeader> - <SectionTitle>Speed</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField label="Speed"> - <NumberField - icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />} - value={speedDraft.displayValue} - suffix="x" - scrubRanges={[ - { from: 0.01, to: 1, pixelsPerUnit: 160 }, - { from: 1, to: 5, pixelsPerUnit: 48 }, - ]} - scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }} - onFocus={() => { - pendingRateRef.current = rate; - speedDraft.onFocus(); - }} - onChange={speedDraft.onChange} - onBlur={speedDraft.onBlur} - onScrub={speedDraft.scrubTo} - onScrubEnd={speedDraft.commitScrub} - onReset={() => - applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch }) - } - isDefault={rate === DEFAULT_RETIME_RATE} - /> - </SectionField> - <div className="flex items-center justify-between"> - <span className="text-sm">Change pitch</span> - <Switch - checked={!maintainPitch} - disabled={!maintainPitchAvailable} - onCheckedChange={(checked) => - applyRetime({ rate, maintainPitch: !checked }) - } - /> - </div> - </SectionFields> - </SectionContent> - </Section> - ); -} +import { useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { NumberField } from "@/components/ui/number-field";
+import { Switch } from "@/components/ui/switch";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { DashboardSpeed02Icon } from "@hugeicons/core-free-icons";
+import { buildConstantRetime } from "@/lib/retime";
+import {
+ DEFAULT_RETIME_RATE,
+ MIN_RETIME_RATE,
+ MAX_RETIME_RATE,
+ clampRetimeRate,
+ canMaintainPitch,
+} from "@/constants/retime-constants";
+import type { AudioElement, VideoElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+
+function rateToDisplay({ rate }: { rate: number }): string {
+ return rate.toFixed(2);
+}
+
+function parseSpeedInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ const rounded = Math.round(parsed * 100) / 100;
+ return clampRetimeRate({ rate: rounded });
+}
+
+function buildRetime({
+ rate,
+ maintainPitch,
+}: {
+ rate: number;
+ maintainPitch: boolean;
+}) {
+ if (rate === DEFAULT_RETIME_RATE && !maintainPitch) return undefined;
+ return buildConstantRetime({ rate, maintainPitch });
+}
+
+export function SpeedTab({
+ element,
+ trackId,
+}: {
+ element: AudioElement | VideoElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const rate = clampRetimeRate({
+ rate: element.retime?.rate ?? DEFAULT_RETIME_RATE,
+ });
+ const maintainPitchAvailable = canMaintainPitch({ rate });
+ const maintainPitch = element.retime?.maintainPitch ?? false;
+ const pendingRateRef = useRef(rate);
+
+ const applyRetime = ({
+ rate: nextRate,
+ maintainPitch: nextMaintainPitch,
+ pushHistory = true,
+ }: {
+ rate: number;
+ maintainPitch: boolean;
+ pushHistory?: boolean;
+ }) => {
+ editor.timeline.updateElementRetime({
+ trackId,
+ elementId: element.id,
+ retime: buildRetime({ rate: nextRate, maintainPitch: nextMaintainPitch }),
+ pushHistory,
+ });
+ };
+
+ const speedDraft = usePropertyDraft({
+ displayValue: rateToDisplay({ rate }),
+ parse: (input) => parseSpeedInput({ input }),
+ onPreview: (nextRate) => {
+ pendingRateRef.current = nextRate;
+ applyRetime({ rate: nextRate, maintainPitch, pushHistory: false });
+ },
+ onCommit: () => {
+ applyRetime({ rate: pendingRateRef.current, maintainPitch });
+ },
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:speed`}>
+ <SectionHeader>
+ <SectionTitle>Speed</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Speed">
+ <NumberField
+ icon={<HugeiconsIcon icon={DashboardSpeed02Icon} />}
+ value={speedDraft.displayValue}
+ suffix="x"
+ scrubRanges={[
+ { from: 0.01, to: 1, pixelsPerUnit: 160 },
+ { from: 1, to: 5, pixelsPerUnit: 48 },
+ ]}
+ scrubClamp={{ min: MIN_RETIME_RATE, max: MAX_RETIME_RATE }}
+ onFocus={() => {
+ pendingRateRef.current = rate;
+ speedDraft.onFocus();
+ }}
+ onChange={speedDraft.onChange}
+ onBlur={speedDraft.onBlur}
+ onScrub={speedDraft.scrubTo}
+ onScrubEnd={speedDraft.commitScrub}
+ onReset={() =>
+ applyRetime({ rate: DEFAULT_RETIME_RATE, maintainPitch })
+ }
+ isDefault={rate === DEFAULT_RETIME_RATE}
+ />
+ </SectionField>
+ <div className="flex items-center justify-between">
+ <span className="text-sm">Change pitch</span>
+ <Switch
+ checked={!maintainPitch}
+ disabled={!maintainPitchAvailable}
+ onCheckedChange={(checked) =>
+ applyRetime({ rate, maintainPitch: !checked })
+ }
+ />
+ </div>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx index f8e2527a..45831dc0 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -1,762 +1,762 @@ -"use client"; - -import { Textarea } from "@/components/ui/textarea"; -import { FontPicker } from "@/components/ui/font-picker"; -import type { TextElement } from "@/lib/timeline"; -import { NumberField } from "@/components/ui/number-field"; -import { useRef } from "react"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { ColorPicker } from "@/components/ui/color-picker"; -import { Button } from "@/components/ui/button"; -import { uppercase } from "@/utils/string"; -import { clamp } from "@/utils/math"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_COLOR } from "@/constants/project-constants"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, - MAX_FONT_SIZE, - MIN_FONT_SIZE, -} from "@/constants/text-constants"; -import { usePropertyDraft } from "../hooks/use-property-draft"; -import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { isPropertyAtDefault } from "./transform-tab"; -import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - MinusSignIcon, - PlusSignIcon, - TextFontIcon, -} from "@hugeicons/core-free-icons"; -import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { cn } from "@/utils/ui"; - -export function TextTab({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - return ( - <div className="flex flex-col"> - <ContentSection element={element} trackId={trackId} /> - <TypographySection element={element} trackId={trackId} /> - <SpacingSection element={element} trackId={trackId} /> - <BackgroundSection element={element} trackId={trackId} /> - </div> - ); -} - -function ContentSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const content = usePropertyDraft({ - displayValue: element.content, - parse: (input) => input, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { content: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:content`}> - <SectionHeader> - <SectionTitle>Content</SectionTitle> - </SectionHeader> - <SectionContent> - <Textarea - placeholder="Name" - value={content.displayValue} - className="min-h-20" - onFocus={content.onFocus} - onChange={content.onChange} - onBlur={content.onBlur} - /> - </SectionContent> - </Section> - ); -} - -function TypographySection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTextColor = resolveColorAtTime({ - baseColor: element.color, - animations: element.animations, - propertyPath: "color", - localTime, - }); - - const textColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedTextColor, - buildBaseUpdates: ({ value }) => ({ color: value }), - }); - - const fontSize = usePropertyDraft({ - displayValue: element.fontSize.toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: MIN_FONT_SIZE, - max: MAX_FONT_SIZE, - }); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { fontSize: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:typography`}> - <SectionHeader> - <SectionTitle>Typography</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <SectionField label="Font"> - <FontPicker - defaultValue={element.fontFamily} - onValueChange={(value) => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { fontFamily: value }, - }, - ], - }) - } - /> - </SectionField> - <SectionField label="Size"> - <NumberField - value={fontSize.displayValue} - min={MIN_FONT_SIZE} - max={MAX_FONT_SIZE} - onFocus={fontSize.onFocus} - onChange={fontSize.onChange} - onBlur={fontSize.onBlur} - onScrub={fontSize.scrubTo} - onScrubEnd={fontSize.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - fontSize: DEFAULTS.text.element.fontSize, - }, - }, - ], - }) - } - isDefault={element.fontSize === DEFAULTS.text.element.fontSize} - icon={<HugeiconsIcon icon={TextFontIcon} />} - /> - </SectionField> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={textColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle text color keyframe" - onToggle={textColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={uppercase({ - string: resolvedTextColor.replace("#", ""), - })} - onChange={(color) => textColor.onChange({ color: `#${color}` })} - onChangeEnd={textColor.onChangeEnd} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} - -function SpacingSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - - const letterSpacing = usePropertyDraft({ - displayValue: Math.round( - element.letterSpacing ?? DEFAULTS.text.letterSpacing, - ).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { letterSpacing: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const lineHeight = usePropertyDraft({ - displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) - ? null - : Math.max(0.1, Math.round(parsed * 10) / 10); - }, - onPreview: (value) => - editor.timeline.previewElements({ - updates: [ - { trackId, elementId: element.id, updates: { lineHeight: value } }, - ], - }), - onCommit: () => editor.timeline.commitPreview(), - }); - - return ( - <Section collapsible sectionKey={`${element.id}:spacing`}> - <SectionHeader> - <SectionTitle>Spacing</SectionTitle> - </SectionHeader> - <SectionContent> - <div className="flex items-start gap-2"> - <SectionField label="Letter spacing" className="w-1/2"> - <NumberField - value={letterSpacing.displayValue} - onFocus={letterSpacing.onFocus} - onChange={letterSpacing.onChange} - onBlur={letterSpacing.onBlur} - onScrub={letterSpacing.scrubTo} - onScrubEnd={letterSpacing.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { letterSpacing: DEFAULTS.text.letterSpacing }, - }, - ], - }) - } - isDefault={ - (element.letterSpacing ?? DEFAULTS.text.letterSpacing) === - DEFAULTS.text.letterSpacing - } - icon={<OcTextWidthIcon size={14} />} - /> - </SectionField> - <SectionField label="Line height" className="w-1/2"> - <NumberField - value={lineHeight.displayValue} - onFocus={lineHeight.onFocus} - onChange={lineHeight.onChange} - onBlur={lineHeight.onBlur} - onScrub={lineHeight.scrubTo} - onScrubEnd={lineHeight.commitScrub} - onReset={() => - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { lineHeight: DEFAULTS.text.lineHeight }, - }, - ], - }) - } - isDefault={ - (element.lineHeight ?? DEFAULTS.text.lineHeight) === - DEFAULTS.text.lineHeight - } - icon={<OcTextHeightIcon size={14} />} - /> - </SectionField> - </div> - </SectionContent> - </Section> - ); -} - -function BackgroundSection({ - element, - trackId, -}: { - element: TextElement; - trackId: string; -}) { - const editor = useEditor(); - const lastSelectedColor = useRef(DEFAULT_COLOR); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedBgColor = resolveColorAtTime({ - baseColor: element.background.color, - animations: element.animations, - propertyPath: "background.color", - localTime, - }); - - const bgColor = useKeyframedColorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.color", - localTime, - isPlayheadWithinElementRange, - resolvedColor: resolvedBgColor, - buildBaseUpdates: ({ value }) => ({ - background: { ...element.background, color: value }, - }), - }); - - const bg = element.background; - - const resolvedPaddingX = resolveNumberAtTime({ - baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - }); - const resolvedPaddingY = resolveNumberAtTime({ - baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - }); - const resolvedOffsetX = resolveNumberAtTime({ - baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - }); - const resolvedOffsetY = resolveNumberAtTime({ - baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - }); - const resolvedCornerRadius = resolveNumberAtTime({ - baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - }); - - const paddingX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingX: value }, - }), - }); - - const paddingY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.paddingY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedPaddingY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); - }, - valueAtPlayhead: resolvedPaddingY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, paddingY: value }, - }), - }); - - const offsetX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetX).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetX, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetX: value }, - }), - }); - - const offsetY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.offsetY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedOffsetY).toString(), - parse: (input) => { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : Math.round(parsed); - }, - valueAtPlayhead: resolvedOffsetY, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, offsetY: value }, - }), - }); - - const cornerRadius = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "background.cornerRadius", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedCornerRadius).toString(), - parse: (input) => { - const parsed = parseFloat(input); - if (Number.isNaN(parsed)) return null; - return clamp({ - value: Math.round(parsed), - min: CORNER_RADIUS_MIN, - max: CORNER_RADIUS_MAX, - }); - }, - valueAtPlayhead: resolvedCornerRadius, - step: 1, - buildBaseUpdates: ({ value }) => ({ - background: { ...bg, cornerRadius: value }, - }), - }); - - const toggleBackgroundEnabled = () => { - const enabled = !element.background.enabled; - const color = - enabled && element.background.color === "transparent" - ? lastSelectedColor.current - : element.background.color; - editor.timeline.updateElements({ - updates: [ - { - trackId, - elementId: element.id, - updates: { - background: { - ...element.background, - enabled, - color, - }, - }, - }, - ], - }); - }; - - return ( - <Section - collapsible - defaultOpen={element.background.enabled} - sectionKey={`${element.id}:background`} - > - <SectionHeader - trailing={ - <Button - variant="ghost" - size="icon" - onClick={(event) => { - event.stopPropagation(); - toggleBackgroundEnabled(); - }} - > - <HugeiconsIcon - icon={element.background.enabled ? MinusSignIcon : PlusSignIcon} - strokeWidth={1} - /> - </Button> - } - > - <SectionTitle>Background</SectionTitle> - </SectionHeader> - <SectionContent - className={cn( - !element.background.enabled && "pointer-events-none opacity-50", - )} - > - <SectionFields> - <SectionField - label="Color" - beforeLabel={ - <KeyframeToggle - isActive={bgColor.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background color keyframe" - onToggle={bgColor.toggleKeyframe} - /> - } - > - <ColorPicker - value={ - !element.background.enabled || - element.background.color === "transparent" - ? lastSelectedColor.current.replace("#", "") - : resolvedBgColor.replace("#", "") - } - onChange={(color) => { - const hexColor = `#${color}`; - if (color !== "transparent") { - lastSelectedColor.current = hexColor; - } - bgColor.onChange({ color: hexColor }); - }} - onChangeEnd={bgColor.onChangeEnd} - /> - </SectionField> - <div className="flex items-start gap-2"> - <SectionField - label="Width" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background width keyframe" - onToggle={paddingX.toggleKeyframe} - /> - } - > - <NumberField - icon="W" - value={paddingX.displayValue} - min={0} - onFocus={paddingX.onFocus} - onChange={paddingX.onChange} - onBlur={paddingX.onBlur} - onScrub={paddingX.scrubTo} - onScrubEnd={paddingX.commitScrub} - onReset={() => - paddingX.commitValue({ - value: DEFAULTS.text.background.paddingX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingX, - staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, - defaultValue: DEFAULTS.text.background.paddingX, - })} - /> - </SectionField> - <SectionField - label="Height" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={paddingY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle background height keyframe" - onToggle={paddingY.toggleKeyframe} - /> - } - > - <NumberField - icon="H" - value={paddingY.displayValue} - min={0} - onFocus={paddingY.onFocus} - onChange={paddingY.onChange} - onBlur={paddingY.onBlur} - onScrub={paddingY.scrubTo} - onScrubEnd={paddingY.commitScrub} - onReset={() => - paddingY.commitValue({ - value: DEFAULTS.text.background.paddingY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedPaddingY, - staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, - defaultValue: DEFAULTS.text.background.paddingY, - })} - /> - </SectionField> - </div> - <div className="flex items-start gap-2"> - <SectionField - label="X-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle x-offset keyframe" - onToggle={offsetX.toggleKeyframe} - /> - } - > - <NumberField - icon="X" - value={offsetX.displayValue} - onFocus={offsetX.onFocus} - onChange={offsetX.onChange} - onBlur={offsetX.onBlur} - onScrub={offsetX.scrubTo} - onScrubEnd={offsetX.commitScrub} - onReset={() => - offsetX.commitValue({ - value: DEFAULTS.text.background.offsetX, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetX, - staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, - defaultValue: DEFAULTS.text.background.offsetX, - })} - /> - </SectionField> - <SectionField - label="Y-offset" - className="w-1/2" - beforeLabel={ - <KeyframeToggle - isActive={offsetY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle y-offset keyframe" - onToggle={offsetY.toggleKeyframe} - /> - } - > - <NumberField - icon="Y" - value={offsetY.displayValue} - onFocus={offsetY.onFocus} - onChange={offsetY.onChange} - onBlur={offsetY.onBlur} - onScrub={offsetY.scrubTo} - onScrubEnd={offsetY.commitScrub} - onReset={() => - offsetY.commitValue({ - value: DEFAULTS.text.background.offsetY, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedOffsetY, - staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, - defaultValue: DEFAULTS.text.background.offsetY, - })} - /> - </SectionField> - </div> - <SectionField - label="Corner radius" - beforeLabel={ - <KeyframeToggle - isActive={cornerRadius.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle corner radius keyframe" - onToggle={cornerRadius.toggleKeyframe} - /> - } - > - <NumberField - icon="R" - value={cornerRadius.displayValue} - min={CORNER_RADIUS_MIN} - max={CORNER_RADIUS_MAX} - onFocus={cornerRadius.onFocus} - onChange={cornerRadius.onChange} - onBlur={cornerRadius.onBlur} - onScrub={cornerRadius.scrubTo} - onScrubEnd={cornerRadius.commitScrub} - onReset={() => - cornerRadius.commitValue({ value: CORNER_RADIUS_MIN }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedCornerRadius, - staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, - defaultValue: CORNER_RADIUS_MIN, - })} - /> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} +"use client";
+
+import { Textarea } from "@/components/ui/textarea";
+import { FontPicker } from "@/components/ui/font-picker";
+import type { TextElement } from "@/lib/timeline";
+import { NumberField } from "@/components/ui/number-field";
+import { useRef } from "react";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
+import { uppercase } from "@/utils/string";
+import { clamp } from "@/utils/math";
+import { useEditor } from "@/hooks/use-editor";
+import { DEFAULT_COLOR } from "@/constants/project-constants";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+ MAX_FONT_SIZE,
+ MIN_FONT_SIZE,
+} from "@/constants/text-constants";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { useKeyframedColorProperty } from "../hooks/use-keyframed-color-property";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { isPropertyAtDefault } from "./transform-tab";
+import { resolveColorAtTime, resolveNumberAtTime } from "@/lib/animation";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ MinusSignIcon,
+ PlusSignIcon,
+ TextFontIcon,
+} from "@hugeicons/core-free-icons";
+import { OcTextHeightIcon, OcTextWidthIcon } from "@opencut/ui/icons";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { cn } from "@/utils/ui";
+
+export function TextTab({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ return (
+ <div className="flex flex-col">
+ <ContentSection element={element} trackId={trackId} />
+ <TypographySection element={element} trackId={trackId} />
+ <SpacingSection element={element} trackId={trackId} />
+ <BackgroundSection element={element} trackId={trackId} />
+ </div>
+ );
+}
+
+function ContentSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const content = usePropertyDraft({
+ displayValue: element.content,
+ parse: (input) => input,
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { content: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:content`}>
+ <SectionHeader>
+ <SectionTitle>Content</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <Textarea
+ placeholder="Name"
+ value={content.displayValue}
+ className="min-h-20"
+ onFocus={content.onFocus}
+ onChange={content.onChange}
+ onBlur={content.onBlur}
+ />
+ </SectionContent>
+ </Section>
+ );
+}
+
+function TypographySection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTextColor = resolveColorAtTime({
+ baseColor: element.color,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ });
+
+ const textColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedTextColor,
+ buildBaseUpdates: ({ value }) => ({ color: value }),
+ });
+
+ const fontSize = usePropertyDraft({
+ displayValue: element.fontSize.toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: MIN_FONT_SIZE,
+ max: MAX_FONT_SIZE,
+ });
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { fontSize: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:typography`}>
+ <SectionHeader>
+ <SectionTitle>Typography</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <SectionField label="Font">
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value) =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { fontFamily: value },
+ },
+ ],
+ })
+ }
+ />
+ </SectionField>
+ <SectionField label="Size">
+ <NumberField
+ value={fontSize.displayValue}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ onFocus={fontSize.onFocus}
+ onChange={fontSize.onChange}
+ onBlur={fontSize.onBlur}
+ onScrub={fontSize.scrubTo}
+ onScrubEnd={fontSize.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ fontSize: DEFAULTS.text.element.fontSize,
+ },
+ },
+ ],
+ })
+ }
+ isDefault={element.fontSize === DEFAULTS.text.element.fontSize}
+ icon={<HugeiconsIcon icon={TextFontIcon} />}
+ />
+ </SectionField>
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={textColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle text color keyframe"
+ onToggle={textColor.toggleKeyframe}
+ />
+ }
+ >
+ <ColorPicker
+ value={uppercase({
+ string: resolvedTextColor.replace("#", ""),
+ })}
+ onChange={(color) => textColor.onChange({ color: `#${color}` })}
+ onChangeEnd={textColor.onChangeEnd}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function SpacingSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+
+ const letterSpacing = usePropertyDraft({
+ displayValue: Math.round(
+ element.letterSpacing ?? DEFAULTS.text.letterSpacing,
+ ).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { letterSpacing: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ const lineHeight = usePropertyDraft({
+ displayValue: (element.lineHeight ?? DEFAULTS.text.lineHeight).toFixed(1),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed)
+ ? null
+ : Math.max(0.1, Math.round(parsed * 10) / 10);
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { lineHeight: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:spacing`}>
+ <SectionHeader>
+ <SectionTitle>Spacing</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <SectionField label="Letter spacing" className="w-1/2">
+ <NumberField
+ value={letterSpacing.displayValue}
+ onFocus={letterSpacing.onFocus}
+ onChange={letterSpacing.onChange}
+ onBlur={letterSpacing.onBlur}
+ onScrub={letterSpacing.scrubTo}
+ onScrubEnd={letterSpacing.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { letterSpacing: DEFAULTS.text.letterSpacing },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.letterSpacing ?? DEFAULTS.text.letterSpacing) ===
+ DEFAULTS.text.letterSpacing
+ }
+ icon={<OcTextWidthIcon size={14} />}
+ />
+ </SectionField>
+ <SectionField label="Line height" className="w-1/2">
+ <NumberField
+ value={lineHeight.displayValue}
+ onFocus={lineHeight.onFocus}
+ onChange={lineHeight.onChange}
+ onBlur={lineHeight.onBlur}
+ onScrub={lineHeight.scrubTo}
+ onScrubEnd={lineHeight.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { lineHeight: DEFAULTS.text.lineHeight },
+ },
+ ],
+ })
+ }
+ isDefault={
+ (element.lineHeight ?? DEFAULTS.text.lineHeight) ===
+ DEFAULTS.text.lineHeight
+ }
+ icon={<OcTextHeightIcon size={14} />}
+ />
+ </SectionField>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function BackgroundSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedBgColor = resolveColorAtTime({
+ baseColor: element.background.color,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ });
+
+ const bgColor = useKeyframedColorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.color",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedColor: resolvedBgColor,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...element.background, color: value },
+ }),
+ });
+
+ const bg = element.background;
+
+ const resolvedPaddingX = resolveNumberAtTime({
+ baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ });
+ const resolvedPaddingY = resolveNumberAtTime({
+ baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ });
+ const resolvedOffsetX = resolveNumberAtTime({
+ baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ });
+ const resolvedOffsetY = resolveNumberAtTime({
+ baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ });
+ const resolvedCornerRadius = resolveNumberAtTime({
+ baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ });
+
+ const paddingX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ },
+ valueAtPlayhead: resolvedPaddingX,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingX: value },
+ }),
+ });
+
+ const paddingY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.paddingY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedPaddingY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
+ },
+ valueAtPlayhead: resolvedPaddingY,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, paddingY: value },
+ }),
+ });
+
+ const offsetX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetX).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ valueAtPlayhead: resolvedOffsetX,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetX: value },
+ }),
+ });
+
+ const offsetY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.offsetY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedOffsetY).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : Math.round(parsed);
+ },
+ valueAtPlayhead: resolvedOffsetY,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, offsetY: value },
+ }),
+ });
+
+ const cornerRadius = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "background.cornerRadius",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedCornerRadius).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({
+ value: Math.round(parsed),
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ });
+ },
+ valueAtPlayhead: resolvedCornerRadius,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ background: { ...bg, cornerRadius: value },
+ }),
+ });
+
+ const toggleBackgroundEnabled = () => {
+ const enabled = !element.background.enabled;
+ const color =
+ enabled && element.background.color === "transparent"
+ ? lastSelectedColor.current
+ : element.background.color;
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ background: {
+ ...element.background,
+ enabled,
+ color,
+ },
+ },
+ },
+ ],
+ });
+ };
+
+ return (
+ <Section
+ collapsible
+ defaultOpen={element.background.enabled}
+ sectionKey={`${element.id}:background`}
+ >
+ <SectionHeader
+ trailing={
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={(event) => {
+ event.stopPropagation();
+ toggleBackgroundEnabled();
+ }}
+ >
+ <HugeiconsIcon
+ icon={element.background.enabled ? MinusSignIcon : PlusSignIcon}
+ strokeWidth={1}
+ />
+ </Button>
+ }
+ >
+ <SectionTitle>Background</SectionTitle>
+ </SectionHeader>
+ <SectionContent
+ className={cn(
+ !element.background.enabled && "pointer-events-none opacity-50",
+ )}
+ >
+ <SectionFields>
+ <SectionField
+ label="Color"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={bgColor.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background color keyframe"
+ onToggle={bgColor.toggleKeyframe}
+ />
+ }
+ >
+ <ColorPicker
+ value={
+ !element.background.enabled ||
+ element.background.color === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : resolvedBgColor.replace("#", "")
+ }
+ onChange={(color) => {
+ const hexColor = `#${color}`;
+ if (color !== "transparent") {
+ lastSelectedColor.current = hexColor;
+ }
+ bgColor.onChange({ color: hexColor });
+ }}
+ onChangeEnd={bgColor.onChangeEnd}
+ />
+ </SectionField>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="Width"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background width keyframe"
+ onToggle={paddingX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="W"
+ value={paddingX.displayValue}
+ min={0}
+ onFocus={paddingX.onFocus}
+ onChange={paddingX.onChange}
+ onBlur={paddingX.onBlur}
+ onScrub={paddingX.scrubTo}
+ onScrubEnd={paddingX.commitScrub}
+ onReset={() =>
+ paddingX.commitValue({
+ value: DEFAULTS.text.background.paddingX,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingX,
+ staticValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
+ defaultValue: DEFAULTS.text.background.paddingX,
+ })}
+ />
+ </SectionField>
+ <SectionField
+ label="Height"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={paddingY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle background height keyframe"
+ onToggle={paddingY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="H"
+ value={paddingY.displayValue}
+ min={0}
+ onFocus={paddingY.onFocus}
+ onChange={paddingY.onChange}
+ onBlur={paddingY.onBlur}
+ onScrub={paddingY.scrubTo}
+ onScrubEnd={paddingY.commitScrub}
+ onReset={() =>
+ paddingY.commitValue({
+ value: DEFAULTS.text.background.paddingY,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: paddingY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedPaddingY,
+ staticValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
+ defaultValue: DEFAULTS.text.background.paddingY,
+ })}
+ />
+ </SectionField>
+ </div>
+ <div className="flex items-start gap-2">
+ <SectionField
+ label="X-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle x-offset keyframe"
+ onToggle={offsetX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="X"
+ value={offsetX.displayValue}
+ onFocus={offsetX.onFocus}
+ onChange={offsetX.onChange}
+ onBlur={offsetX.onBlur}
+ onScrub={offsetX.scrubTo}
+ onScrubEnd={offsetX.commitScrub}
+ onReset={() =>
+ offsetX.commitValue({
+ value: DEFAULTS.text.background.offsetX,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetX,
+ staticValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
+ defaultValue: DEFAULTS.text.background.offsetX,
+ })}
+ />
+ </SectionField>
+ <SectionField
+ label="Y-offset"
+ className="w-1/2"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={offsetY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle y-offset keyframe"
+ onToggle={offsetY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="Y"
+ value={offsetY.displayValue}
+ onFocus={offsetY.onFocus}
+ onChange={offsetY.onChange}
+ onBlur={offsetY.onBlur}
+ onScrub={offsetY.scrubTo}
+ onScrubEnd={offsetY.commitScrub}
+ onReset={() =>
+ offsetY.commitValue({
+ value: DEFAULTS.text.background.offsetY,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: offsetY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedOffsetY,
+ staticValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
+ defaultValue: DEFAULTS.text.background.offsetY,
+ })}
+ />
+ </SectionField>
+ </div>
+ <SectionField
+ label="Corner radius"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={cornerRadius.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle corner radius keyframe"
+ onToggle={cornerRadius.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon="R"
+ value={cornerRadius.displayValue}
+ min={CORNER_RADIUS_MIN}
+ max={CORNER_RADIUS_MAX}
+ onFocus={cornerRadius.onFocus}
+ onChange={cornerRadius.onChange}
+ onBlur={cornerRadius.onBlur}
+ onScrub={cornerRadius.scrubTo}
+ onScrubEnd={cornerRadius.commitScrub}
+ onReset={() =>
+ cornerRadius.commitValue({ value: CORNER_RADIUS_MIN })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: cornerRadius.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedCornerRadius,
+ staticValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
+ defaultValue: CORNER_RADIUS_MIN,
+ })}
+ />
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx index a015a9bb..288b8349 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx @@ -1,424 +1,424 @@ -import { NumberField } from "@/components/ui/number-field"; -import { useEditor } from "@/hooks/use-editor"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { VisualElement } from "@/lib/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowExpandIcon, - Link05Icon, - RotateClockwiseIcon, -} from "@hugeicons/core-free-icons"; -import { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, - resolveTransformAtTime, -} from "@/lib/animation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property"; -import { usePropertiesStore } from "../stores/properties-store"; - -export function parseNumericInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : parsed; -} - -export function isPropertyAtDefault({ - hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue, - staticValue, - defaultValue, -}: { - hasAnimatedKeyframes: boolean; - isPlayheadWithinElementRange: boolean; - resolvedValue: number; - staticValue: number; - defaultValue: number; -}): boolean { - if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { - return isNearlyEqual({ - leftValue: resolvedValue, - rightValue: defaultValue, - }); - } - - return staticValue === defaultValue; -} - -export function TransformTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const editor = useEditor(); - const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); - const setTransformScaleLocked = usePropertiesStore( - (s) => s.setTransformScaleLocked, - ); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = useKeyframedVectorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.position", - localTime, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position, - displayX: Math.round(resolvedTransform.position.x).toString(), - displayY: Math.round(resolvedTransform.position.y).toString(), - parseComponent: (input) => parseNumericInput({ input }), - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { ...element.transform, position: value }, - }), - }); - - const parseScale = (input: string) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return parsed / 100; - }; - - const scaleX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleX, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleX: value, - ...(isScaleLocked ? { scaleY: value } : {}), - }, - }), - }); - - const scaleY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleY, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleY: value, - ...(isScaleLocked ? { scaleX: value } : {}), - }, - }), - }); - - const scaleFieldPropsX = { - value: scaleX.displayValue, - onFocus: scaleX.onFocus, - onChange: scaleX.onChange, - onBlur: scaleX.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleX.scrubTo, - onScrubEnd: scaleX.commitScrub, - onReset: () => - scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleX, - staticValue: element.transform.scaleX, - defaultValue: DEFAULTS.element.transform.scaleX, - }), - }; - - const scaleFieldPropsY = { - value: scaleY.displayValue, - onFocus: scaleY.onFocus, - onChange: scaleY.onChange, - onBlur: scaleY.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleY.scrubTo, - onScrubEnd: scaleY.commitScrub, - onReset: () => - scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleY, - staticValue: element.transform.scaleY, - defaultValue: DEFAULTS.element.transform.scaleY, - }), - }; - - const rotation = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.rotate", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.rotate).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return clamp({ value: parsed, min: -360, max: 360 }); - }, - valueAtPlayhead: resolvedTransform.rotate, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - rotate: value, - }, - }), - }); - - const hasScaleKeyframe = hasGroupKeyframeAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - - const toggleScaleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - const existing = getGroupKeyframesAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - if (existing.length > 0) { - editor.timeline.removeKeyframes({ - keyframes: existing.map((ref) => ({ - trackId, - elementId: element.id, - ...ref, - })), - }); - return; - } - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleX", - time: localTime, - value: resolvedTransform.scaleX, - }, - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleY", - time: localTime, - value: resolvedTransform.scaleY, - }, - ], - }); - }; - - const scaleLockButton = ( - <Button - type="button" - variant={isScaleLocked ? "secondary" : "ghost"} - size="icon" - aria-pressed={isScaleLocked} - onClick={() => setTransformScaleLocked(!isScaleLocked)} - > - <HugeiconsIcon icon={Link05Icon} /> - </Button> - ); - - return ( - <Section collapsible sectionKey={`${element.id}:transform`}> - <SectionHeader> - <SectionTitle>Transform</SectionTitle> - </SectionHeader> - <SectionContent> - <SectionFields> - <div className="flex items-end gap-2"> - {isScaleLocked ? ( - <> - <SectionField - label="Scale" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={hasScaleKeyframe} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle scale keyframe" - onToggle={toggleScaleKeyframe} - /> - } - > - <NumberField - icon={<HugeiconsIcon icon={ArrowExpandIcon} />} - {...scaleFieldPropsX} - /> - </SectionField> - {scaleLockButton} - </> - ) : ( - <> - <SectionField - label="Width" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleX.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle width scale keyframe" - onToggle={scaleX.toggleKeyframe} - /> - } - > - <NumberField icon="W" {...scaleFieldPropsX} /> - </SectionField> - {scaleLockButton} - <SectionField - label="Height" - className="min-w-0 flex-1" - beforeLabel={ - <KeyframeToggle - isActive={scaleY.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle height scale keyframe" - onToggle={scaleY.toggleKeyframe} - /> - } - > - <NumberField icon="H" {...scaleFieldPropsY} /> - </SectionField> - </> - )} - </div> - <SectionField - label="Position" - beforeLabel={ - <KeyframeToggle - isActive={position.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle position keyframe" - onToggle={position.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon="X" - className="flex-1" - value={position.x.displayValue} - onFocus={position.x.onFocus} - onChange={position.x.onChange} - onBlur={position.x.onBlur} - onScrub={position.x.scrubTo} - onScrubEnd={position.x.commitScrub} - onReset={() => - position.commitX({ - value: DEFAULTS.element.transform.position.x, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: position.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.x, - staticValue: element.transform.position.x, - defaultValue: DEFAULTS.element.transform.position.x, - })} - /> - <NumberField - icon="Y" - className="flex-1" - value={position.y.displayValue} - onFocus={position.y.onFocus} - onChange={position.y.onChange} - onBlur={position.y.onBlur} - onScrub={position.y.scrubTo} - onScrubEnd={position.y.commitScrub} - onReset={() => - position.commitY({ - value: DEFAULTS.element.transform.position.y, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: position.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.y, - staticValue: element.transform.position.y, - defaultValue: DEFAULTS.element.transform.position.y, - })} - /> - </div> - </SectionField> - - <SectionField - label="Rotation" - beforeLabel={ - <KeyframeToggle - isActive={rotation.isKeyframedAtTime} - isDisabled={!isPlayheadWithinElementRange} - title="Toggle rotation keyframe" - onToggle={rotation.toggleKeyframe} - /> - } - > - <div className="flex items-center gap-2"> - <NumberField - icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} - className="flex-none" - value={rotation.displayValue} - onFocus={rotation.onFocus} - onChange={rotation.onChange} - onBlur={rotation.onBlur} - dragSensitivity="slow" - onScrub={rotation.scrubTo} - onScrubEnd={rotation.commitScrub} - onReset={() => - rotation.commitValue({ - value: DEFAULTS.element.transform.rotate, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.rotate, - staticValue: element.transform.rotate, - defaultValue: DEFAULTS.element.transform.rotate, - })} - /> - </div> - </SectionField> - </SectionFields> - </SectionContent> - </Section> - ); -} +import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { clamp, isNearlyEqual } from "@/utils/math";
+import type { VisualElement } from "@/lib/timeline";
+import {
+ Section,
+ SectionContent,
+ SectionField,
+ SectionFields,
+ SectionHeader,
+ SectionTitle,
+} from "@/components/section";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Link05Icon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ resolveTransformAtTime,
+} from "@/lib/animation";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { useElementPlayhead } from "../hooks/use-element-playhead";
+import { KeyframeToggle } from "../components/keyframe-toggle";
+import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
+import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
+import { usePropertiesStore } from "../stores/properties-store";
+
+export function parseNumericInput({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function isPropertyAtDefault({
+ hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue,
+ staticValue,
+ defaultValue,
+}: {
+ hasAnimatedKeyframes: boolean;
+ isPlayheadWithinElementRange: boolean;
+ resolvedValue: number;
+ staticValue: number;
+ defaultValue: number;
+}): boolean {
+ if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
+ return isNearlyEqual({
+ leftValue: resolvedValue,
+ rightValue: defaultValue,
+ });
+ }
+
+ return staticValue === defaultValue;
+}
+
+export function TransformTab({
+ element,
+ trackId,
+}: {
+ element: VisualElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
+ const setTransformScaleLocked = usePropertiesStore(
+ (s) => s.setTransformScaleLocked,
+ );
+ const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
+ startTime: element.startTime,
+ duration: element.duration,
+ });
+ const resolvedTransform = resolveTransformAtTime({
+ baseTransform: element.transform,
+ animations: element.animations,
+ localTime,
+ });
+
+ const position = useKeyframedVectorProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.position",
+ localTime,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position,
+ displayX: Math.round(resolvedTransform.position.x).toString(),
+ displayY: Math.round(resolvedTransform.position.y).toString(),
+ parseComponent: (input) => parseNumericInput({ input }),
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: { ...element.transform, position: value },
+ }),
+ });
+
+ const parseScale = (input: string) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return parsed / 100;
+ };
+
+ const scaleX = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleX",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleX,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleX: value,
+ ...(isScaleLocked ? { scaleY: value } : {}),
+ },
+ }),
+ });
+
+ const scaleY = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.scaleY",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
+ parse: parseScale,
+ valueAtPlayhead: resolvedTransform.scaleY,
+ step: 0.01,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ scaleY: value,
+ ...(isScaleLocked ? { scaleX: value } : {}),
+ },
+ }),
+ });
+
+ const scaleFieldPropsX = {
+ value: scaleX.displayValue,
+ onFocus: scaleX.onFocus,
+ onChange: scaleX.onChange,
+ onBlur: scaleX.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleX.scrubTo,
+ onScrubEnd: scaleX.commitScrub,
+ onReset: () =>
+ scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleX,
+ staticValue: element.transform.scaleX,
+ defaultValue: DEFAULTS.element.transform.scaleX,
+ }),
+ };
+
+ const scaleFieldPropsY = {
+ value: scaleY.displayValue,
+ onFocus: scaleY.onFocus,
+ onChange: scaleY.onChange,
+ onBlur: scaleY.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scaleY.scrubTo,
+ onScrubEnd: scaleY.commitScrub,
+ onReset: () =>
+ scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
+ isDefault: isPropertyAtDefault({
+ hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.scaleY,
+ staticValue: element.transform.scaleY,
+ defaultValue: DEFAULTS.element.transform.scaleY,
+ }),
+ };
+
+ const rotation = useKeyframedNumberProperty({
+ trackId,
+ elementId: element.id,
+ animations: element.animations,
+ propertyPath: "transform.rotate",
+ localTime,
+ isPlayheadWithinElementRange,
+ displayValue: Math.round(resolvedTransform.rotate).toString(),
+ parse: (input) => {
+ const parsed = parseNumericInput({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ valueAtPlayhead: resolvedTransform.rotate,
+ step: 1,
+ buildBaseUpdates: ({ value }) => ({
+ transform: {
+ ...element.transform,
+ rotate: value,
+ },
+ }),
+ });
+
+ const hasScaleKeyframe = hasGroupKeyframeAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+
+ const toggleScaleKeyframe = () => {
+ if (!isPlayheadWithinElementRange) return;
+ const existing = getGroupKeyframesAtTime({
+ animations: element.animations,
+ group: "transform.scale",
+ time: localTime,
+ });
+ if (existing.length > 0) {
+ editor.timeline.removeKeyframes({
+ keyframes: existing.map((ref) => ({
+ trackId,
+ elementId: element.id,
+ ...ref,
+ })),
+ });
+ return;
+ }
+ editor.timeline.upsertKeyframes({
+ keyframes: [
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleX",
+ time: localTime,
+ value: resolvedTransform.scaleX,
+ },
+ {
+ trackId,
+ elementId: element.id,
+ propertyPath: "transform.scaleY",
+ time: localTime,
+ value: resolvedTransform.scaleY,
+ },
+ ],
+ });
+ };
+
+ const scaleLockButton = (
+ <Button
+ type="button"
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setTransformScaleLocked(!isScaleLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ );
+
+ return (
+ <Section collapsible sectionKey={`${element.id}:transform`}>
+ <SectionHeader>
+ <SectionTitle>Transform</SectionTitle>
+ </SectionHeader>
+ <SectionContent>
+ <SectionFields>
+ <div className="flex items-end gap-2">
+ {isScaleLocked ? (
+ <>
+ <SectionField
+ label="Scale"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={hasScaleKeyframe}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle scale keyframe"
+ onToggle={toggleScaleKeyframe}
+ />
+ }
+ >
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldPropsX}
+ />
+ </SectionField>
+ {scaleLockButton}
+ </>
+ ) : (
+ <>
+ <SectionField
+ label="Width"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleX.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle width scale keyframe"
+ onToggle={scaleX.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="W" {...scaleFieldPropsX} />
+ </SectionField>
+ {scaleLockButton}
+ <SectionField
+ label="Height"
+ className="min-w-0 flex-1"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={scaleY.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle height scale keyframe"
+ onToggle={scaleY.toggleKeyframe}
+ />
+ }
+ >
+ <NumberField icon="H" {...scaleFieldPropsY} />
+ </SectionField>
+ </>
+ )}
+ </div>
+ <SectionField
+ label="Position"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={position.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle position keyframe"
+ onToggle={position.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon="X"
+ className="flex-1"
+ value={position.x.displayValue}
+ onFocus={position.x.onFocus}
+ onChange={position.x.onChange}
+ onBlur={position.x.onBlur}
+ onScrub={position.x.scrubTo}
+ onScrubEnd={position.x.commitScrub}
+ onReset={() =>
+ position.commitX({
+ value: DEFAULTS.element.transform.position.x,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.x,
+ staticValue: element.transform.position.x,
+ defaultValue: DEFAULTS.element.transform.position.x,
+ })}
+ />
+ <NumberField
+ icon="Y"
+ className="flex-1"
+ value={position.y.displayValue}
+ onFocus={position.y.onFocus}
+ onChange={position.y.onChange}
+ onBlur={position.y.onBlur}
+ onScrub={position.y.scrubTo}
+ onScrubEnd={position.y.commitScrub}
+ onReset={() =>
+ position.commitY({
+ value: DEFAULTS.element.transform.position.y,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: position.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.position.y,
+ staticValue: element.transform.position.y,
+ defaultValue: DEFAULTS.element.transform.position.y,
+ })}
+ />
+ </div>
+ </SectionField>
+
+ <SectionField
+ label="Rotation"
+ beforeLabel={
+ <KeyframeToggle
+ isActive={rotation.isKeyframedAtTime}
+ isDisabled={!isPlayheadWithinElementRange}
+ title="Toggle rotation keyframe"
+ onToggle={rotation.toggleKeyframe}
+ />
+ }
+ >
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ className="flex-none"
+ value={rotation.displayValue}
+ onFocus={rotation.onFocus}
+ onChange={rotation.onChange}
+ onBlur={rotation.onBlur}
+ dragSensitivity="slow"
+ onScrub={rotation.scrubTo}
+ onScrubEnd={rotation.commitScrub}
+ onReset={() =>
+ rotation.commitValue({
+ value: DEFAULTS.element.transform.rotate,
+ })
+ }
+ isDefault={isPropertyAtDefault({
+ hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
+ isPlayheadWithinElementRange,
+ resolvedValue: resolvedTransform.rotate,
+ staticValue: element.transform.rotate,
+ defaultValue: DEFAULTS.element.transform.rotate,
+ })}
+ />
+ </div>
+ </SectionField>
+ </SectionFields>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/drag-line.tsx b/apps/web/src/components/editor/panels/timeline/drag-line.tsx index 7597a620..a5a145b4 100644 --- a/apps/web/src/components/editor/panels/timeline/drag-line.tsx +++ b/apps/web/src/components/editor/panels/timeline/drag-line.tsx @@ -1,29 +1,29 @@ -import { getDropLineY } from "@/lib/timeline/drop-utils"; -import type { TimelineTrack, DropTarget } from "@/lib/timeline"; -import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; - -interface DragLineProps { - dropTarget: DropTarget | null; - tracks: TimelineTrack[]; - isVisible: boolean; - headerHeight?: number; -} - -export function DragLine({ - dropTarget, - tracks, - isVisible, - headerHeight = 0, -}: DragLineProps) { - if (!isVisible || !dropTarget) return null; - - const y = getDropLineY({ dropTarget, tracks }); - const lineTop = y + headerHeight; - - return ( - <div - className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5" - style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }} - /> - ); -} +import { getDropLineY } from "@/lib/timeline/drop-utils";
+import type { TimelineTrack, DropTarget } from "@/lib/timeline";
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
+
+interface DragLineProps {
+ dropTarget: DropTarget | null;
+ tracks: TimelineTrack[];
+ isVisible: boolean;
+ headerHeight?: number;
+}
+
+export function DragLine({
+ dropTarget,
+ tracks,
+ isVisible,
+ headerHeight = 0,
+}: DragLineProps) {
+ if (!isVisible || !dropTarget) return null;
+
+ const y = getDropLineY({ dropTarget, tracks });
+ const lineTop = y + headerHeight;
+
+ return (
+ <div
+ className="bg-primary pointer-events-none absolute right-0 left-0 h-0.5"
+ style={{ top: `${lineTop}px`, zIndex: TIMELINE_LAYERS.dragLine }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx index acb57203..766d0843 100644 --- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx +++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx @@ -1,50 +1,50 @@ -"use client"; - -import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { - getCenteredLineLeft, - TIMELINE_INDICATOR_LINE_WIDTH_PX, -} from "@/lib/timeline"; -import { TIMELINE_LAYERS } from "@/constants/timeline-constants"; -interface SnapIndicatorProps { - snapPoint: SnapPoint | null; - zoomLevel: number; - isVisible: boolean; - timelineRef: React.RefObject<HTMLDivElement | null>; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; -} - -export function SnapIndicator({ - snapPoint, - zoomLevel, - isVisible, - timelineRef, - tracksScrollRef, -}: SnapIndicatorProps) { - const { leftPosition, topPosition, height } = useSnapIndicatorPosition({ - snapPoint, - zoomLevel, - timelineRef, - tracksScrollRef, - }); - - if (!isVisible || !snapPoint) { - return null; - } - - return ( - <div - className="pointer-events-none absolute" - style={{ - left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`, - top: topPosition, - height: `${height}px`, - width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`, - zIndex: TIMELINE_LAYERS.snapIndicator, - }} - > - <div className={"bg-primary/40 h-full w-0.5 opacity-80"} /> - </div> - ); -} +"use client";
+
+import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import {
+ getCenteredLineLeft,
+ TIMELINE_INDICATOR_LINE_WIDTH_PX,
+} from "@/lib/timeline";
+import { TIMELINE_LAYERS } from "@/constants/timeline-constants";
+interface SnapIndicatorProps {
+ snapPoint: SnapPoint | null;
+ zoomLevel: number;
+ isVisible: boolean;
+ timelineRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+}
+
+export function SnapIndicator({
+ snapPoint,
+ zoomLevel,
+ isVisible,
+ timelineRef,
+ tracksScrollRef,
+}: SnapIndicatorProps) {
+ const { leftPosition, topPosition, height } = useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ timelineRef,
+ tracksScrollRef,
+ });
+
+ if (!isVisible || !snapPoint) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ left: `${getCenteredLineLeft({ centerPixel: leftPosition })}px`,
+ top: topPosition,
+ height: `${height}px`,
+ width: `${TIMELINE_INDICATOR_LINE_WIDTH_PX}px`,
+ zIndex: TIMELINE_LAYERS.snapIndicator,
+ }}
+ >
+ <div className={"bg-primary/40 h-full w-0.5 opacity-80"} />
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 13a31375..8960e3a6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -1,934 +1,934 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { useAssetsPanelStore } from "@/stores/assets-panel-store"; -import { AudioWaveform } from "./audio-waveform"; -import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize"; -import { - useKeyframeDrag, - type KeyframeDragState, -} from "@/hooks/timeline/element/use-keyframe-drag"; -import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { getElementKeyframes } from "@/lib/animation"; -import { - getElementClasses, - getTrackHeight, - canElementHaveAudio, - canElementBeHidden, - hasElementEffects, - hasMediaId, - timelineTimeToPixels, - timelineTimeToSnappedPixels, -} from "@/lib/timeline"; -import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@/components/ui/context-menu"; -import type { - TimelineElement as TimelineElementType, - TimelineTrack, - ElementDragState, - VideoElement, - ImageElement, - AudioElement, -} from "@/lib/timeline"; -import type { MediaAsset } from "@/lib/media/types"; -import { mediaSupportsAudio } from "@/lib/media/media-utils"; -import { - getActionDefinition, - type TAction, - type TActionWithOptionalArgs, - invokeAction, -} from "@/lib/actions"; -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { resolveStickerId } from "@/lib/stickers"; -import { buildGraphicPreviewUrl } from "@/lib/graphics"; -import Image from "next/image"; -import { - ScissorIcon, - Delete02Icon, - Copy01Icon, - ViewIcon, - ViewOffSlashIcon, - VolumeHighIcon, - VolumeOffIcon, - VolumeMute02Icon, - Search01Icon, - Exchange01Icon, - KeyframeIcon, - MagicWand05Icon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { uppercase } from "@/utils/string"; -import type { ComponentProps, ReactNode } from "react"; -import type { - SelectedKeyframeRef, - ElementKeyframe, -} from "@/lib/animation/types"; -import { cn } from "@/utils/ui"; -import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store"; - -const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40; -const ELEMENT_RING_WIDTH_PX = 1.5; -const THUMBNAIL_ASPECT_RATIO = 16 / 9; - -interface KeyframeIndicator { - time: number; - offsetPx: number; - keyframes: SelectedKeyframeRef[]; -} - -export function buildKeyframeIndicator({ - keyframe, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, -}: { - keyframe: ElementKeyframe; - trackId: string; - elementId: string; - displayedStartTime: number; - zoomLevel: number; - elementLeft: number; -}): { - time: number; - offsetPx: number; - keyframeRef: SelectedKeyframeRef; -} { - const keyframeRef = { - trackId, - elementId, - propertyPath: keyframe.propertyPath, - keyframeId: keyframe.id, - }; - const keyframeLeft = timelineTimeToSnappedPixels({ - time: displayedStartTime + keyframe.time, - zoomLevel, - }); - return { - time: keyframe.time, - offsetPx: keyframeLeft - elementLeft, - keyframeRef, - }; -} - -export function getKeyframeIndicators({ - keyframes, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, - elementWidth, -}: { - keyframes: ElementKeyframe[]; - trackId: string; - elementId: string; - displayedStartTime: number; - zoomLevel: number; - elementLeft: number; - elementWidth: number; -}): KeyframeIndicator[] { - if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) { - return []; - } - - const keyframesByTime = new Map<number, KeyframeIndicator>(); - for (const keyframe of keyframes) { - const indicator = buildKeyframeIndicator({ - keyframe, - trackId, - elementId, - displayedStartTime, - zoomLevel, - elementLeft, - }); - const existingIndicator = keyframesByTime.get(indicator.time); - if (!existingIndicator) { - keyframesByTime.set(indicator.time, { - time: indicator.time, - offsetPx: indicator.offsetPx, - keyframes: [indicator.keyframeRef], - }); - continue; - } - - existingIndicator.keyframes.push(indicator.keyframeRef); - } - - return [...keyframesByTime.values()].sort((a, b) => a.time - b.time); -} - -export function getDisplayShortcut({ action }: { action: TAction }) { - const { defaultShortcuts } = getActionDefinition({ action }); - if (!defaultShortcuts?.length) { - return ""; - } - - return uppercase({ - string: defaultShortcuts[0].replace("+", " "), - }); -} - -interface TimelineElementProps { - element: TimelineElementType; - track: TimelineTrack; - zoomLevel: number; - isSelected: boolean; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; - onResizeStateChange?: (params: { isResizing: boolean }) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - dragState: ElementDragState; - isDropTarget?: boolean; -} - -export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onSnapPointChange, - onResizeStateChange, - onElementMouseDown, - onElementClick, - dragState, - isDropTarget = false, -}: TimelineElementProps) { - const mediaAssets = useEditor((e) => e.media.getAssets()); - const { selectedElements } = useElementSelection(); - const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia); - - let mediaAsset: MediaAsset | null = null; - - if (hasMediaId(element)) { - mediaAsset = - mediaAssets.find((asset) => asset.id === element.mediaId) ?? null; - } - - const hasAudio = mediaSupportsAudio({ media: mediaAsset }); - - const { handleResizeStart, isResizing, currentStartTime, currentDuration } = - useTimelineElementResize({ - element, - track, - zoomLevel, - onSnapPointChange, - onResizeStateChange, - }); - - const isCurrentElementSelected = selectedElements.some( - (selected) => - selected.elementId === element.id && selected.trackId === track.id, - ); - - const isBeingDragged = dragState.elementId === element.id; - const dragOffsetY = - isBeingDragged && dragState.isDragging - ? dragState.currentMouseY - dragState.startMouseY - : 0; - const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime - : element.startTime; - const displayedStartTime = isResizing ? currentStartTime : elementStartTime; - const displayedDuration = isResizing ? currentDuration : element.duration; - const elementWidth = timelineTimeToPixels({ - time: displayedDuration, - zoomLevel, - }); - const elementLeft = timelineTimeToSnappedPixels({ - time: displayedStartTime, - zoomLevel, - }); - const keyframeIndicators = isSelected - ? getKeyframeIndicators({ - keyframes: getElementKeyframes({ animations: element.animations }), - trackId: track.id, - elementId: element.id, - displayedStartTime, - zoomLevel, - elementLeft, - elementWidth, - }) - : []; - - const { - keyframeDragState, - handleKeyframeMouseDown, - handleKeyframeClick, - getVisualOffsetPx, - } = useKeyframeDrag({ zoomLevel, element, displayedStartTime }); - const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => { - event.stopPropagation(); - if (hasMediaId(element)) { - requestRevealMedia(element.mediaId); - } - }; - - const isMuted = canElementHaveAudio(element) && element.muted === true; - - return ( - <ContextMenu> - <ContextMenuTrigger asChild> - <div - className="absolute top-0 h-full select-none" - style={{ - left: `${elementLeft}px`, - width: `${elementWidth}px`, - transform: - isBeingDragged && dragState.isDragging - ? `translate3d(0, ${dragOffsetY}px, 0)` - : undefined, - }} - > - <ElementInner - element={element} - track={track} - isSelected={isSelected} - onElementClick={onElementClick} - onElementMouseDown={onElementMouseDown} - handleResizeStart={handleResizeStart} - isDropTarget={isDropTarget} - /> - {isSelected && ( - <div className="pointer-events-none absolute inset-0 overflow-hidden"> - <KeyframeIndicators - indicators={keyframeIndicators} - dragState={keyframeDragState} - displayedStartTime={displayedStartTime} - elementLeft={elementLeft} - onKeyframeMouseDown={handleKeyframeMouseDown} - onKeyframeClick={handleKeyframeClick} - getVisualOffsetPx={getVisualOffsetPx} - /> - </div> - )} - </div> - </ContextMenuTrigger> - <ContextMenuContent className="w-64"> - <ActionMenuItem - action="split" - icon={<HugeiconsIcon icon={ScissorIcon} />} - > - Split - </ActionMenuItem> - <CopyMenuItem /> - {canElementHaveAudio(element) && hasAudio && ( - <MuteMenuItem - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - isMuted={isMuted} - /> - )} - {canElementBeHidden(element) && ( - <VisibilityMenuItem - element={element} - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - /> - )} - {selectedElements.length === 1 && ( - <ActionMenuItem - action="duplicate-selected" - icon={<HugeiconsIcon icon={Copy01Icon} />} - > - Duplicate - </ActionMenuItem> - )} - {selectedElements.length === 1 && hasMediaId(element) && ( - <> - <ContextMenuItem - icon={<HugeiconsIcon icon={Search01Icon} />} - onClick={(event: React.MouseEvent) => - handleRevealInMedia({ event }) - } - > - Reveal media - </ContextMenuItem> - <ContextMenuItem - icon={<HugeiconsIcon icon={Exchange01Icon} />} - disabled - > - Replace media - </ContextMenuItem> - </> - )} - <ContextMenuSeparator /> - <DeleteMenuItem - isMultipleSelected={selectedElements.length > 1} - isCurrentElementSelected={isCurrentElementSelected} - elementType={element.type} - selectedCount={selectedElements.length} - /> - </ContextMenuContent> - </ContextMenu> - ); -} - -function ElementInner({ - element, - track, - isSelected, - onElementClick, - onElementMouseDown, - handleResizeStart, - isDropTarget = false, -}: { - element: TimelineElementType; - track: TimelineTrack; - isSelected: boolean; - onElementClick: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - onElementMouseDown: ( - event: React.MouseEvent, - element: TimelineElementType, - ) => void; - handleResizeStart: (params: { - event: React.MouseEvent; - elementId: string; - side: "left" | "right"; - }) => void; - isDropTarget?: boolean; -}) { - const isReducedOpacity = - (canElementBeHidden(element) && element.hidden) || isDropTarget; - return ( - <div - className="absolute top-0 bottom-0" - style={{ - left: `${ELEMENT_RING_WIDTH_PX}px`, - right: `${ELEMENT_RING_WIDTH_PX}px`, - }} - > - <div - className="absolute inset-0 rounded-sm" - style={ - isSelected - ? { - boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`, - } - : undefined - } - > - <div - className={cn( - "absolute inset-0 overflow-hidden rounded-sm", - getElementClasses({ type: track.type }), - isReducedOpacity && "opacity-50", - )} - > - <button - type="button" - tabIndex={-1} - className="absolute inset-0 size-full flex flex-col" - onClick={(event) => onElementClick(event, element)} - onMouseDown={(event) => onElementMouseDown(event, element)} - > - <div className="flex flex-1 min-h-0 items-center overflow-hidden"> - <ElementContent element={element} track={track} /> - </div> - </button> - </div> - </div> - - {isSelected && ( - <> - <ResizeHandle - side="left" - elementId={element.id} - handleResizeStart={handleResizeStart} - /> - <ResizeHandle - side="right" - elementId={element.id} - handleResizeStart={handleResizeStart} - /> - </> - )} - </div> - ); -} - -function ResizeHandle({ - side, - elementId, - handleResizeStart, -}: { - side: "left" | "right"; - elementId: string; - handleResizeStart: (params: { - event: React.MouseEvent; - elementId: string; - side: "left" | "right"; - }) => void; -}) { - const isLeft = side === "left"; - return ( - <button - type="button" - className={cn( - "absolute top-0 bottom-0 w-2", - isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize", - )} - onMouseDown={(event) => handleResizeStart({ event, elementId, side })} - onClick={(event) => event.stopPropagation()} - aria-label={`${isLeft ? "Left" : "Right"} resize handle`} - ></button> - ); -} - -function KeyframeIndicators({ - indicators, - dragState, - displayedStartTime, - elementLeft, - onKeyframeMouseDown, - onKeyframeClick, - getVisualOffsetPx, -}: { - indicators: KeyframeIndicator[]; - dragState: KeyframeDragState; - displayedStartTime: number; - elementLeft: number; - onKeyframeMouseDown: (params: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - }) => void; - onKeyframeClick: (params: { - event: React.MouseEvent; - keyframes: SelectedKeyframeRef[]; - orderedKeyframes: SelectedKeyframeRef[]; - indicatorTime: number; - }) => void; - getVisualOffsetPx: (params: { - indicatorTime: number; - indicatorOffsetPx: number; - isBeingDragged: boolean; - displayedStartTime: number; - elementLeft: number; - }) => number; -}) { - const { isKeyframeSelected } = useKeyframeSelection(); - const orderedKeyframes = indicators.flatMap( - (indicator) => indicator.keyframes, - ); - - return indicators.map((indicator) => { - const isIndicatorSelected = indicator.keyframes.some((keyframe) => - isKeyframeSelected({ keyframe }), - ); - const isBeingDragged = indicator.keyframes.some((keyframe) => - dragState.draggingKeyframeIds.has(keyframe.keyframeId), - ); - const visualOffsetPx = getVisualOffsetPx({ - indicatorTime: indicator.time, - indicatorOffsetPx: indicator.offsetPx, - isBeingDragged, - displayedStartTime, - elementLeft, - }); - - return ( - <button - key={indicator.time} - type="button" - className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab" - style={{ left: visualOffsetPx }} - onMouseDown={(event) => - onKeyframeMouseDown({ event, keyframes: indicator.keyframes }) - } - onClick={(event) => - onKeyframeClick({ - event, - keyframes: indicator.keyframes, - orderedKeyframes, - indicatorTime: indicator.time, - }) - } - aria-label="Select keyframe" - > - <HugeiconsIcon - icon={KeyframeIcon} - className={cn( - "size-3.5 mt-1.5 text-black", - isIndicatorSelected ? "fill-primary" : "fill-white", - )} - strokeWidth={1.5} - /> - </button> - ); - }); -} - -interface ElementContentProps { - element: TimelineElementType; - track: TimelineTrack; -} - -function TextElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "text" }>; -}) { - return ( - <div className="flex size-full items-center justify-start pl-2"> - <span className="truncate text-xs text-white">{element.content}</span> - </div> - ); -} - -function EffectElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "effect" }>; -}) { - return ( - <div className="flex size-full items-center justify-start gap-1 pl-2"> - <HugeiconsIcon - icon={MagicWand05Icon} - className="size-4 shrink-0 text-white" - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); -} - -function StickerElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "sticker" }>; -}) { - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={resolveStickerId({ - stickerId: element.stickerId, - options: { width: 20, height: 20 }, - })} - alt={element.name} - className="size-4 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); -} - -function GraphicElementContent({ - element, -}: { - element: Extract<TimelineElementType, { type: "graphic" }>; -}) { - return ( - <div className="flex size-full items-center gap-2 pl-2"> - <Image - src={buildGraphicPreviewUrl({ - definitionId: element.definitionId, - params: element.params, - size: 20, - })} - alt={element.name} - className="size-4 shrink-0" - width={20} - height={20} - unoptimized - /> - <span className="truncate text-xs text-white">{element.name}</span> - </div> - ); -} - -function AudioElementContent({ element }: { element: AudioElement }) { - const mediaAssets = useEditor((e) => e.media.getAssets()); - const mediaAsset = - element.sourceType === "upload" - ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null) - : null; - - const audioBuffer = - element.sourceType === "library" ? element.buffer : undefined; - const audioUrl = - element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url; - const mediaLabel = mediaAsset?.name ?? element.name; - - if (audioBuffer || audioUrl) { - return ( - <div className="relative size-full"> - <AudioWaveform - audioBuffer={audioBuffer} - audioUrl={audioUrl} - color={ELEMENT_TYPE_CONFIG.audio.waveformColor} - /> - <MediaElementHeader name={mediaLabel} hasFade={false} /> - </div> - ); - } - - return ( - <span className="text-foreground/80 truncate text-xs">{element.name}</span> - ); -} - -function EffectsButton({ - element, - track, -}: { - element: VideoElement | ImageElement; - track: TimelineTrack; -}) { - const editor = useEditor(); - const setActiveTab = usePropertiesStore((s) => s.setActiveTab); - - const handleClick = (event: React.MouseEvent) => { - event.stopPropagation(); - editor.selection.setSelectedElements({ - elements: [{ trackId: track.id, elementId: element.id }], - }); - setActiveTab(element.type, "effects"); - }; - - return ( - <button - type="button" - className="flex shrink-0 justify-center text-white cursor-pointer" - onMouseDown={(event) => event.stopPropagation()} - onClick={handleClick} - > - <HugeiconsIcon icon={MagicWand05Icon} size={12} /> - </button> - ); -} - -function TiledMediaContent({ - element, - track, -}: { - element: VideoElement | ImageElement; - track: TimelineTrack; -}) { - const mediaAssets = useEditor((e) => e.media.getAssets()); - - const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId); - const imageUrl = - element.type === "video" - ? mediaAsset?.thumbnailUrl - : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url); - - if (!imageUrl) { - return ( - <span className="text-foreground/80 truncate text-xs"> - {element.name} - </span> - ); - } - - const trackHeight = getTrackHeight({ type: track.type }); - const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO; - - return ( - <> - <div - className="absolute inset-0" - style={{ - backgroundColor: "var(--muted)", - backgroundImage: `url(${imageUrl})`, - backgroundRepeat: "repeat-x", - backgroundSize: `${tileWidth}px ${trackHeight}px`, - backgroundPosition: "left center", - pointerEvents: "none", - }} - /> - <MediaElementHeader - name={mediaAsset?.name} - leading={ - hasElementEffects({ element }) ? ( - <EffectsButton element={element} track={track} /> - ) : null - } - hasFade={true} - /> - </> - ); -} - -function MediaElementHeader({ - name, - leading, - hasFade, -}: { - name?: string | null; - leading?: ReactNode; - hasFade?: boolean; -}) { - if (!name && !leading) { - return null; - } - - return ( - <div - className={cn( - "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1", - hasFade && "from-black/30 to-transparent", - )} - > - {leading && <div className="pl-1">{leading}</div>} - {name && ( - <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75"> - {name} - </span> - )} - </div> - ); -} - -function ElementContent({ element, track }: ElementContentProps) { - switch (element.type) { - case "text": - return <TextElementContent element={element} />; - case "effect": - return <EffectElementContent element={element} />; - case "sticker": - return <StickerElementContent element={element} />; - case "graphic": - return <GraphicElementContent element={element} />; - case "audio": - return <AudioElementContent element={element} />; - case "video": - case "image": - return <TiledMediaContent element={element} track={track} />; - } -} - -function CopyMenuItem() { - return ( - <ActionMenuItem - action="copy-selected" - icon={<HugeiconsIcon icon={Copy01Icon} />} - > - Copy - </ActionMenuItem> - ); -} - -function MuteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - isMuted, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - isMuted: boolean; -}) { - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return <HugeiconsIcon icon={VolumeMute02Icon} />; - } - return isMuted ? ( - <HugeiconsIcon icon={VolumeOffIcon} /> - ) : ( - <HugeiconsIcon icon={VolumeHighIcon} /> - ); - }; - - return ( - <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}> - {isMuted ? "Unmute" : "Mute"} - </ActionMenuItem> - ); -} - -function VisibilityMenuItem({ - element, - isMultipleSelected, - isCurrentElementSelected, -}: { - element: TimelineElementType; - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; -}) { - const isHidden = canElementBeHidden(element) && element.hidden; - - const getIcon = () => { - if (isMultipleSelected && isCurrentElementSelected) { - return <HugeiconsIcon icon={ViewOffSlashIcon} />; - } - return isHidden ? ( - <HugeiconsIcon icon={ViewIcon} /> - ) : ( - <HugeiconsIcon icon={ViewOffSlashIcon} /> - ); - }; - - return ( - <ActionMenuItem - action="toggle-elements-visibility-selected" - icon={getIcon()} - > - {isHidden ? "Show" : "Hide"} - </ActionMenuItem> - ); -} - -function DeleteMenuItem({ - isMultipleSelected, - isCurrentElementSelected, - elementType, - selectedCount, -}: { - isMultipleSelected: boolean; - isCurrentElementSelected: boolean; - elementType: TimelineElementType["type"]; - selectedCount: number; -}) { - return ( - <ActionMenuItem - action="delete-selected" - variant="destructive" - icon={<HugeiconsIcon icon={Delete02Icon} />} - > - {isMultipleSelected && isCurrentElementSelected - ? `Delete ${selectedCount} elements` - : `Delete ${elementType === "text" ? "text" : "clip"}`} - </ActionMenuItem> - ); -} - -function ActionMenuItem({ - action, - children, - ...props -}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & { - action: TActionWithOptionalArgs; - children: ReactNode; -}) { - return ( - <ContextMenuItem - onClick={(event: React.MouseEvent) => { - event.stopPropagation(); - invokeAction(action); - }} - textRight={getDisplayShortcut({ action })} - {...props} - > - {children} - </ContextMenuItem> - ); -} +"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import { useAssetsPanelStore } from "@/stores/assets-panel-store";
+import { AudioWaveform } from "./audio-waveform";
+import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
+import {
+ useKeyframeDrag,
+ type KeyframeDragState,
+} from "@/hooks/timeline/element/use-keyframe-drag";
+import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import { getElementKeyframes } from "@/lib/animation";
+import {
+ getElementClasses,
+ getTrackHeight,
+ canElementHaveAudio,
+ canElementBeHidden,
+ hasElementEffects,
+ hasMediaId,
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
+import { ELEMENT_TYPE_CONFIG } from "@/constants/timeline-constants";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
+} from "@/components/ui/context-menu";
+import type {
+ TimelineElement as TimelineElementType,
+ TimelineTrack,
+ ElementDragState,
+ VideoElement,
+ ImageElement,
+ AudioElement,
+} from "@/lib/timeline";
+import type { MediaAsset } from "@/lib/media/types";
+import { mediaSupportsAudio } from "@/lib/media/media-utils";
+import {
+ getActionDefinition,
+ type TAction,
+ type TActionWithOptionalArgs,
+ invokeAction,
+} from "@/lib/actions";
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { resolveStickerId } from "@/lib/stickers";
+import { buildGraphicPreviewUrl } from "@/lib/graphics";
+import Image from "next/image";
+import {
+ ScissorIcon,
+ Delete02Icon,
+ Copy01Icon,
+ ViewIcon,
+ ViewOffSlashIcon,
+ VolumeHighIcon,
+ VolumeOffIcon,
+ VolumeMute02Icon,
+ Search01Icon,
+ Exchange01Icon,
+ KeyframeIcon,
+ MagicWand05Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { uppercase } from "@/utils/string";
+import type { ComponentProps, ReactNode } from "react";
+import type {
+ SelectedKeyframeRef,
+ ElementKeyframe,
+} from "@/lib/animation/types";
+import { cn } from "@/utils/ui";
+import { usePropertiesStore } from "@/components/editor/panels/properties/stores/properties-store";
+
+const KEYFRAME_INDICATOR_MIN_WIDTH_PX = 40;
+const ELEMENT_RING_WIDTH_PX = 1.5;
+const THUMBNAIL_ASPECT_RATIO = 16 / 9;
+
+interface KeyframeIndicator {
+ time: number;
+ offsetPx: number;
+ keyframes: SelectedKeyframeRef[];
+}
+
+export function buildKeyframeIndicator({
+ keyframe,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+}: {
+ keyframe: ElementKeyframe;
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+}): {
+ time: number;
+ offsetPx: number;
+ keyframeRef: SelectedKeyframeRef;
+} {
+ const keyframeRef = {
+ trackId,
+ elementId,
+ propertyPath: keyframe.propertyPath,
+ keyframeId: keyframe.id,
+ };
+ const keyframeLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime + keyframe.time,
+ zoomLevel,
+ });
+ return {
+ time: keyframe.time,
+ offsetPx: keyframeLeft - elementLeft,
+ keyframeRef,
+ };
+}
+
+export function getKeyframeIndicators({
+ keyframes,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+}: {
+ keyframes: ElementKeyframe[];
+ trackId: string;
+ elementId: string;
+ displayedStartTime: number;
+ zoomLevel: number;
+ elementLeft: number;
+ elementWidth: number;
+}): KeyframeIndicator[] {
+ if (elementWidth < KEYFRAME_INDICATOR_MIN_WIDTH_PX) {
+ return [];
+ }
+
+ const keyframesByTime = new Map<number, KeyframeIndicator>();
+ for (const keyframe of keyframes) {
+ const indicator = buildKeyframeIndicator({
+ keyframe,
+ trackId,
+ elementId,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ });
+ const existingIndicator = keyframesByTime.get(indicator.time);
+ if (!existingIndicator) {
+ keyframesByTime.set(indicator.time, {
+ time: indicator.time,
+ offsetPx: indicator.offsetPx,
+ keyframes: [indicator.keyframeRef],
+ });
+ continue;
+ }
+
+ existingIndicator.keyframes.push(indicator.keyframeRef);
+ }
+
+ return [...keyframesByTime.values()].sort((a, b) => a.time - b.time);
+}
+
+export function getDisplayShortcut({ action }: { action: TAction }) {
+ const { defaultShortcuts } = getActionDefinition({ action });
+ if (!defaultShortcuts?.length) {
+ return "";
+ }
+
+ return uppercase({
+ string: defaultShortcuts[0].replace("+", " "),
+ });
+}
+
+interface TimelineElementProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ zoomLevel: number;
+ isSelected: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementClick: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ dragState: ElementDragState;
+ isDropTarget?: boolean;
+}
+
+export function TimelineElement({
+ element,
+ track,
+ zoomLevel,
+ isSelected,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ dragState,
+ isDropTarget = false,
+}: TimelineElementProps) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const { selectedElements } = useElementSelection();
+ const requestRevealMedia = useAssetsPanelStore((s) => s.requestRevealMedia);
+
+ let mediaAsset: MediaAsset | null = null;
+
+ if (hasMediaId(element)) {
+ mediaAsset =
+ mediaAssets.find((asset) => asset.id === element.mediaId) ?? null;
+ }
+
+ const hasAudio = mediaSupportsAudio({ media: mediaAsset });
+
+ const { handleResizeStart, isResizing, currentStartTime, currentDuration } =
+ useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onSnapPointChange,
+ onResizeStateChange,
+ });
+
+ const isCurrentElementSelected = selectedElements.some(
+ (selected) =>
+ selected.elementId === element.id && selected.trackId === track.id,
+ );
+
+ const isBeingDragged = dragState.elementId === element.id;
+ const dragOffsetY =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentMouseY - dragState.startMouseY
+ : 0;
+ const elementStartTime =
+ isBeingDragged && dragState.isDragging
+ ? dragState.currentTime
+ : element.startTime;
+ const displayedStartTime = isResizing ? currentStartTime : elementStartTime;
+ const displayedDuration = isResizing ? currentDuration : element.duration;
+ const elementWidth = timelineTimeToPixels({
+ time: displayedDuration,
+ zoomLevel,
+ });
+ const elementLeft = timelineTimeToSnappedPixels({
+ time: displayedStartTime,
+ zoomLevel,
+ });
+ const keyframeIndicators = isSelected
+ ? getKeyframeIndicators({
+ keyframes: getElementKeyframes({ animations: element.animations }),
+ trackId: track.id,
+ elementId: element.id,
+ displayedStartTime,
+ zoomLevel,
+ elementLeft,
+ elementWidth,
+ })
+ : [];
+
+ const {
+ keyframeDragState,
+ handleKeyframeMouseDown,
+ handleKeyframeClick,
+ getVisualOffsetPx,
+ } = useKeyframeDrag({ zoomLevel, element, displayedStartTime });
+ const handleRevealInMedia = ({ event }: { event: React.MouseEvent }) => {
+ event.stopPropagation();
+ if (hasMediaId(element)) {
+ requestRevealMedia(element.mediaId);
+ }
+ };
+
+ const isMuted = canElementHaveAudio(element) && element.muted === true;
+
+ return (
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ className="absolute top-0 h-full select-none"
+ style={{
+ left: `${elementLeft}px`,
+ width: `${elementWidth}px`,
+ transform:
+ isBeingDragged && dragState.isDragging
+ ? `translate3d(0, ${dragOffsetY}px, 0)`
+ : undefined,
+ }}
+ >
+ <ElementInner
+ element={element}
+ track={track}
+ isSelected={isSelected}
+ onElementClick={onElementClick}
+ onElementMouseDown={onElementMouseDown}
+ handleResizeStart={handleResizeStart}
+ isDropTarget={isDropTarget}
+ />
+ {isSelected && (
+ <div className="pointer-events-none absolute inset-0 overflow-hidden">
+ <KeyframeIndicators
+ indicators={keyframeIndicators}
+ dragState={keyframeDragState}
+ displayedStartTime={displayedStartTime}
+ elementLeft={elementLeft}
+ onKeyframeMouseDown={handleKeyframeMouseDown}
+ onKeyframeClick={handleKeyframeClick}
+ getVisualOffsetPx={getVisualOffsetPx}
+ />
+ </div>
+ )}
+ </div>
+ </ContextMenuTrigger>
+ <ContextMenuContent className="w-64">
+ <ActionMenuItem
+ action="split"
+ icon={<HugeiconsIcon icon={ScissorIcon} />}
+ >
+ Split
+ </ActionMenuItem>
+ <CopyMenuItem />
+ {canElementHaveAudio(element) && hasAudio && (
+ <MuteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ isMuted={isMuted}
+ />
+ )}
+ {canElementBeHidden(element) && (
+ <VisibilityMenuItem
+ element={element}
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ />
+ )}
+ {selectedElements.length === 1 && (
+ <ActionMenuItem
+ action="duplicate-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Duplicate
+ </ActionMenuItem>
+ )}
+ {selectedElements.length === 1 && hasMediaId(element) && (
+ <>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Search01Icon} />}
+ onClick={(event: React.MouseEvent) =>
+ handleRevealInMedia({ event })
+ }
+ >
+ Reveal media
+ </ContextMenuItem>
+ <ContextMenuItem
+ icon={<HugeiconsIcon icon={Exchange01Icon} />}
+ disabled
+ >
+ Replace media
+ </ContextMenuItem>
+ </>
+ )}
+ <ContextMenuSeparator />
+ <DeleteMenuItem
+ isMultipleSelected={selectedElements.length > 1}
+ isCurrentElementSelected={isCurrentElementSelected}
+ elementType={element.type}
+ selectedCount={selectedElements.length}
+ />
+ </ContextMenuContent>
+ </ContextMenu>
+ );
+}
+
+function ElementInner({
+ element,
+ track,
+ isSelected,
+ onElementClick,
+ onElementMouseDown,
+ handleResizeStart,
+ isDropTarget = false,
+}: {
+ element: TimelineElementType;
+ track: TimelineTrack;
+ isSelected: boolean;
+ onElementClick: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ onElementMouseDown: (
+ event: React.MouseEvent,
+ element: TimelineElementType,
+ ) => void;
+ handleResizeStart: (params: {
+ event: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+ isDropTarget?: boolean;
+}) {
+ const isReducedOpacity =
+ (canElementBeHidden(element) && element.hidden) || isDropTarget;
+ return (
+ <div
+ className="absolute top-0 bottom-0"
+ style={{
+ left: `${ELEMENT_RING_WIDTH_PX}px`,
+ right: `${ELEMENT_RING_WIDTH_PX}px`,
+ }}
+ >
+ <div
+ className="absolute inset-0 rounded-sm"
+ style={
+ isSelected
+ ? {
+ boxShadow: `0 0 0 ${ELEMENT_RING_WIDTH_PX}px var(--primary)`,
+ }
+ : undefined
+ }
+ >
+ <div
+ className={cn(
+ "absolute inset-0 overflow-hidden rounded-sm",
+ getElementClasses({ type: track.type }),
+ isReducedOpacity && "opacity-50",
+ )}
+ >
+ <button
+ type="button"
+ tabIndex={-1}
+ className="absolute inset-0 size-full flex flex-col"
+ onClick={(event) => onElementClick(event, element)}
+ onMouseDown={(event) => onElementMouseDown(event, element)}
+ >
+ <div className="flex flex-1 min-h-0 items-center overflow-hidden">
+ <ElementContent element={element} track={track} />
+ </div>
+ </button>
+ </div>
+ </div>
+
+ {isSelected && (
+ <>
+ <ResizeHandle
+ side="left"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ <ResizeHandle
+ side="right"
+ elementId={element.id}
+ handleResizeStart={handleResizeStart}
+ />
+ </>
+ )}
+ </div>
+ );
+}
+
+function ResizeHandle({
+ side,
+ elementId,
+ handleResizeStart,
+}: {
+ side: "left" | "right";
+ elementId: string;
+ handleResizeStart: (params: {
+ event: React.MouseEvent;
+ elementId: string;
+ side: "left" | "right";
+ }) => void;
+}) {
+ const isLeft = side === "left";
+ return (
+ <button
+ type="button"
+ className={cn(
+ "absolute top-0 bottom-0 w-2",
+ isLeft ? "-left-1 cursor-w-resize" : "-right-1 cursor-e-resize",
+ )}
+ onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
+ onClick={(event) => event.stopPropagation()}
+ aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
+ ></button>
+ );
+}
+
+function KeyframeIndicators({
+ indicators,
+ dragState,
+ displayedStartTime,
+ elementLeft,
+ onKeyframeMouseDown,
+ onKeyframeClick,
+ getVisualOffsetPx,
+}: {
+ indicators: KeyframeIndicator[];
+ dragState: KeyframeDragState;
+ displayedStartTime: number;
+ elementLeft: number;
+ onKeyframeMouseDown: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ }) => void;
+ onKeyframeClick: (params: {
+ event: React.MouseEvent;
+ keyframes: SelectedKeyframeRef[];
+ orderedKeyframes: SelectedKeyframeRef[];
+ indicatorTime: number;
+ }) => void;
+ getVisualOffsetPx: (params: {
+ indicatorTime: number;
+ indicatorOffsetPx: number;
+ isBeingDragged: boolean;
+ displayedStartTime: number;
+ elementLeft: number;
+ }) => number;
+}) {
+ const { isKeyframeSelected } = useKeyframeSelection();
+ const orderedKeyframes = indicators.flatMap(
+ (indicator) => indicator.keyframes,
+ );
+
+ return indicators.map((indicator) => {
+ const isIndicatorSelected = indicator.keyframes.some((keyframe) =>
+ isKeyframeSelected({ keyframe }),
+ );
+ const isBeingDragged = indicator.keyframes.some((keyframe) =>
+ dragState.draggingKeyframeIds.has(keyframe.keyframeId),
+ );
+ const visualOffsetPx = getVisualOffsetPx({
+ indicatorTime: indicator.time,
+ indicatorOffsetPx: indicator.offsetPx,
+ isBeingDragged,
+ displayedStartTime,
+ elementLeft,
+ });
+
+ return (
+ <button
+ key={indicator.time}
+ type="button"
+ className="pointer-events-auto absolute top-1/2 -translate-x-1/2 -translate-y-1/2 cursor-grab"
+ style={{ left: visualOffsetPx }}
+ onMouseDown={(event) =>
+ onKeyframeMouseDown({ event, keyframes: indicator.keyframes })
+ }
+ onClick={(event) =>
+ onKeyframeClick({
+ event,
+ keyframes: indicator.keyframes,
+ orderedKeyframes,
+ indicatorTime: indicator.time,
+ })
+ }
+ aria-label="Select keyframe"
+ >
+ <HugeiconsIcon
+ icon={KeyframeIcon}
+ className={cn(
+ "size-3.5 mt-1.5 text-black",
+ isIndicatorSelected ? "fill-primary" : "fill-white",
+ )}
+ strokeWidth={1.5}
+ />
+ </button>
+ );
+ });
+}
+
+interface ElementContentProps {
+ element: TimelineElementType;
+ track: TimelineTrack;
+}
+
+function TextElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "text" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start pl-2">
+ <span className="truncate text-xs text-white">{element.content}</span>
+ </div>
+ );
+}
+
+function EffectElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "effect" }>;
+}) {
+ return (
+ <div className="flex size-full items-center justify-start gap-1 pl-2">
+ <HugeiconsIcon
+ icon={MagicWand05Icon}
+ className="size-4 shrink-0 text-white"
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function StickerElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "sticker" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={resolveStickerId({
+ stickerId: element.stickerId,
+ options: { width: 20, height: 20 },
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function GraphicElementContent({
+ element,
+}: {
+ element: Extract<TimelineElementType, { type: "graphic" }>;
+}) {
+ return (
+ <div className="flex size-full items-center gap-2 pl-2">
+ <Image
+ src={buildGraphicPreviewUrl({
+ definitionId: element.definitionId,
+ params: element.params,
+ size: 20,
+ })}
+ alt={element.name}
+ className="size-4 shrink-0"
+ width={20}
+ height={20}
+ unoptimized
+ />
+ <span className="truncate text-xs text-white">{element.name}</span>
+ </div>
+ );
+}
+
+function AudioElementContent({ element }: { element: AudioElement }) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+ const mediaAsset =
+ element.sourceType === "upload"
+ ? (mediaAssets.find((asset) => asset.id === element.mediaId) ?? null)
+ : null;
+
+ const audioBuffer =
+ element.sourceType === "library" ? element.buffer : undefined;
+ const audioUrl =
+ element.sourceType === "library" ? element.sourceUrl : mediaAsset?.url;
+ const mediaLabel = mediaAsset?.name ?? element.name;
+
+ if (audioBuffer || audioUrl) {
+ return (
+ <div className="relative size-full">
+ <AudioWaveform
+ audioBuffer={audioBuffer}
+ audioUrl={audioUrl}
+ color={ELEMENT_TYPE_CONFIG.audio.waveformColor}
+ />
+ <MediaElementHeader name={mediaLabel} hasFade={false} />
+ </div>
+ );
+ }
+
+ return (
+ <span className="text-foreground/80 truncate text-xs">{element.name}</span>
+ );
+}
+
+function EffectsButton({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const editor = useEditor();
+ const setActiveTab = usePropertiesStore((s) => s.setActiveTab);
+
+ const handleClick = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ editor.selection.setSelectedElements({
+ elements: [{ trackId: track.id, elementId: element.id }],
+ });
+ setActiveTab(element.type, "effects");
+ };
+
+ return (
+ <button
+ type="button"
+ className="flex shrink-0 justify-center text-white cursor-pointer"
+ onMouseDown={(event) => event.stopPropagation()}
+ onClick={handleClick}
+ >
+ <HugeiconsIcon icon={MagicWand05Icon} size={12} />
+ </button>
+ );
+}
+
+function TiledMediaContent({
+ element,
+ track,
+}: {
+ element: VideoElement | ImageElement;
+ track: TimelineTrack;
+}) {
+ const mediaAssets = useEditor((e) => e.media.getAssets());
+
+ const mediaAsset = mediaAssets.find((asset) => asset.id === element.mediaId);
+ const imageUrl =
+ element.type === "video"
+ ? mediaAsset?.thumbnailUrl
+ : (mediaAsset?.thumbnailUrl ?? mediaAsset?.url);
+
+ if (!imageUrl) {
+ return (
+ <span className="text-foreground/80 truncate text-xs">
+ {element.name}
+ </span>
+ );
+ }
+
+ const trackHeight = getTrackHeight({ type: track.type });
+ const tileWidth = trackHeight * THUMBNAIL_ASPECT_RATIO;
+
+ return (
+ <>
+ <div
+ className="absolute inset-0"
+ style={{
+ backgroundColor: "var(--muted)",
+ backgroundImage: `url(${imageUrl})`,
+ backgroundRepeat: "repeat-x",
+ backgroundSize: `${tileWidth}px ${trackHeight}px`,
+ backgroundPosition: "left center",
+ pointerEvents: "none",
+ }}
+ />
+ <MediaElementHeader
+ name={mediaAsset?.name}
+ leading={
+ hasElementEffects({ element }) ? (
+ <EffectsButton element={element} track={track} />
+ ) : null
+ }
+ hasFade={true}
+ />
+ </>
+ );
+}
+
+function MediaElementHeader({
+ name,
+ leading,
+ hasFade,
+}: {
+ name?: string | null;
+ leading?: ReactNode;
+ hasFade?: boolean;
+}) {
+ if (!name && !leading) {
+ return null;
+ }
+
+ return (
+ <div
+ className={cn(
+ "absolute top-0 left-0 flex h-7 w-full bg-linear-to-b pt-1",
+ hasFade && "from-black/30 to-transparent",
+ )}
+ >
+ {leading && <div className="pl-1">{leading}</div>}
+ {name && (
+ <span className="truncate px-1.5 text-[0.6rem] leading-tight text-white/75">
+ {name}
+ </span>
+ )}
+ </div>
+ );
+}
+
+function ElementContent({ element, track }: ElementContentProps) {
+ switch (element.type) {
+ case "text":
+ return <TextElementContent element={element} />;
+ case "effect":
+ return <EffectElementContent element={element} />;
+ case "sticker":
+ return <StickerElementContent element={element} />;
+ case "graphic":
+ return <GraphicElementContent element={element} />;
+ case "audio":
+ return <AudioElementContent element={element} />;
+ case "video":
+ case "image":
+ return <TiledMediaContent element={element} track={track} />;
+ }
+}
+
+function CopyMenuItem() {
+ return (
+ <ActionMenuItem
+ action="copy-selected"
+ icon={<HugeiconsIcon icon={Copy01Icon} />}
+ >
+ Copy
+ </ActionMenuItem>
+ );
+}
+
+function MuteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ isMuted,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ isMuted: boolean;
+}) {
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={VolumeMute02Icon} />;
+ }
+ return isMuted ? (
+ <HugeiconsIcon icon={VolumeOffIcon} />
+ ) : (
+ <HugeiconsIcon icon={VolumeHighIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem action="toggle-elements-muted-selected" icon={getIcon()}>
+ {isMuted ? "Unmute" : "Mute"}
+ </ActionMenuItem>
+ );
+}
+
+function VisibilityMenuItem({
+ element,
+ isMultipleSelected,
+ isCurrentElementSelected,
+}: {
+ element: TimelineElementType;
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+}) {
+ const isHidden = canElementBeHidden(element) && element.hidden;
+
+ const getIcon = () => {
+ if (isMultipleSelected && isCurrentElementSelected) {
+ return <HugeiconsIcon icon={ViewOffSlashIcon} />;
+ }
+ return isHidden ? (
+ <HugeiconsIcon icon={ViewIcon} />
+ ) : (
+ <HugeiconsIcon icon={ViewOffSlashIcon} />
+ );
+ };
+
+ return (
+ <ActionMenuItem
+ action="toggle-elements-visibility-selected"
+ icon={getIcon()}
+ >
+ {isHidden ? "Show" : "Hide"}
+ </ActionMenuItem>
+ );
+}
+
+function DeleteMenuItem({
+ isMultipleSelected,
+ isCurrentElementSelected,
+ elementType,
+ selectedCount,
+}: {
+ isMultipleSelected: boolean;
+ isCurrentElementSelected: boolean;
+ elementType: TimelineElementType["type"];
+ selectedCount: number;
+}) {
+ return (
+ <ActionMenuItem
+ action="delete-selected"
+ variant="destructive"
+ icon={<HugeiconsIcon icon={Delete02Icon} />}
+ >
+ {isMultipleSelected && isCurrentElementSelected
+ ? `Delete ${selectedCount} elements`
+ : `Delete ${elementType === "text" ? "text" : "clip"}`}
+ </ActionMenuItem>
+ );
+}
+
+function ActionMenuItem({
+ action,
+ children,
+ ...props
+}: Omit<ComponentProps<typeof ContextMenuItem>, "onClick" | "textRight"> & {
+ action: TActionWithOptionalArgs;
+ children: ReactNode;
+}) {
+ return (
+ <ContextMenuItem
+ onClick={(event: React.MouseEvent) => {
+ event.stopPropagation();
+ invokeAction(action);
+ }}
+ textRight={getDisplayShortcut({ action })}
+ {...props}
+ >
+ {children}
+ </ContextMenuItem>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx index 29fd513b..7d9a4bc2 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-tick.tsx @@ -1,39 +1,39 @@ -"use client"; - -import { timelineTimeToSnappedPixels } from "@/lib/timeline"; -import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; - -interface TimelineTickProps { - time: number; - zoomLevel: number; - fps: number; - showLabel: boolean; -} - -export function TimelineTick({ - time, - zoomLevel, - fps, - showLabel, -}: TimelineTickProps) { - const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel }); - - if (showLabel) { - const label = formatRulerLabel({ timeInSeconds: time, fps }); - return ( - <span - className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none" - style={{ left: `${leftPosition}px` }} - > - {label} - </span> - ); - } - - return ( - <div - className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l" - style={{ left: `${leftPosition}px` }} - /> - ); -} +"use client";
+
+import { timelineTimeToSnappedPixels } from "@/lib/timeline";
+import { formatRulerLabel } from "@/lib/timeline/ruler-utils";
+
+interface TimelineTickProps {
+ time: number;
+ zoomLevel: number;
+ fps: number;
+ showLabel: boolean;
+}
+
+export function TimelineTick({
+ time,
+ zoomLevel,
+ fps,
+ showLabel,
+}: TimelineTickProps) {
+ const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel });
+
+ if (showLabel) {
+ const label = formatRulerLabel({ timeInSeconds: time, fps });
+ return (
+ <span
+ className="text-muted-foreground/85 absolute top-1 select-none text-[10px] leading-none"
+ style={{ left: `${leftPosition}px` }}
+ >
+ {label}
+ </span>
+ );
+ }
+
+ return (
+ <div
+ className="border-muted-foreground/25 absolute top-1.5 h-1.5 border-l"
+ style={{ left: `${leftPosition}px` }}
+ />
+ );
+}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx index 6a9e4dfe..ffc2007b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-track.tsx @@ -1,128 +1,128 @@ -"use client"; - -import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; -import { TimelineElement } from "./timeline-element"; -import type { TimelineTrack } from "@/lib/timeline"; -import type { TimelineElement as TimelineElementType } from "@/lib/timeline"; -import type { SnapPoint } from "@/lib/timeline/snap-utils"; -import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants"; -import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; -import type { ElementDragState } from "@/lib/timeline"; -import { useEditor } from "@/hooks/use-editor"; - -interface TimelineTrackContentProps { - track: TimelineTrack; - zoomLevel: number; - dragState: ElementDragState; - rulerScrollRef: React.RefObject<HTMLDivElement | null>; - tracksScrollRef: React.RefObject<HTMLDivElement | null>; - lastMouseXRef: React.RefObject<number>; - onSnapPointChange?: (snapPoint: SnapPoint | null) => void; - onResizeStateChange?: (params: { isResizing: boolean }) => void; - onElementMouseDown: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - }) => void; - onElementClick: (params: { - event: React.MouseEvent; - element: TimelineElementType; - track: TimelineTrack; - }) => void; - onTrackMouseDown?: (event: React.MouseEvent) => void; - onTrackMouseUp?: (event: React.MouseEvent) => void; - shouldIgnoreClick?: () => boolean; - targetElementId?: string | null; -} - -export function TimelineTrackContent({ - track, - zoomLevel, - dragState, - rulerScrollRef, - tracksScrollRef, - lastMouseXRef, - onSnapPointChange, - onResizeStateChange, - onElementMouseDown, - onElementClick, - onTrackMouseDown, - onTrackMouseUp, - shouldIgnoreClick, - targetElementId = null, -}: TimelineTrackContentProps) { - const { isElementSelected } = useElementSelection(); - const duration = useEditor((e) => e.timeline.getTotalDuration()); - - useEdgeAutoScroll({ - isActive: dragState.isDragging, - getMouseClientX: () => lastMouseXRef.current ?? 0, - rulerScrollRef, - tracksScrollRef, - contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, - }); - - return ( - <div className="relative size-full"> - <button - type="button" - className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0" - aria-label={`Select ${track.name} track`} - onMouseUp={(event) => { - if (shouldIgnoreClick?.()) return; - onTrackMouseUp?.(event); - }} - onMouseDown={(event) => { - event.preventDefault(); - onTrackMouseDown?.(event); - }} - /> - {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */} - <div - className="relative h-full min-w-full" - style={{ zIndex: TIMELINE_LAYERS.trackContent }} - onMouseUp={(event) => { - if (event.target !== event.currentTarget) return; - if (shouldIgnoreClick?.()) return; - onTrackMouseUp?.(event); - }} - onMouseDown={(event) => { - if (event.target !== event.currentTarget) return; - event.preventDefault(); - onTrackMouseDown?.(event); - }} - > - {track.elements.length === 0 ? ( - <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" /> - ) : ( - track.elements.map((element) => { - const isSelected = isElementSelected({ - trackId: track.id, - elementId: element.id, - }); - - return ( - <TimelineElement - key={element.id} - element={element} - track={track} - zoomLevel={zoomLevel} - isSelected={isSelected} - onSnapPointChange={onSnapPointChange} - onResizeStateChange={onResizeStateChange} - onElementMouseDown={(event, element) => - onElementMouseDown({ event, element, track }) - } - onElementClick={(event, element) => - onElementClick({ event, element, track }) - } - dragState={dragState} - isDropTarget={element.id === targetElementId} - /> - ); - }) - )} - </div> - </div> - ); -} +"use client";
+
+import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { TimelineElement } from "./timeline-element";
+import type { TimelineTrack } from "@/lib/timeline";
+import type { TimelineElement as TimelineElementType } from "@/lib/timeline";
+import type { SnapPoint } from "@/lib/timeline/snap-utils";
+import { TIMELINE_CONSTANTS, TIMELINE_LAYERS } from "@/constants/timeline-constants";
+import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
+import type { ElementDragState } from "@/lib/timeline";
+import { useEditor } from "@/hooks/use-editor";
+
+interface TimelineTrackContentProps {
+ track: TimelineTrack;
+ zoomLevel: number;
+ dragState: ElementDragState;
+ rulerScrollRef: React.RefObject<HTMLDivElement | null>;
+ tracksScrollRef: React.RefObject<HTMLDivElement | null>;
+ lastMouseXRef: React.RefObject<number>;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+ onResizeStateChange?: (params: { isResizing: boolean }) => void;
+ onElementMouseDown: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onElementClick: (params: {
+ event: React.MouseEvent;
+ element: TimelineElementType;
+ track: TimelineTrack;
+ }) => void;
+ onTrackMouseDown?: (event: React.MouseEvent) => void;
+ onTrackMouseUp?: (event: React.MouseEvent) => void;
+ shouldIgnoreClick?: () => boolean;
+ targetElementId?: string | null;
+}
+
+export function TimelineTrackContent({
+ track,
+ zoomLevel,
+ dragState,
+ rulerScrollRef,
+ tracksScrollRef,
+ lastMouseXRef,
+ onSnapPointChange,
+ onResizeStateChange,
+ onElementMouseDown,
+ onElementClick,
+ onTrackMouseDown,
+ onTrackMouseUp,
+ shouldIgnoreClick,
+ targetElementId = null,
+}: TimelineTrackContentProps) {
+ const { isElementSelected } = useElementSelection();
+ const duration = useEditor((e) => e.timeline.getTotalDuration());
+
+ useEdgeAutoScroll({
+ isActive: dragState.isDragging,
+ getMouseClientX: () => lastMouseXRef.current ?? 0,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth: duration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel,
+ });
+
+ return (
+ <div className="relative size-full">
+ <button
+ type="button"
+ className="absolute inset-0 m-0 size-full appearance-none border-0 bg-transparent p-0"
+ aria-label={`Select ${track.name} track`}
+ onMouseUp={(event) => {
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ />
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: empty track area is a pointer-only seek surface */}
+ <div
+ className="relative h-full min-w-full"
+ style={{ zIndex: TIMELINE_LAYERS.trackContent }}
+ onMouseUp={(event) => {
+ if (event.target !== event.currentTarget) return;
+ if (shouldIgnoreClick?.()) return;
+ onTrackMouseUp?.(event);
+ }}
+ onMouseDown={(event) => {
+ if (event.target !== event.currentTarget) return;
+ event.preventDefault();
+ onTrackMouseDown?.(event);
+ }}
+ >
+ {track.elements.length === 0 ? (
+ <div className="text-muted-foreground border-muted/30 pointer-events-none flex size-full items-center justify-center rounded-sm border-2 border-dashed text-xs" />
+ ) : (
+ track.elements.map((element) => {
+ const isSelected = isElementSelected({
+ trackId: track.id,
+ elementId: element.id,
+ });
+
+ return (
+ <TimelineElement
+ key={element.id}
+ element={element}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onSnapPointChange={onSnapPointChange}
+ onResizeStateChange={onResizeStateChange}
+ onElementMouseDown={(event, element) =>
+ onElementMouseDown({ event, element, track })
+ }
+ onElementClick={(event, element) =>
+ onElementClick({ event, element, track })
+ }
+ dragState={dragState}
+ isDropTarget={element.id === targetElementId}
+ />
+ );
+ })
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx index f7576377..34efeaff 100644 --- a/apps/web/src/components/editor/scenes-view.tsx +++ b/apps/web/src/components/editor/scenes-view.tsx @@ -1,219 +1,219 @@ -"use client"; - -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { Button } from "@/components/ui/button"; -import { Check, ListCheck, Trash2 } from "lucide-react"; -import { cn } from "@/utils/ui"; -import { useState } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, - DialogTrigger, -} from "@/components/ui/dialog"; -import { canDeleteScene, getMainScene } from "@/lib/scenes"; -import { toast } from "sonner"; -import { useEditor } from "@/hooks/use-editor"; - -export function ScenesView({ children }: { children: React.ReactNode }) { - const editor = useEditor(); - const scenes = editor.scenes.getScenes(); - const currentScene = editor.scenes.getActiveScene(); - const [isSelectMode, setIsSelectMode] = useState(false); - const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set()); - - const handleSceneSwitch = async (sceneId: string) => { - if (isSelectMode) { - toggleSceneSelection({ sceneId }); - return; - } - - try { - await editor.scenes.switchToScene({ sceneId }); - } catch (error) { - console.error("Failed to switch scene:", error); - } - }; - - const toggleSceneSelection = ({ sceneId }: { sceneId: string }) => { - setSelectedScenes((prev) => { - const newSet = new Set(prev); - if (newSet.has(sceneId)) { - newSet.delete(sceneId); - } else { - newSet.add(sceneId); - } - return newSet; - }); - }; - - const handleSelectMode = () => { - setIsSelectMode(!isSelectMode); - setSelectedScenes(new Set()); - }; - - const handleDeleteSelected = async () => { - for (const sceneId of selectedScenes) { - const scene = scenes.find((scene) => scene.id === sceneId); - if (!scene) { - continue; - } - - const { canDelete, reason } = canDeleteScene({ scene }); - if (!canDelete) { - toast.error(reason || "Failed to delete scene"); - continue; - } - - try { - await editor.scenes.deleteScene({ sceneId }); - } catch (error) { - console.error("Failed to delete scene:", error); - } - } - setSelectedScenes(new Set()); - setIsSelectMode(false); - }; - - const isMainSceneSelected = (() => { - const mainScene = getMainScene({ scenes }); - return Boolean(mainScene?.id && selectedScenes.has(mainScene.id)); - })(); - - return ( - <Sheet> - <SheetTrigger asChild>{children}</SheetTrigger> - <SheetContent> - <SheetHeader> - <SheetTitle> - {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"} - </SheetTitle> - <SheetDescription> - {isSelectMode - ? "Select scenes to delete" - : "Switch between scenes in your project"} - </SheetDescription> - </SheetHeader> - <div className="flex flex-col gap-4 py-4"> - <div className="flex items-center gap-2"> - <Button - className="rounded-md" - variant={isSelectMode ? "default" : "outline"} - size="sm" - onClick={handleSelectMode} - > - <ListCheck /> - {isSelectMode ? "Cancel" : "Select"} - </Button> - {isSelectMode && ( - <DeleteDialog - count={selectedScenes.size} - onDelete={handleDeleteSelected} - disabled={isMainSceneSelected} - trigger={ - <Button - className="rounded-md" - variant="destructive" - disabled={isMainSceneSelected} - size="sm" - > - <Trash2 /> - Delete ({selectedScenes.size}) - </Button> - } - /> - )} - </div> - {scenes.length === 0 ? ( - <div className="text-muted-foreground text-sm"> - No scenes available - </div> - ) : ( - <div className="space-y-2"> - {scenes.map((scene) => ( - <Button - key={scene.id} - variant="outline" - className={cn( - "w-full justify-between font-normal", - currentScene?.id === scene.id && - !isSelectMode && - "border-primary !text-primary", - isSelectMode && - selectedScenes.has(scene.id) && - "bg-accent border-foreground/30", - )} - onClick={() => handleSceneSwitch(scene.id)} - > - <span>{scene.name}</span> - <div className="flex items-center gap-2"> - {((isSelectMode && selectedScenes.has(scene.id)) || - (!isSelectMode && currentScene?.id === scene.id)) && ( - <Check className="size-4" /> - )} - </div> - </Button> - ))} - </div> - )} - </div> - </SheetContent> - </Sheet> - ); -} - -function DeleteDialog({ - count, - onDelete, - disabled, - trigger, -}: { - count: number; - onDelete: () => void; - disabled?: boolean; - trigger: React.ReactNode; -}) { - const [open, setOpen] = useState(false); - - const handleDelete = () => { - onDelete(); - setOpen(false); - }; - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger asChild>{trigger}</DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Delete Scenes</DialogTitle> - <DialogDescription> - Are you sure you want to delete {count} scene - {count === 1 ? "" : "s"}? This action cannot be undone. - </DialogDescription> - </DialogHeader> - <DialogFooter> - <Button variant="outline" onClick={() => setOpen(false)}> - Cancel - </Button> - <Button - variant="destructive" - onClick={handleDelete} - disabled={disabled} - > - Delete - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} +"use client";
+
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { Check, ListCheck, Trash2 } from "lucide-react";
+import { cn } from "@/utils/ui";
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { canDeleteScene, getMainScene } from "@/lib/scenes";
+import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
+
+export function ScenesView({ children }: { children: React.ReactNode }) {
+ const editor = useEditor();
+ const scenes = editor.scenes.getScenes();
+ const currentScene = editor.scenes.getActiveScene();
+ const [isSelectMode, setIsSelectMode] = useState(false);
+ const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set());
+
+ const handleSceneSwitch = async (sceneId: string) => {
+ if (isSelectMode) {
+ toggleSceneSelection({ sceneId });
+ return;
+ }
+
+ try {
+ await editor.scenes.switchToScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to switch scene:", error);
+ }
+ };
+
+ const toggleSceneSelection = ({ sceneId }: { sceneId: string }) => {
+ setSelectedScenes((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(sceneId)) {
+ newSet.delete(sceneId);
+ } else {
+ newSet.add(sceneId);
+ }
+ return newSet;
+ });
+ };
+
+ const handleSelectMode = () => {
+ setIsSelectMode(!isSelectMode);
+ setSelectedScenes(new Set());
+ };
+
+ const handleDeleteSelected = async () => {
+ for (const sceneId of selectedScenes) {
+ const scene = scenes.find((scene) => scene.id === sceneId);
+ if (!scene) {
+ continue;
+ }
+
+ const { canDelete, reason } = canDeleteScene({ scene });
+ if (!canDelete) {
+ toast.error(reason || "Failed to delete scene");
+ continue;
+ }
+
+ try {
+ await editor.scenes.deleteScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to delete scene:", error);
+ }
+ }
+ setSelectedScenes(new Set());
+ setIsSelectMode(false);
+ };
+
+ const isMainSceneSelected = (() => {
+ const mainScene = getMainScene({ scenes });
+ return Boolean(mainScene?.id && selectedScenes.has(mainScene.id));
+ })();
+
+ return (
+ <Sheet>
+ <SheetTrigger asChild>{children}</SheetTrigger>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle>
+ {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"}
+ </SheetTitle>
+ <SheetDescription>
+ {isSelectMode
+ ? "Select scenes to delete"
+ : "Switch between scenes in your project"}
+ </SheetDescription>
+ </SheetHeader>
+ <div className="flex flex-col gap-4 py-4">
+ <div className="flex items-center gap-2">
+ <Button
+ className="rounded-md"
+ variant={isSelectMode ? "default" : "outline"}
+ size="sm"
+ onClick={handleSelectMode}
+ >
+ <ListCheck />
+ {isSelectMode ? "Cancel" : "Select"}
+ </Button>
+ {isSelectMode && (
+ <DeleteDialog
+ count={selectedScenes.size}
+ onDelete={handleDeleteSelected}
+ disabled={isMainSceneSelected}
+ trigger={
+ <Button
+ className="rounded-md"
+ variant="destructive"
+ disabled={isMainSceneSelected}
+ size="sm"
+ >
+ <Trash2 />
+ Delete ({selectedScenes.size})
+ </Button>
+ }
+ />
+ )}
+ </div>
+ {scenes.length === 0 ? (
+ <div className="text-muted-foreground text-sm">
+ No scenes available
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {scenes.map((scene) => (
+ <Button
+ key={scene.id}
+ variant="outline"
+ className={cn(
+ "w-full justify-between font-normal",
+ currentScene?.id === scene.id &&
+ !isSelectMode &&
+ "border-primary !text-primary",
+ isSelectMode &&
+ selectedScenes.has(scene.id) &&
+ "bg-accent border-foreground/30",
+ )}
+ onClick={() => handleSceneSwitch(scene.id)}
+ >
+ <span>{scene.name}</span>
+ <div className="flex items-center gap-2">
+ {((isSelectMode && selectedScenes.has(scene.id)) ||
+ (!isSelectMode && currentScene?.id === scene.id)) && (
+ <Check className="size-4" />
+ )}
+ </div>
+ </Button>
+ ))}
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </Sheet>
+ );
+}
+
+function DeleteDialog({
+ count,
+ onDelete,
+ disabled,
+ trigger,
+}: {
+ count: number;
+ onDelete: () => void;
+ disabled?: boolean;
+ trigger: React.ReactNode;
+}) {
+ const [open, setOpen] = useState(false);
+
+ const handleDelete = () => {
+ onDelete();
+ setOpen(false);
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>{trigger}</DialogTrigger>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Delete Scenes</DialogTitle>
+ <DialogDescription>
+ Are you sure you want to delete {count} scene
+ {count === 1 ? "" : "s"}? This action cannot be undone.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button variant="outline" onClick={() => setOpen(false)}>
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={disabled}
+ >
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx index b98340ce..e453a491 100644 --- a/apps/web/src/components/footer.tsx +++ b/apps/web/src/components/footer.tsx @@ -1,123 +1,123 @@ -import Link from "next/link"; -import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri"; -import { FaGithub } from "react-icons/fa6"; -import Image from "next/image"; -import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; -import { capitalizeFirstLetter } from "@/utils/string"; - -type Category = "resources" | "company"; - -interface FooterLink { - label: string; - href: string; -} - -type CategoryLinks = Record<Category, FooterLink[]>; - -const links: CategoryLinks = { - resources: [ - { label: "Roadmap", href: "/roadmap" }, - { label: "Changelog", href: "/changelog" }, - { label: "Blog", href: "/blog" }, - { label: "Privacy", href: "/privacy" }, - { label: "Terms of use", href: "/terms" }, - ], - company: [ - { label: "Contributors", href: "/contributors" }, - { label: "Sponsors", href: "/sponsors" }, - { label: "Brand", href: "/brand" }, - { label: "About", href: `${SOCIAL_LINKS.github}/blob/main/README.md` }, - ], -}; - -export function Footer() { - return ( - <footer className="bg-background border-t"> - <div className="mx-auto max-w-5xl px-8 py-10"> - <div className="mb-8 grid grid-cols-1 gap-12 md:grid-cols-2"> - {/* Brand Section */} - <div className="max-w-sm md:col-span-1"> - <div className="mb-4 flex items-center justify-start gap-2"> - <Image - src={DEFAULT_LOGO_URL} - alt="OpenCut" - width={24} - height={24} - className="invert dark:invert-0" - /> - <span className="text-lg font-bold">OpenCut</span> - </div> - <p className="text-muted-foreground mb-5 text-sm md:text-left"> - The privacy-first video editor that feels simple to use. - </p> - <div className="flex justify-start gap-3"> - <Link - href={SOCIAL_LINKS.github} - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <FaGithub className="size-5" /> - </Link> - <Link - href={SOCIAL_LINKS.x} - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <RiTwitterXLine className="size-5" /> - </Link> - <Link - href={SOCIAL_LINKS.discord} - className="text-muted-foreground hover:text-foreground transition-colors" - target="_blank" - rel="noopener noreferrer" - > - <RiDiscordFill className="size-5" /> - </Link> - </div> - </div> - - <div className="flex items-start justify-start gap-12 py-2"> - {(Object.keys(links) as Category[]).map((category) => ( - <div key={category} className="flex flex-col gap-2"> - <h3 className="text-foreground font-semibold"> - {capitalizeFirstLetter({ string: category })} - </h3> - <ul className="space-y-2 text-sm"> - {links[category].map((link) => ( - <li key={link.href}> - <Link - href={link.href} - className="text-muted-foreground hover:text-foreground transition-colors" - target={ - link.href.startsWith("http") ? "_blank" : undefined - } - rel={ - link.href.startsWith("http") - ? "noopener noreferrer" - : undefined - } - > - {link.label} - </Link> - </li> - ))} - </ul> - </div> - ))} - </div> - </div> - - {/* Bottom Section */} - <div className="flex flex-col items-start justify-between gap-4 pt-2 md:flex-row"> - <div className="text-muted-foreground flex items-center gap-4 text-sm"> - <span> - © {new Date().getFullYear()} OpenCut, All Rights Reserved - </span> - </div> - </div> - </div> - </footer> - ); -} +import Link from "next/link";
+import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
+import { FaGithub } from "react-icons/fa6";
+import Image from "next/image";
+import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
+import { capitalizeFirstLetter } from "@/utils/string";
+
+type Category = "resources" | "company";
+
+interface FooterLink {
+ label: string;
+ href: string;
+}
+
+type CategoryLinks = Record<Category, FooterLink[]>;
+
+const links: CategoryLinks = {
+ resources: [
+ { label: "Roadmap", href: "/roadmap" },
+ { label: "Changelog", href: "/changelog" },
+ { label: "Blog", href: "/blog" },
+ { label: "Privacy", href: "/privacy" },
+ { label: "Terms of use", href: "/terms" },
+ ],
+ company: [
+ { label: "Contributors", href: "/contributors" },
+ { label: "Sponsors", href: "/sponsors" },
+ { label: "Brand", href: "/brand" },
+ { label: "About", href: `${SOCIAL_LINKS.github}/blob/main/README.md` },
+ ],
+};
+
+export function Footer() {
+ return (
+ <footer className="bg-background border-t">
+ <div className="mx-auto max-w-5xl px-8 py-10">
+ <div className="mb-8 grid grid-cols-1 gap-12 md:grid-cols-2">
+ {/* Brand Section */}
+ <div className="max-w-sm md:col-span-1">
+ <div className="mb-4 flex items-center justify-start gap-2">
+ <Image
+ src={DEFAULT_LOGO_URL}
+ alt="OpenCut"
+ width={24}
+ height={24}
+ className="invert dark:invert-0"
+ />
+ <span className="text-lg font-bold">OpenCut</span>
+ </div>
+ <p className="text-muted-foreground mb-5 text-sm md:text-left">
+ The privacy-first video editor that feels simple to use.
+ </p>
+ <div className="flex justify-start gap-3">
+ <Link
+ href={SOCIAL_LINKS.github}
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <FaGithub className="size-5" />
+ </Link>
+ <Link
+ href={SOCIAL_LINKS.x}
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <RiTwitterXLine className="size-5" />
+ </Link>
+ <Link
+ href={SOCIAL_LINKS.discord}
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <RiDiscordFill className="size-5" />
+ </Link>
+ </div>
+ </div>
+
+ <div className="flex items-start justify-start gap-12 py-2">
+ {(Object.keys(links) as Category[]).map((category) => (
+ <div key={category} className="flex flex-col gap-2">
+ <h3 className="text-foreground font-semibold">
+ {capitalizeFirstLetter({ string: category })}
+ </h3>
+ <ul className="space-y-2 text-sm">
+ {links[category].map((link) => (
+ <li key={link.href}>
+ <Link
+ href={link.href}
+ className="text-muted-foreground hover:text-foreground transition-colors"
+ target={
+ link.href.startsWith("http") ? "_blank" : undefined
+ }
+ rel={
+ link.href.startsWith("http")
+ ? "noopener noreferrer"
+ : undefined
+ }
+ >
+ {link.label}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* Bottom Section */}
+ <div className="flex flex-col items-start justify-between gap-4 pt-2 md:flex-row">
+ <div className="text-muted-foreground flex items-center gap-4 text-sm">
+ <span>
+ © {new Date().getFullYear()} OpenCut, All Rights Reserved
+ </span>
+ </div>
+ </div>
+ </div>
+ </footer>
+ );
+}
diff --git a/apps/web/src/components/gitHub-contribute-section.tsx b/apps/web/src/components/gitHub-contribute-section.tsx index 153a662a..a06afaea 100644 --- a/apps/web/src/components/gitHub-contribute-section.tsx +++ b/apps/web/src/components/gitHub-contribute-section.tsx @@ -1,44 +1,44 @@ -import { Button } from "./ui/button"; -import Link from "next/link"; -import { SOCIAL_LINKS } from "@/constants/site-constants"; -import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -export function GitHubContributeSection({ - title, - description, -}: { - title: string; - description: string; -}) { - return ( - <div className="flex flex-col gap-6"> - <div className="flex flex-col gap-4 text-center"> - <h3 className="text-2xl font-semibold">{title}</h3> - <p className="text-muted-foreground">{description}</p> - </div> - <div className="flex flex-col justify-center gap-4 sm:flex-row"> - <Link - href={`${SOCIAL_LINKS.github}/blob/main/.github/CONTRIBUTING.md`} - target="_blank" - rel="noopener noreferrer" - > - <Button className="w-full" size="lg"> - <HugeiconsIcon icon={GithubIcon} /> - Start contributing - </Button> - </Link> - <Link - href={`${SOCIAL_LINKS.github}/issues`} - target="_blank" - rel="noopener noreferrer" - > - <Button variant="outline" className="w-full" size="lg"> - <HugeiconsIcon icon={Link04Icon} /> - Report issues - </Button> - </Link> - </div> - </div> - ); -} +import { Button } from "./ui/button";
+import Link from "next/link";
+import { SOCIAL_LINKS } from "@/constants/site-constants";
+import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+export function GitHubContributeSection({
+ title,
+ description,
+}: {
+ title: string;
+ description: string;
+}) {
+ return (
+ <div className="flex flex-col gap-6">
+ <div className="flex flex-col gap-4 text-center">
+ <h3 className="text-2xl font-semibold">{title}</h3>
+ <p className="text-muted-foreground">{description}</p>
+ </div>
+ <div className="flex flex-col justify-center gap-4 sm:flex-row">
+ <Link
+ href={`${SOCIAL_LINKS.github}/blob/main/.github/CONTRIBUTING.md`}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Button className="w-full" size="lg">
+ <HugeiconsIcon icon={GithubIcon} />
+ Start contributing
+ </Button>
+ </Link>
+ <Link
+ href={`${SOCIAL_LINKS.github}/issues`}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <Button variant="outline" className="w-full" size="lg">
+ <HugeiconsIcon icon={Link04Icon} />
+ Report issues
+ </Button>
+ </Link>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx index e24486b4..5986308e 100644 --- a/apps/web/src/components/header.tsx +++ b/apps/web/src/components/header.tsx @@ -1,197 +1,197 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { motion } from "motion/react"; -import { Button } from "./ui/button"; -import { ArrowRight } from "lucide-react"; -import Image from "next/image"; -import { ThemeToggle } from "./theme-toggle"; -import { - Copy01Icon, - Download01Icon, - GithubIcon, - LinkSquare02Icon, - Menu02Icon, -} from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { cn } from "@/utils/ui"; -import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "./ui/context-menu"; - -export function Header() { - const [isMenuOpen, setIsMenuOpen] = useState(false); - const closeMenu = () => setIsMenuOpen(false); - - const links = [ - { - label: "Roadmap", - href: "/roadmap", - }, - { - label: "Contributors", - href: "/contributors", - }, - { - label: "Sponsors", - href: "/sponsors", - }, - { - label: "Blog", - href: "/blog", - }, - ]; - - return ( - <header className="bg-background shadow-background/85 sticky top-0 z-10 shadow-[0_30px_35px_15px_rgba(0,0,0,1)]"> - <div className="relative flex w-full items-center justify-between px-6 pt-4"> - <div className="relative z-10 flex items-center gap-6"> - <ContextMenu> - <ContextMenuTrigger asChild> - <Link href="/" className="flex items-center gap-3"> - <Image - src={DEFAULT_LOGO_URL} - alt="OpenCut Logo" - className="invert dark:invert-0" - width={32} - height={32} - /> - </Link> - </ContextMenuTrigger> - <ContextMenuContent> - <ContextMenuItem - onClick={async () => { - const res = await fetch(DEFAULT_LOGO_URL); - const svg = await res.text(); - await navigator.clipboard.writeText(svg); - }} - > - <HugeiconsIcon icon={Copy01Icon} /> - Copy SVG - </ContextMenuItem> - <ContextMenuItem - onClick={() => { - const a = document.createElement("a"); - a.href = DEFAULT_LOGO_URL; - a.download = "opencut-logo.svg"; - a.click(); - }} - > - <HugeiconsIcon icon={Download01Icon} /> - Download SVG - </ContextMenuItem> - <Link href="/brand"> - <ContextMenuItem> - <HugeiconsIcon icon={LinkSquare02Icon} /> - Brand assets - </ContextMenuItem> - </Link> - </ContextMenuContent> - </ContextMenu> - - <nav className="hidden items-center gap-4 md:flex"> - {links.map((link) => ( - <Link key={link.href} href={link.href}> - <Button variant="text" className="p-0 text-sm"> - {link.label} - </Button> - </Link> - ))} - </nav> - </div> - - <div className="relative z-10"> - <div className="flex items-center gap-3 md:hidden"> - <Button - variant="text" - size="icon" - className="flex items-center justify-center p-0" - onClick={() => setIsMenuOpen(!isMenuOpen)} - > - <HugeiconsIcon icon={Menu02Icon} size={30} /> - </Button> - </div> - <div className="hidden items-center gap-3 md:flex"> - <Link href={SOCIAL_LINKS.github}> - <Button className="bg-background text-sm" variant="outline"> - <HugeiconsIcon icon={GithubIcon} className="size-4" /> - 40k+ - </Button> - </Link> - <Link href="/projects"> - <Button className="text-sm"> - Projects - <ArrowRight className="size-4" /> - </Button> - </Link> - <ThemeToggle /> - </div> - </div> - <div - className={cn( - "bg-background/20 pointer-events-none fixed inset-0 opacity-0 backdrop-blur-3xl", - "transition-opacity duration-150", - isMenuOpen && "pointer-events-auto opacity-100", - )} - > - <div className="relative h-full"> - <button - type="button" - aria-label="Close menu" - className="absolute inset-0" - onClick={closeMenu} - onKeyDown={(event) => { - if ( - event.key === "Enter" || - event.key === " " || - event.key === "Escape" - ) { - event.preventDefault(); - closeMenu(); - } - }} - /> - <nav className="flex flex-col gap-3 px-6 pt-[5rem]"> - {links.map((link, index) => ( - <motion.div - key={link.href} - initial={{ scale: 0.98, opacity: 0 }} - animate={{ - scale: isMenuOpen ? 1 : 0.98, - opacity: isMenuOpen ? 1 : 0, - }} - transition={{ - duration: 0.4, - delay: isMenuOpen ? index * 0.1 : 0, - ease: [0.25, 0.46, 0.45, 0.94], - }} - > - <Link - href={link.href} - className="text-2xl font-semibold" - onClick={() => setIsMenuOpen(false)} - > - {link.label} - </Link> - </motion.div> - ))} - </nav> - <ThemeToggle - className="absolute right-8 bottom-8 size-10" - iconClassName="!size-[1.2rem]" - onToggle={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - /> - </div> - </div> - </div> - </header> - ); -} +"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import { motion } from "motion/react";
+import { Button } from "./ui/button";
+import { ArrowRight } from "lucide-react";
+import Image from "next/image";
+import { ThemeToggle } from "./theme-toggle";
+import {
+ Copy01Icon,
+ Download01Icon,
+ GithubIcon,
+ LinkSquare02Icon,
+ Menu02Icon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { cn } from "@/utils/ui";
+import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from "./ui/context-menu";
+
+export function Header() {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const closeMenu = () => setIsMenuOpen(false);
+
+ const links = [
+ {
+ label: "Roadmap",
+ href: "/roadmap",
+ },
+ {
+ label: "Contributors",
+ href: "/contributors",
+ },
+ {
+ label: "Sponsors",
+ href: "/sponsors",
+ },
+ {
+ label: "Blog",
+ href: "/blog",
+ },
+ ];
+
+ return (
+ <header className="bg-background shadow-background/85 sticky top-0 z-10 shadow-[0_30px_35px_15px_rgba(0,0,0,1)]">
+ <div className="relative flex w-full items-center justify-between px-6 pt-4">
+ <div className="relative z-10 flex items-center gap-6">
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <Link href="/" className="flex items-center gap-3">
+ <Image
+ src={DEFAULT_LOGO_URL}
+ alt="OpenCut Logo"
+ className="invert dark:invert-0"
+ width={32}
+ height={32}
+ />
+ </Link>
+ </ContextMenuTrigger>
+ <ContextMenuContent>
+ <ContextMenuItem
+ onClick={async () => {
+ const res = await fetch(DEFAULT_LOGO_URL);
+ const svg = await res.text();
+ await navigator.clipboard.writeText(svg);
+ }}
+ >
+ <HugeiconsIcon icon={Copy01Icon} />
+ Copy SVG
+ </ContextMenuItem>
+ <ContextMenuItem
+ onClick={() => {
+ const a = document.createElement("a");
+ a.href = DEFAULT_LOGO_URL;
+ a.download = "opencut-logo.svg";
+ a.click();
+ }}
+ >
+ <HugeiconsIcon icon={Download01Icon} />
+ Download SVG
+ </ContextMenuItem>
+ <Link href="/brand">
+ <ContextMenuItem>
+ <HugeiconsIcon icon={LinkSquare02Icon} />
+ Brand assets
+ </ContextMenuItem>
+ </Link>
+ </ContextMenuContent>
+ </ContextMenu>
+
+ <nav className="hidden items-center gap-4 md:flex">
+ {links.map((link) => (
+ <Link key={link.href} href={link.href}>
+ <Button variant="text" className="p-0 text-sm">
+ {link.label}
+ </Button>
+ </Link>
+ ))}
+ </nav>
+ </div>
+
+ <div className="relative z-10">
+ <div className="flex items-center gap-3 md:hidden">
+ <Button
+ variant="text"
+ size="icon"
+ className="flex items-center justify-center p-0"
+ onClick={() => setIsMenuOpen(!isMenuOpen)}
+ >
+ <HugeiconsIcon icon={Menu02Icon} size={30} />
+ </Button>
+ </div>
+ <div className="hidden items-center gap-3 md:flex">
+ <Link href={SOCIAL_LINKS.github}>
+ <Button className="bg-background text-sm" variant="outline">
+ <HugeiconsIcon icon={GithubIcon} className="size-4" />
+ 40k+
+ </Button>
+ </Link>
+ <Link href="/projects">
+ <Button className="text-sm">
+ Projects
+ <ArrowRight className="size-4" />
+ </Button>
+ </Link>
+ <ThemeToggle />
+ </div>
+ </div>
+ <div
+ className={cn(
+ "bg-background/20 pointer-events-none fixed inset-0 opacity-0 backdrop-blur-3xl",
+ "transition-opacity duration-150",
+ isMenuOpen && "pointer-events-auto opacity-100",
+ )}
+ >
+ <div className="relative h-full">
+ <button
+ type="button"
+ aria-label="Close menu"
+ className="absolute inset-0"
+ onClick={closeMenu}
+ onKeyDown={(event) => {
+ if (
+ event.key === "Enter" ||
+ event.key === " " ||
+ event.key === "Escape"
+ ) {
+ event.preventDefault();
+ closeMenu();
+ }
+ }}
+ />
+ <nav className="flex flex-col gap-3 px-6 pt-[5rem]">
+ {links.map((link, index) => (
+ <motion.div
+ key={link.href}
+ initial={{ scale: 0.98, opacity: 0 }}
+ animate={{
+ scale: isMenuOpen ? 1 : 0.98,
+ opacity: isMenuOpen ? 1 : 0,
+ }}
+ transition={{
+ duration: 0.4,
+ delay: isMenuOpen ? index * 0.1 : 0,
+ ease: [0.25, 0.46, 0.45, 0.94],
+ }}
+ >
+ <Link
+ href={link.href}
+ className="text-2xl font-semibold"
+ onClick={() => setIsMenuOpen(false)}
+ >
+ {link.label}
+ </Link>
+ </motion.div>
+ ))}
+ </nav>
+ <ThemeToggle
+ className="absolute right-8 bottom-8 size-10"
+ iconClassName="!size-[1.2rem]"
+ onToggle={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ </div>
+ </div>
+ </header>
+ );
+}
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx index 355bbc81..ac49e4be 100644 --- a/apps/web/src/components/landing/hero.tsx +++ b/apps/web/src/components/landing/hero.tsx @@ -1,41 +1,41 @@ -"use client"; - -import { Button } from "../ui/button"; -import { ArrowRight } from "lucide-react"; -import Image from "next/image"; -import { Handlebars } from "./handlebars"; -import Link from "next/link"; - -export function Hero() { - return ( - <div className="flex min-h-[calc(100svh-4.5rem)] flex-col items-center justify-between px-4 text-center"> - <Image - className="absolute top-0 left-0 -z-50 size-full object-cover opacity-85 invert dark:invert-0" - src="/landing-page-dark.png" - height={1903.5} - width={1269} - alt="OpenCut video editor landing page background" - /> - <div className="mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center"> - <div className="inline-block text-4xl font-bold tracking-tighter md:text-[4rem]"> - <h1>The open source</h1> - <Handlebars>Video editor</Handlebars> - </div> - - <p className="text-muted-foreground mx-auto mt-10 max-w-xl text-base font-light tracking-wide sm:text-xl"> - A simple but powerful video editor that gets the job done. Works on - any platform. - </p> - - <div className="mt-8 flex justify-center gap-8"> - <Link href="/projects"> - <Button type="submit" size="lg" className="h-11 text-base"> - Try early beta - <ArrowRight className="ml-0.5" /> - </Button> - </Link> - </div> - </div> - </div> - ); -} +"use client";
+
+import { Button } from "../ui/button";
+import { ArrowRight } from "lucide-react";
+import Image from "next/image";
+import { Handlebars } from "./handlebars";
+import Link from "next/link";
+
+export function Hero() {
+ return (
+ <div className="flex min-h-[calc(100svh-4.5rem)] flex-col items-center justify-between px-4 text-center">
+ <Image
+ className="absolute top-0 left-0 -z-50 size-full object-cover opacity-85 invert dark:invert-0"
+ src="/landing-page-dark.png"
+ height={1903.5}
+ width={1269}
+ alt="OpenCut video editor landing page background"
+ />
+ <div className="mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center">
+ <div className="inline-block text-4xl font-bold tracking-tighter md:text-[4rem]">
+ <h1>The open source</h1>
+ <Handlebars>Video editor</Handlebars>
+ </div>
+
+ <p className="text-muted-foreground mx-auto mt-10 max-w-xl text-base font-light tracking-wide sm:text-xl">
+ A simple but powerful video editor that gets the job done. Works on
+ any platform.
+ </p>
+
+ <div className="mt-8 flex justify-center gap-8">
+ <Link href="/projects">
+ <Button type="submit" size="lg" className="h-11 text-base">
+ Try early beta
+ <ArrowRight className="ml-0.5" />
+ </Button>
+ </Link>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index 35e5dfba..79f1809a 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -1,133 +1,133 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { Loader2 } from "lucide-react"; -import { EditorCore } from "@/core"; -import { useEditor } from "@/hooks/use-editor"; -import { useKeybindingsListener } from "@/hooks/use-keybindings"; -import { useKeybindingsStore } from "@/stores/keybindings-store"; -import { useEditorActions } from "@/hooks/actions/use-editor-actions"; -import { loadFontAtlas } from "@/lib/fonts/google-fonts"; - -interface EditorProviderProps { - projectId: string; - children: React.ReactNode; -} - -export function EditorProvider({ projectId, children }: EditorProviderProps) { - const activeProject = useEditor((e) => e.project.getActiveOrNull()); - const router = useRouter(); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - const { setLoadingProject } = useKeybindingsStore(); - - useEffect(() => { - setLoadingProject(isLoading); - }, [isLoading, setLoadingProject]); - - useEffect(() => { - let cancelled = false; - const editor = EditorCore.getInstance(); - - const loadProject = async () => { - try { - setIsLoading(true); - await editor.project.loadProject({ id: projectId }); - - if (cancelled) return; - - setIsLoading(false); - loadFontAtlas(); - } catch (err) { - if (cancelled) return; - - const isNotFound = - err instanceof Error && - (err.message.includes("not found") || - err.message.includes("does not exist")); - - if (isNotFound) { - try { - const newProjectId = await editor.project.createNewProject({ - name: "Untitled Project", - }); - router.replace(`/editor/${newProjectId}`); - } catch (_createErr) { - setError("Failed to create project"); - setIsLoading(false); - } - } else { - setError( - err instanceof Error ? err.message : "Failed to load project", - ); - setIsLoading(false); - } - } - }; - - loadProject(); - - return () => { - cancelled = true; - }; - }, [projectId, router]); - - if (error) { - return ( - <div className="bg-background flex h-screen w-screen items-center justify-center"> - <div className="flex flex-col items-center gap-4"> - <p className="text-destructive text-sm">{error}</p> - </div> - </div> - ); - } - - if (isLoading) { - return ( - <div className="bg-background flex h-screen w-screen items-center justify-center"> - <div className="flex flex-col items-center gap-4"> - <Loader2 className="text-muted-foreground size-8 animate-spin" /> - <p className="text-muted-foreground text-sm">Loading project...</p> - </div> - </div> - ); - } - - if (!activeProject) { - return ( - <div className="bg-background flex h-screen w-screen items-center justify-center"> - <div className="flex flex-col items-center gap-4"> - <Loader2 className="text-muted-foreground size-8 animate-spin" /> - <p className="text-muted-foreground text-sm">Exiting project...</p> - </div> - </div> - ); - } - - return ( - <> - <EditorRuntimeBindings /> - {children} - </> - ); -} - -function EditorRuntimeBindings() { - const editor = useEditor(); - - useEffect(() => { - const handleBeforeUnload = (event: BeforeUnloadEvent) => { - if (!editor.save.getIsDirty()) return; - event.preventDefault(); - (event as unknown as { returnValue: string }).returnValue = ""; - }; - - window.addEventListener("beforeunload", handleBeforeUnload); - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, [editor]); - - useEditorActions(); - useKeybindingsListener(); - return null; -} +"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Loader2 } from "lucide-react";
+import { EditorCore } from "@/core";
+import { useEditor } from "@/hooks/use-editor";
+import { useKeybindingsListener } from "@/hooks/use-keybindings";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+import { useEditorActions } from "@/hooks/actions/use-editor-actions";
+import { loadFontAtlas } from "@/lib/fonts/google-fonts";
+
+interface EditorProviderProps {
+ projectId: string;
+ children: React.ReactNode;
+}
+
+export function EditorProvider({ projectId, children }: EditorProviderProps) {
+ const activeProject = useEditor((e) => e.project.getActiveOrNull());
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+ const { setLoadingProject } = useKeybindingsStore();
+
+ useEffect(() => {
+ setLoadingProject(isLoading);
+ }, [isLoading, setLoadingProject]);
+
+ useEffect(() => {
+ let cancelled = false;
+ const editor = EditorCore.getInstance();
+
+ const loadProject = async () => {
+ try {
+ setIsLoading(true);
+ await editor.project.loadProject({ id: projectId });
+
+ if (cancelled) return;
+
+ setIsLoading(false);
+ loadFontAtlas();
+ } catch (err) {
+ if (cancelled) return;
+
+ const isNotFound =
+ err instanceof Error &&
+ (err.message.includes("not found") ||
+ err.message.includes("does not exist"));
+
+ if (isNotFound) {
+ try {
+ const newProjectId = await editor.project.createNewProject({
+ name: "Untitled Project",
+ });
+ router.replace(`/editor/${newProjectId}`);
+ } catch (_createErr) {
+ setError("Failed to create project");
+ setIsLoading(false);
+ }
+ } else {
+ setError(
+ err instanceof Error ? err.message : "Failed to load project",
+ );
+ setIsLoading(false);
+ }
+ }
+ };
+
+ loadProject();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [projectId, router]);
+
+ if (error) {
+ return (
+ <div className="bg-background flex h-screen w-screen items-center justify-center">
+ <div className="flex flex-col items-center gap-4">
+ <p className="text-destructive text-sm">{error}</p>
+ </div>
+ </div>
+ );
+ }
+
+ if (isLoading) {
+ return (
+ <div className="bg-background flex h-screen w-screen items-center justify-center">
+ <div className="flex flex-col items-center gap-4">
+ <Loader2 className="text-muted-foreground size-8 animate-spin" />
+ <p className="text-muted-foreground text-sm">Loading project...</p>
+ </div>
+ </div>
+ );
+ }
+
+ if (!activeProject) {
+ return (
+ <div className="bg-background flex h-screen w-screen items-center justify-center">
+ <div className="flex flex-col items-center gap-4">
+ <Loader2 className="text-muted-foreground size-8 animate-spin" />
+ <p className="text-muted-foreground text-sm">Exiting project...</p>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <>
+ <EditorRuntimeBindings />
+ {children}
+ </>
+ );
+}
+
+function EditorRuntimeBindings() {
+ const editor = useEditor();
+
+ useEffect(() => {
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ if (!editor.save.getIsDirty()) return;
+ event.preventDefault();
+ (event as unknown as { returnValue: string }).returnValue = "";
+ };
+
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
+ }, [editor]);
+
+ useEditorActions();
+ useKeybindingsListener();
+ return null;
+}
diff --git a/apps/web/src/components/section.tsx b/apps/web/src/components/section.tsx index e3fc96df..86f2f71f 100644 --- a/apps/web/src/components/section.tsx +++ b/apps/web/src/components/section.tsx @@ -1,257 +1,257 @@ -import { createContext, useContext, useEffect, useState } from "react"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowDownIcon } from "@hugeicons/core-free-icons"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; - -const sectionExpandedCache = new Map<string, boolean>(); -const mountedSectionKeys = new Set<string>(); - -interface SectionContext { - isOpen: boolean; - toggle: () => void; - collapsible: boolean; -} - -const SectionCtx = createContext<SectionContext | null>(null); - -function useSectionContext() { - return useContext(SectionCtx); -} - -interface SectionProps { - children: React.ReactNode; - collapsible?: boolean; - defaultOpen?: boolean; - sectionKey?: string; - className?: string; - showTopBorder?: boolean; - showBottomBorder?: boolean; -} - -export function Section({ - children, - collapsible = false, - defaultOpen = true, - sectionKey, - className, - showTopBorder = false, - showBottomBorder = true, -}: SectionProps) { - const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined; - const [isOpen, setIsOpen] = useState(cached ?? defaultOpen); - - useEffect(() => { - if (!sectionKey) return; - if ( - process.env.NODE_ENV !== "production" && - mountedSectionKeys.has(sectionKey) - ) { - console.error( - `[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`, - ); - } - mountedSectionKeys.add(sectionKey); - return () => { - mountedSectionKeys.delete(sectionKey); - }; - }, [sectionKey]); - - const toggle = () => { - const next = !isOpen; - setIsOpen(next); - if (sectionKey) sectionExpandedCache.set(sectionKey, next); - }; - - return ( - <SectionCtx.Provider value={{ isOpen, toggle, collapsible }}> - <div - className={cn( - "flex flex-col", - showTopBorder && "border-t first:border-t-0", - showBottomBorder && "border-b", - className, - )} - > - {children} - </div> - </SectionCtx.Provider> - ); -} - -interface SectionHeaderProps { - children?: React.ReactNode; - trailing?: React.ReactNode; - leading?: React.ReactNode; - actions?: React.ReactNode; - onClick?: () => void; - className?: string; -} - -export function SectionHeader({ - children, - trailing, - leading, - actions, - onClick, - className, -}: SectionHeaderProps) { - const ctx = useSectionContext(); - const isCollapsible = ctx?.collapsible ?? false; - const isOpen = ctx?.isOpen ?? true; - const isInteractive = isCollapsible || !!onClick; - const handleClick = isCollapsible ? ctx?.toggle : onClick; - - const chevronIcon = ( - <HugeiconsIcon - icon={ArrowDownIcon} - className={cn( - "size-4 shrink-0 transition-transform duration-200 ease-out", - isOpen - ? "rotate-0 text-foreground" - : "-rotate-90 text-muted-foreground", - )} - /> - ); - - const trailingArea = - trailing || isCollapsible ? ( - <div className="flex items-center"> - {trailing} - {isCollapsible && ( - <Button - variant="ghost" - size="icon" - aria-label={isOpen ? "Collapse section" : "Expand section"} - onClick={handleClick} - > - {chevronIcon} - </Button> - )} - </div> - ) : null; - - const innerContent = isInteractive ? ( - <button - type="button" - className="min-w-0 flex-1 flex items-center gap-2 h-full cursor-pointer text-left" - onClick={handleClick} - > - {leading} - <div className="min-w-0 flex-1 flex items-center">{children}</div> - </button> - ) : ( - <> - {leading} - <div className="min-w-0 flex-1 flex items-center">{children}</div> - </> - ); - - return ( - <div - className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)} - > - {innerContent} - {trailingArea} - {actions} - </div> - ); -} - -export function SectionTitle({ - children, - className, - onClick, -}: { - children: React.ReactNode; - className?: string; - onClick?: () => void; -}) { - const ctx = useSectionContext(); - const isCollapsible = ctx?.collapsible ?? false; - const isOpen = ctx?.isOpen ?? true; - - const titleClass = cn( - "text-sm font-medium", - isCollapsible && isOpen ? "text-foreground" : "text-muted-foreground", - className, - ); - - if (onClick) { - return ( - <button - type="button" - className={cn("cursor-pointer", titleClass)} - onClick={onClick} - > - {children} - </button> - ); - } - - return <span className={titleClass}>{children}</span>; -} - -export function SectionFields({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - <div className={cn("flex flex-col gap-3.5", className)}>{children}</div> - ); -} - -export function SectionField({ - label, - beforeLabel, - children, - className, -}: { - label: string; - beforeLabel?: React.ReactNode; - children: React.ReactNode; - className?: string; -}) { - return ( - <div className={cn("flex flex-col gap-2", className)}> - <div className="flex h-4 items-center gap-1.5"> - {beforeLabel} - <Label>{label}</Label> - </div> - {children} - </div> - ); -} - -export function SectionContent({ - children, - className, -}: { - children: React.ReactNode; - className?: string; -}) { - const ctx = useSectionContext(); - const isCollapsible = ctx?.collapsible ?? false; - const isOpen = ctx?.isOpen ?? true; - - const content = <div className={cn("p-4 pt-0", className)}>{children}</div>; - - if (isCollapsible) { - return ( - <div - className={cn( - "grid transition-[grid-template-rows] duration-100 ease-out", - isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]", - )} - > - <div className="overflow-hidden">{content}</div> - </div> - ); - } - - return content; -} +import { createContext, useContext, useEffect, useState } from "react";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowDownIcon } from "@hugeicons/core-free-icons";
+import { Label } from "@/components/ui/label";
+import { Button } from "@/components/ui/button";
+
+const sectionExpandedCache = new Map<string, boolean>();
+const mountedSectionKeys = new Set<string>();
+
+interface SectionContext {
+ isOpen: boolean;
+ toggle: () => void;
+ collapsible: boolean;
+}
+
+const SectionCtx = createContext<SectionContext | null>(null);
+
+function useSectionContext() {
+ return useContext(SectionCtx);
+}
+
+interface SectionProps {
+ children: React.ReactNode;
+ collapsible?: boolean;
+ defaultOpen?: boolean;
+ sectionKey?: string;
+ className?: string;
+ showTopBorder?: boolean;
+ showBottomBorder?: boolean;
+}
+
+export function Section({
+ children,
+ collapsible = false,
+ defaultOpen = true,
+ sectionKey,
+ className,
+ showTopBorder = false,
+ showBottomBorder = true,
+}: SectionProps) {
+ const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
+ const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
+
+ useEffect(() => {
+ if (!sectionKey) return;
+ if (
+ process.env.NODE_ENV !== "production" &&
+ mountedSectionKeys.has(sectionKey)
+ ) {
+ console.error(
+ `[Section] duplicate sectionKey mounted simultaneously: "${sectionKey}"`,
+ );
+ }
+ mountedSectionKeys.add(sectionKey);
+ return () => {
+ mountedSectionKeys.delete(sectionKey);
+ };
+ }, [sectionKey]);
+
+ const toggle = () => {
+ const next = !isOpen;
+ setIsOpen(next);
+ if (sectionKey) sectionExpandedCache.set(sectionKey, next);
+ };
+
+ return (
+ <SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
+ <div
+ className={cn(
+ "flex flex-col",
+ showTopBorder && "border-t first:border-t-0",
+ showBottomBorder && "border-b",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ </SectionCtx.Provider>
+ );
+}
+
+interface SectionHeaderProps {
+ children?: React.ReactNode;
+ trailing?: React.ReactNode;
+ leading?: React.ReactNode;
+ actions?: React.ReactNode;
+ onClick?: () => void;
+ className?: string;
+}
+
+export function SectionHeader({
+ children,
+ trailing,
+ leading,
+ actions,
+ onClick,
+ className,
+}: SectionHeaderProps) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+ const isInteractive = isCollapsible || !!onClick;
+ const handleClick = isCollapsible ? ctx?.toggle : onClick;
+
+ const chevronIcon = (
+ <HugeiconsIcon
+ icon={ArrowDownIcon}
+ className={cn(
+ "size-4 shrink-0 transition-transform duration-200 ease-out",
+ isOpen
+ ? "rotate-0 text-foreground"
+ : "-rotate-90 text-muted-foreground",
+ )}
+ />
+ );
+
+ const trailingArea =
+ trailing || isCollapsible ? (
+ <div className="flex items-center">
+ {trailing}
+ {isCollapsible && (
+ <Button
+ variant="ghost"
+ size="icon"
+ aria-label={isOpen ? "Collapse section" : "Expand section"}
+ onClick={handleClick}
+ >
+ {chevronIcon}
+ </Button>
+ )}
+ </div>
+ ) : null;
+
+ const innerContent = isInteractive ? (
+ <button
+ type="button"
+ className="min-w-0 flex-1 flex items-center gap-2 h-full cursor-pointer text-left"
+ onClick={handleClick}
+ >
+ {leading}
+ <div className="min-w-0 flex-1 flex items-center">{children}</div>
+ </button>
+ ) : (
+ <>
+ {leading}
+ <div className="min-w-0 flex-1 flex items-center">{children}</div>
+ </>
+ );
+
+ return (
+ <div
+ className={cn("flex h-11 w-full items-center gap-2 px-3.5", className)}
+ >
+ {innerContent}
+ {trailingArea}
+ {actions}
+ </div>
+ );
+}
+
+export function SectionTitle({
+ children,
+ className,
+ onClick,
+}: {
+ children: React.ReactNode;
+ className?: string;
+ onClick?: () => void;
+}) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+
+ const titleClass = cn(
+ "text-sm font-medium",
+ isCollapsible && isOpen ? "text-foreground" : "text-muted-foreground",
+ className,
+ );
+
+ if (onClick) {
+ return (
+ <button
+ type="button"
+ className={cn("cursor-pointer", titleClass)}
+ onClick={onClick}
+ >
+ {children}
+ </button>
+ );
+ }
+
+ return <span className={titleClass}>{children}</span>;
+}
+
+export function SectionFields({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <div className={cn("flex flex-col gap-3.5", className)}>{children}</div>
+ );
+}
+
+export function SectionField({
+ label,
+ beforeLabel,
+ children,
+ className,
+}: {
+ label: string;
+ beforeLabel?: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+ <div className={cn("flex flex-col gap-2", className)}>
+ <div className="flex h-4 items-center gap-1.5">
+ {beforeLabel}
+ <Label>{label}</Label>
+ </div>
+ {children}
+ </div>
+ );
+}
+
+export function SectionContent({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+
+ const content = <div className={cn("p-4 pt-0", className)}>{children}</div>;
+
+ if (isCollapsible) {
+ return (
+ <div
+ className={cn(
+ "grid transition-[grid-template-rows] duration-100 ease-out",
+ isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
+ )}
+ >
+ <div className="overflow-hidden">{content}</div>
+ </div>
+ );
+ }
+
+ return content;
+}
diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx index 5b233782..fc7ae121 100644 --- a/apps/web/src/components/storage-provider.tsx +++ b/apps/web/src/components/storage-provider.tsx @@ -1,81 +1,81 @@ -"use client"; - -import { createContext, useContext, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { useEditor } from "@/hooks/use-editor"; -import { storageService } from "@/services/storage/service"; - -interface StorageContextType { - isInitialized: boolean; - isLoading: boolean; - hasSupport: boolean; - error: string | null; -} - -const StorageContext = createContext<StorageContextType | null>(null); - -export function useStorage() { - const context = useContext(StorageContext); - if (!context) { - throw new Error("useStorage must be used within StorageProvider"); - } - return context; -} - -interface StorageProviderProps { - children: React.ReactNode; -} - -export function StorageProvider({ children }: StorageProviderProps) { - const [status, setStatus] = useState<StorageContextType>({ - isInitialized: false, - isLoading: true, - hasSupport: false, - error: null, - }); - - const editor = useEditor(); - const hasInitialized = useRef(false); - - useEffect(() => { - if (hasInitialized.current) return; - hasInitialized.current = true; - - const initializeStorage = async () => { - setStatus((prev) => ({ ...prev, isLoading: true })); - - try { - const hasSupport = storageService.isFullySupported(); - - if (!hasSupport) { - toast.warning( - "Storage not fully supported. Some features may not work.", - ); - } - - await editor.project.loadAllProjects(); - - setStatus({ - isInitialized: true, - isLoading: false, - hasSupport, - error: null, - }); - } catch (error) { - console.error("Failed to initialize storage:", error); - setStatus({ - isInitialized: false, - isLoading: false, - hasSupport: storageService.isFullySupported(), - error: error instanceof Error ? error.message : "Unknown error", - }); - } - }; - - initializeStorage(); - }, [editor.project.loadAllProjects]); - - return ( - <StorageContext.Provider value={status}>{children}</StorageContext.Provider> - ); -} +"use client";
+
+import { createContext, useContext, useEffect, useRef, useState } from "react";
+import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
+import { storageService } from "@/services/storage/service";
+
+interface StorageContextType {
+ isInitialized: boolean;
+ isLoading: boolean;
+ hasSupport: boolean;
+ error: string | null;
+}
+
+const StorageContext = createContext<StorageContextType | null>(null);
+
+export function useStorage() {
+ const context = useContext(StorageContext);
+ if (!context) {
+ throw new Error("useStorage must be used within StorageProvider");
+ }
+ return context;
+}
+
+interface StorageProviderProps {
+ children: React.ReactNode;
+}
+
+export function StorageProvider({ children }: StorageProviderProps) {
+ const [status, setStatus] = useState<StorageContextType>({
+ isInitialized: false,
+ isLoading: true,
+ hasSupport: false,
+ error: null,
+ });
+
+ const editor = useEditor();
+ const hasInitialized = useRef(false);
+
+ useEffect(() => {
+ if (hasInitialized.current) return;
+ hasInitialized.current = true;
+
+ const initializeStorage = async () => {
+ setStatus((prev) => ({ ...prev, isLoading: true }));
+
+ try {
+ const hasSupport = storageService.isFullySupported();
+
+ if (!hasSupport) {
+ toast.warning(
+ "Storage not fully supported. Some features may not work.",
+ );
+ }
+
+ await editor.project.loadAllProjects();
+
+ setStatus({
+ isInitialized: true,
+ isLoading: false,
+ hasSupport,
+ error: null,
+ });
+ } catch (error) {
+ console.error("Failed to initialize storage:", error);
+ setStatus({
+ isInitialized: false,
+ isLoading: false,
+ hasSupport: storageService.isFullySupported(),
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ };
+
+ initializeStorage();
+ }, [editor.project.loadAllProjects]);
+
+ return (
+ <StorageContext.Provider value={status}>{children}</StorageContext.Provider>
+ );
+}
diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx index 6fd84fa3..62f82309 100644 --- a/apps/web/src/components/theme-toggle.tsx +++ b/apps/web/src/components/theme-toggle.tsx @@ -1,39 +1,39 @@ -"use client"; - -import { Button } from "./ui/button"; -import { useTheme } from "next-themes"; -import { cn } from "@/utils/ui"; -import { Sun03Icon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; - -interface ThemeToggleProps { - className?: string; - iconClassName?: string; - onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void; -} - -export function ThemeToggle({ - className, - iconClassName, - onToggle, -}: ThemeToggleProps) { - const { theme, setTheme } = useTheme(); - - return ( - <Button - size="icon" - variant="ghost" - className={cn("size-8", className)} - onClick={(e) => { - setTheme(theme === "dark" ? "light" : "dark"); - onToggle?.(e); - }} - > - <HugeiconsIcon - icon={Sun03Icon} - className={cn("!size-[1.1rem]", iconClassName)} - /> - <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span> - </Button> - ); -} +"use client";
+
+import { Button } from "./ui/button";
+import { useTheme } from "next-themes";
+import { cn } from "@/utils/ui";
+import { Sun03Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+interface ThemeToggleProps {
+ className?: string;
+ iconClassName?: string;
+ onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
+}
+
+export function ThemeToggle({
+ className,
+ iconClassName,
+ onToggle,
+}: ThemeToggleProps) {
+ const { theme, setTheme } = useTheme();
+
+ return (
+ <Button
+ size="icon"
+ variant="ghost"
+ className={cn("size-8", className)}
+ onClick={(e) => {
+ setTheme(theme === "dark" ? "light" : "dark");
+ onToggle?.(e);
+ }}
+ >
+ <HugeiconsIcon
+ icon={Sun03Icon}
+ className={cn("!size-[1.1rem]", iconClassName)}
+ />
+ <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/ui/accordion.tsx b/apps/web/src/components/ui/accordion.tsx index 95a1c2df..772f38ad 100644 --- a/apps/web/src/components/ui/accordion.tsx +++ b/apps/web/src/components/ui/accordion.tsx @@ -1,57 +1,57 @@ -"use client"; - -import * as React from "react"; -import { Accordion as AccordionPrimitive } from "radix-ui"; -import { ChevronDown } from "lucide-react"; - -import { cn } from "@/utils/ui"; - -const Accordion = AccordionPrimitive.Root; - -const AccordionItem = React.forwardRef< - React.ElementRef<typeof AccordionPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item> ->(({ className, ...props }, ref) => ( - <AccordionPrimitive.Item - ref={ref} - className={cn("border-b", className)} - {...props} - /> -)); -AccordionItem.displayName = "AccordionItem"; - -const AccordionTrigger = React.forwardRef< - React.ElementRef<typeof AccordionPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger> ->(({ className, children, ...props }, ref) => ( - <AccordionPrimitive.Header className="flex"> - <AccordionPrimitive.Trigger - ref={ref} - className={cn( - "flex flex-1 cursor-pointer items-center justify-between py-4 text-left text-sm font-medium hover:underline [&[data-state=open]>svg]:rotate-180", - className, - )} - {...props} - > - {children} - <ChevronDown className="text-muted-foreground size-4 shrink-0" /> - </AccordionPrimitive.Trigger> - </AccordionPrimitive.Header> -)); -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; - -const AccordionContent = React.forwardRef< - React.ElementRef<typeof AccordionPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content> ->(({ className, children, ...props }, ref) => ( - <AccordionPrimitive.Content - ref={ref} - className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm" - {...props} - > - <div className={cn("pt-0 pb-4", className)}>{children}</div> - </AccordionPrimitive.Content> -)); -AccordionContent.displayName = AccordionPrimitive.Content.displayName; - -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; +"use client";
+
+import * as React from "react";
+import { Accordion as AccordionPrimitive } from "radix-ui";
+import { ChevronDown } from "lucide-react";
+
+import { cn } from "@/utils/ui";
+
+const Accordion = AccordionPrimitive.Root;
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef<typeof AccordionPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
+>(({ className, ...props }, ref) => (
+ <AccordionPrimitive.Item
+ ref={ref}
+ className={cn("border-b", className)}
+ {...props}
+ />
+));
+AccordionItem.displayName = "AccordionItem";
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef<typeof AccordionPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
+>(({ className, children, ...props }, ref) => (
+ <AccordionPrimitive.Header className="flex">
+ <AccordionPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "flex flex-1 cursor-pointer items-center justify-between py-4 text-left text-sm font-medium hover:underline [&[data-state=open]>svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronDown className="text-muted-foreground size-4 shrink-0" />
+ </AccordionPrimitive.Trigger>
+ </AccordionPrimitive.Header>
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef<typeof AccordionPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <AccordionPrimitive.Content
+ ref={ref}
+ className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
+ {...props}
+ >
+ <div className={cn("pt-0 pb-4", className)}>{children}</div>
+ </AccordionPrimitive.Content>
+));
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx index dcc190d3..a1205cdd 100644 --- a/apps/web/src/components/ui/alert.tsx +++ b/apps/web/src/components/ui/alert.tsx @@ -1,71 +1,71 @@ -import * as React from "react"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { Alert02Icon } from "@hugeicons/core-free-icons"; - -const alertVariants = cva( - "relative w-full rounded-lg border p-5 py-4.5 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-destructive [&>svg~*]:pl-7", - { - variants: { - variant: { - default: "bg-background text-foreground", - destructive: - "bg-destructive/5 border-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -const Alert = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> ->(({ className, variant, children, ...props }, ref) => ( - <div - ref={ref} - role="alert" - className={cn(alertVariants({ variant }), className)} - {...props} - > - {variant === "destructive" && ( - <HugeiconsIcon - icon={Alert02Icon} - className="size-5 text-destructive mt-0.5" - /> - )} - {children} - </div> -)); -Alert.displayName = "Alert"; - -const AlertTitle = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLHeadingElement> ->(({ className, ...props }, ref) => ( - <h5 - ref={ref} - className={cn( - "mb-2 text-base leading-none font-semibold tracking-tight", - className, - )} - {...props} - /> -)); -AlertTitle.displayName = "AlertTitle"; - -const AlertDescription = React.forwardRef< - HTMLParagraphElement, - React.HTMLAttributes<HTMLParagraphElement> ->(({ className, ...props }, ref) => ( - <div - ref={ref} - className={cn("text-sm [&_p]:leading-relaxed", className)} - {...props} - /> -)); -AlertDescription.displayName = "AlertDescription"; - -export { Alert, AlertTitle, AlertDescription }; +import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Alert02Icon } from "@hugeicons/core-free-icons";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-5 py-4.5 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-destructive [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "bg-destructive/5 border-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
+>(({ className, variant, children, ...props }, ref) => (
+ <div
+ ref={ref}
+ role="alert"
+ className={cn(alertVariants({ variant }), className)}
+ {...props}
+ >
+ {variant === "destructive" && (
+ <HugeiconsIcon
+ icon={Alert02Icon}
+ className="size-5 text-destructive mt-0.5"
+ />
+ )}
+ {children}
+ </div>
+));
+Alert.displayName = "Alert";
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+ <h5
+ ref={ref}
+ className={cn(
+ "mb-2 text-base leading-none font-semibold tracking-tight",
+ className,
+ )}
+ {...props}
+ />
+));
+AlertTitle.displayName = "AlertTitle";
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("text-sm [&_p]:leading-relaxed", className)}
+ {...props}
+ />
+));
+AlertDescription.displayName = "AlertDescription";
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx index a3b59233..23d4427c 100644 --- a/apps/web/src/components/ui/avatar.tsx +++ b/apps/web/src/components/ui/avatar.tsx @@ -1,50 +1,50 @@ -"use client"; - -import * as React from "react"; -import { Avatar as AvatarPrimitive } from "radix-ui"; - -import { cn } from "@/utils/ui"; - -const Avatar = React.forwardRef< - React.ElementRef<typeof AvatarPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> ->(({ className, ...props }, ref) => ( - <AvatarPrimitive.Root - ref={ref} - className={cn( - "relative flex size-10 shrink-0 overflow-hidden rounded-full", - className, - )} - {...props} - /> -)); -Avatar.displayName = AvatarPrimitive.Root.displayName; - -const AvatarImage = React.forwardRef< - React.ElementRef<typeof AvatarPrimitive.Image>, - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> ->(({ className, ...props }, ref) => ( - <AvatarPrimitive.Image - ref={ref} - className={cn("aspect-square size-full", className)} - {...props} - /> -)); -AvatarImage.displayName = AvatarPrimitive.Image.displayName; - -const AvatarFallback = React.forwardRef< - React.ElementRef<typeof AvatarPrimitive.Fallback>, - React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> ->(({ className, ...props }, ref) => ( - <AvatarPrimitive.Fallback - ref={ref} - className={cn( - "bg-muted flex size-full items-center justify-center rounded-full", - className, - )} - {...props} - /> -)); -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; - -export { Avatar, AvatarImage, AvatarFallback }; +"use client";
+
+import * as React from "react";
+import { Avatar as AvatarPrimitive } from "radix-ui";
+
+import { cn } from "@/utils/ui";
+
+const Avatar = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Root
+ ref={ref}
+ className={cn(
+ "relative flex size-10 shrink-0 overflow-hidden rounded-full",
+ className,
+ )}
+ {...props}
+ />
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Image>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Image
+ ref={ref}
+ className={cn("aspect-square size-full", className)}
+ {...props}
+ />
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef<typeof AvatarPrimitive.Fallback>,
+ React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
+>(({ className, ...props }, ref) => (
+ <AvatarPrimitive.Fallback
+ ref={ref}
+ className={cn(
+ "bg-muted flex size-full items-center justify-center rounded-full",
+ className,
+ )}
+ {...props}
+ />
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx index 927cca89..0d51e8bf 100644 --- a/apps/web/src/components/ui/breadcrumb.tsx +++ b/apps/web/src/components/ui/breadcrumb.tsx @@ -1,106 +1,106 @@ -import { ChevronRight, MoreHorizontal } from "lucide-react"; -import { Slot } from "radix-ui"; - -import { cn } from "@/utils/ui"; - -function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { - return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />; -} - -function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) { - return ( - <ol - data-slot="breadcrumb-list" - className={cn( - "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5", - className, - )} - {...props} - /> - ); -} - -function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) { - return ( - <li - data-slot="breadcrumb-item" - className={cn("inline-flex items-center gap-1.5", className)} - {...props} - /> - ); -} - -function BreadcrumbLink({ - asChild, - className, - ...props -}: React.ComponentProps<"a"> & { - asChild?: boolean; -}) { - const Comp = asChild ? Slot.Root : "a"; - - return ( - <Comp - data-slot="breadcrumb-link" - className={cn("hover:text-foreground transition-colors", className)} - {...props} - /> - ); -} - -function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { - return ( - <span - data-slot="breadcrumb-page" - aria-current="page" - className={cn("text-foreground font-normal", className)} - {...props} - /> - ); -} - -function BreadcrumbSeparator({ - children, - className, - ...props -}: React.ComponentProps<"li">) { - return ( - <li - data-slot="breadcrumb-separator" - role="presentation" - aria-hidden="true" - className={cn("[&>svg]:size-3.5", className)} - {...props} - > - {children ?? <ChevronRight />} - </li> - ); -} - -function BreadcrumbEllipsis({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - <span - data-slot="breadcrumb-ellipsis" - role="presentation" - aria-hidden="true" - className={cn("flex size-9 items-center justify-center", className)} - {...props} - > - <MoreHorizontal className="size-4" /> - <span className="sr-only">More</span> - </span> - ); -} - -export { - Breadcrumb, - BreadcrumbList, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbPage, - BreadcrumbSeparator, - BreadcrumbEllipsis, -}; +import { ChevronRight, MoreHorizontal } from "lucide-react";
+import { Slot } from "radix-ui";
+
+import { cn } from "@/utils/ui";
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+ <ol
+ data-slot="breadcrumb-list"
+ className={cn(
+ "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+ <li
+ data-slot="breadcrumb-item"
+ className={cn("inline-flex items-center gap-1.5", className)}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot.Root : "a";
+
+ return (
+ <Comp
+ data-slot="breadcrumb-link"
+ className={cn("hover:text-foreground transition-colors", className)}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="breadcrumb-page"
+ aria-current="page"
+ className={cn("text-foreground font-normal", className)}
+ {...props}
+ />
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ <li
+ data-slot="breadcrumb-separator"
+ role="presentation"
+ aria-hidden="true"
+ className={cn("[&>svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? <ChevronRight />}
+ </li>
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="breadcrumb-ellipsis"
+ role="presentation"
+ aria-hidden="true"
+ className={cn("flex size-9 items-center justify-center", className)}
+ {...props}
+ >
+ <MoreHorizontal className="size-4" />
+ <span className="sr-only">More</span>
+ </span>
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 3dff9417..677d9330 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -1,63 +1,63 @@ -import * as React from "react"; -import { Slot as SlotPrimitive } from "radix-ui"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/utils/ui"; - -const buttonVariants = cva( - "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: "bg-foreground text-background hover:bg-foreground/90", - background: "bg-background text-foreground hover:bg-background/90", - destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/80", - "destructive-foreground": - "border bg-background hover:bg-destructive/15 text-destructive", - outline: "border border-border bg-background hover:bg-accent", - secondary: - "bg-secondary text-secondary-foreground border border-secondary-border", - text: "bg-transparent rounded-none opacity-100 hover:opacity-75", - ghost: "bg-transparent hover:bg-accent", - link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto", - }, - size: { - default: "h-9 px-4 py-2", - sm: "h-7 p-1 px-2.5 text-sm rounded-sm", - lg: "h-10 p-5 px-6", - icon: "size-7 rounded-sm", - text: "p-0", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -export interface ButtonProps - extends React.ButtonHTMLAttributes<HTMLButtonElement>, - VariantProps<typeof buttonVariants> { - asChild?: boolean; -} - -const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? SlotPrimitive.Slot : "button"; - const effectiveSize = size ?? (variant === "text" ? "text" : "default"); - return ( - <Comp - className={cn( - buttonVariants({ variant, size: effectiveSize, className }), - )} - ref={ref} - type="button" - {...props} - /> - ); - }, -); -Button.displayName = "Button"; - -export { Button, buttonVariants }; +import * as React from "react";
+import { Slot as SlotPrimitive } from "radix-ui";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+
+const buttonVariants = cva(
+ "inline-flex items-center cursor-pointer justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-foreground text-background hover:bg-foreground/90",
+ background: "bg-background text-foreground hover:bg-background/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/80",
+ "destructive-foreground":
+ "border bg-background hover:bg-destructive/15 text-destructive",
+ outline: "border border-border bg-background hover:bg-accent",
+ secondary:
+ "bg-secondary text-secondary-foreground border border-secondary-border",
+ text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
+ ghost: "bg-transparent hover:bg-accent",
+ link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
+ },
+ size: {
+ default: "h-9 px-4 py-2",
+ sm: "h-7 p-1 px-2.5 text-sm rounded-sm",
+ lg: "h-10 p-5 px-6",
+ icon: "size-7 rounded-sm",
+ text: "p-0",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+ VariantProps<typeof buttonVariants> {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? SlotPrimitive.Slot : "button";
+ const effectiveSize = size ?? (variant === "text" ? "text" : "default");
+ return (
+ <Comp
+ className={cn(
+ buttonVariants({ variant, size: effectiveSize, className }),
+ )}
+ ref={ref}
+ type="button"
+ {...props}
+ />
+ );
+ },
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx index 3d088b27..a58ad5b7 100644 --- a/apps/web/src/components/ui/calendar.tsx +++ b/apps/web/src/components/ui/calendar.tsx @@ -1,72 +1,72 @@ -"use client"; - -import type * as React from "react"; -import { ChevronLeft, ChevronRight } from "lucide-react"; -import { DayPicker } from "react-day-picker"; - -import { cn } from "@/utils/ui"; -import { buttonVariants } from "./button"; - -export type CalendarProps = React.ComponentProps<typeof DayPicker>; - -function Calendar({ - className, - classNames, - showOutsideDays = true, - ...props -}: CalendarProps) { - return ( - <DayPicker - showOutsideDays={showOutsideDays} - className={cn("p-3", className)} - classNames={{ - months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", - month: "space-y-4", - caption: "flex justify-center pt-1 relative items-center", - caption_label: "text-sm font-medium", - nav: "space-x-1 flex items-center", - nav_button: cn( - buttonVariants({ variant: "outline" }), - "size-7 bg-transparent p-0 opacity-50 hover:opacity-100", - ), - nav_button_previous: "absolute left-1", - nav_button_next: "absolute right-1", - table: "w-full border-collapse space-y-1", - head_row: "flex", - head_cell: - "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]", - row: "flex w-full mt-2", - cell: cn( - "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md", - props.mode === "range" - ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" - : "[&:has([aria-selected])]:rounded-md", - ), - day: cn( - buttonVariants({ variant: "text" }), - "size-8 p-0 font-normal aria-selected:opacity-100", - ), - day_range_start: "day-range-start", - day_range_end: "day-range-end", - day_selected: - "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", - day_today: "bg-accent text-accent-foreground", - day_outside: - "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", - day_disabled: "text-muted-foreground opacity-50", - day_range_middle: - "aria-selected:bg-accent aria-selected:text-accent-foreground", - day_hidden: "invisible", - ...classNames, - }} - components={{ - IconLeft: () => <ChevronLeft className="size-4" />, - IconRight: () => <ChevronRight className="size-4" />, - }} - {...props} - /> - ); -} -Calendar.displayName = "Calendar"; - -export { Calendar }; +"use client";
+
+import type * as React from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { DayPicker } from "react-day-picker";
+
+import { cn } from "@/utils/ui";
+import { buttonVariants } from "./button";
+
+export type CalendarProps = React.ComponentProps<typeof DayPicker>;
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: CalendarProps) {
+ return (
+ <DayPicker
+ showOutsideDays={showOutsideDays}
+ className={cn("p-3", className)}
+ classNames={{
+ months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
+ month: "space-y-4",
+ caption: "flex justify-center pt-1 relative items-center",
+ caption_label: "text-sm font-medium",
+ nav: "space-x-1 flex items-center",
+ nav_button: cn(
+ buttonVariants({ variant: "outline" }),
+ "size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
+ ),
+ nav_button_previous: "absolute left-1",
+ nav_button_next: "absolute right-1",
+ table: "w-full border-collapse space-y-1",
+ head_row: "flex",
+ head_cell:
+ "text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
+ row: "flex w-full mt-2",
+ cell: cn(
+ "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
+ props.mode === "range"
+ ? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
+ : "[&:has([aria-selected])]:rounded-md",
+ ),
+ day: cn(
+ buttonVariants({ variant: "text" }),
+ "size-8 p-0 font-normal aria-selected:opacity-100",
+ ),
+ day_range_start: "day-range-start",
+ day_range_end: "day-range-end",
+ day_selected:
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside:
+ "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_range_middle:
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
+ day_hidden: "invisible",
+ ...classNames,
+ }}
+ components={{
+ IconLeft: () => <ChevronLeft className="size-4" />,
+ IconRight: () => <ChevronRight className="size-4" />,
+ }}
+ {...props}
+ />
+ );
+}
+Calendar.displayName = "Calendar";
+
+export { Calendar };
diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx index afee272e..03c15f26 100644 --- a/apps/web/src/components/ui/checkbox.tsx +++ b/apps/web/src/components/ui/checkbox.tsx @@ -1,30 +1,30 @@ -"use client"; - -import * as React from "react"; -import { Checkbox as CheckboxPrimitive } from "radix-ui"; -import { Check } from "lucide-react"; - -import { cn } from "@/utils/ui"; - -const Checkbox = React.forwardRef< - React.ElementRef<typeof CheckboxPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> ->(({ className, ...props }, ref) => ( - <CheckboxPrimitive.Root - ref={ref} - className={cn( - "cursor-default bg-background peer focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 shadow-xs rounded-sm border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50", - className, - )} - {...props} - > - <CheckboxPrimitive.Indicator - className={cn("flex items-center justify-center text-current")} - > - <Check className="size-4" /> - </CheckboxPrimitive.Indicator> - </CheckboxPrimitive.Root> -)); -Checkbox.displayName = CheckboxPrimitive.Root.displayName; - -export { Checkbox }; +"use client";
+
+import * as React from "react";
+import { Checkbox as CheckboxPrimitive } from "radix-ui";
+import { Check } from "lucide-react";
+
+import { cn } from "@/utils/ui";
+
+const Checkbox = React.forwardRef<
+ React.ElementRef<typeof CheckboxPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
+>(({ className, ...props }, ref) => (
+ <CheckboxPrimitive.Root
+ ref={ref}
+ className={cn(
+ "cursor-default bg-background peer focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 shadow-xs rounded-sm border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
+ className,
+ )}
+ {...props}
+ >
+ <CheckboxPrimitive.Indicator
+ className={cn("flex items-center justify-center text-current")}
+ >
+ <Check className="size-4" />
+ </CheckboxPrimitive.Indicator>
+ </CheckboxPrimitive.Root>
+));
+Checkbox.displayName = CheckboxPrimitive.Root.displayName;
+
+export { Checkbox };
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx index 6d911a75..2bc7ec56 100644 --- a/apps/web/src/components/ui/color-picker.tsx +++ b/apps/web/src/components/ui/color-picker.tsx @@ -1,469 +1,469 @@ -import { forwardRef, useEffect, useRef, useState } from "react"; -import { cn } from "@/utils/ui"; -import { Input } from "./input"; -import { - Popover, - PopoverClose, - PopoverContent, - PopoverTrigger, -} from "./popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "./select"; -import { Button } from "./button"; -import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - type ColorFormat, - appendAlpha, - extractColorFromText, - formatColorValue, - hexToHsv, - hsvToHex, - parseColorInput, - parseHexAlpha, -} from "@/utils/color"; - -interface ColorPickerProps { - value?: string; - onChange?: (value: string) => void; - onChangeEnd?: (value: string) => void; - className?: string; -} - -const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>( - ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => { - const [isDragging, setIsDragging] = useState< - "saturation" | "hue" | "opacity" | null - >(null); - const [internalHue, setInternalHue] = useState(0); - const [inputValue, setInputValue] = useState(value); - const [colorFormat, setColorFormat] = useState<ColorFormat>("hex"); - - const saturationRef = useRef<HTMLButtonElement>(null); - const hueRef = useRef<HTMLButtonElement>(null); - const opacityRef = useRef<HTMLButtonElement>(null); - const latestDragColorRef = useRef<string | null>(null); - - const isEyeDropperSupported = - typeof window !== "undefined" && "EyeDropper" in window; - - const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value }); - const [h, s, v] = hexToHsv({ hex: rgbValue }); - - const handleEyeDropper = async () => { - if (!isEyeDropperSupported || !EyeDropper) return; - try { - const dropper = new EyeDropper(); - const result = await dropper.open(); - const hex = result.sRGBHex.replace("#", "").toLowerCase(); - const finalHex = appendAlpha({ rgbHex: hex, alpha }); - onChange?.(finalHex); - onChangeEnd?.(finalHex); - } catch { - // user cancelled the picker - } - }; - const hueDiff = Math.abs(h - internalHue); - const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1; - const displayHue = s === 0 || isSameHueWrapped ? internalHue : h; - - useEffect(() => { - setInputValue(formatColorValue({ hex: value, format: colorFormat })); - }, [value, colorFormat]); - - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (!isDragging) return; - - if (isDragging === "saturation" && saturationRef.current) { - const rect = saturationRef.current.getBoundingClientRect(); - const x = Math.max( - 0, - Math.min(1, (e.clientX - rect.left) / rect.width), - ); - const y = Math.max( - 0, - Math.min(1, (e.clientY - rect.top) / rect.height), - ); - const newHex = appendAlpha({ - rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), - alpha, - }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - } - - if (isDragging === "hue" && hueRef.current) { - const rect = hueRef.current.getBoundingClientRect(); - const x = Math.max( - 0, - Math.min(1, (e.clientX - rect.left) / rect.width), - ); - const newH = x * 360; - setInternalHue(newH); - if (s > 0) { - const newHex = appendAlpha({ - rgbHex: hsvToHex({ h: newH, s, v }), - alpha, - }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - } - } - - if (isDragging === "opacity" && opacityRef.current) { - const rect = opacityRef.current.getBoundingClientRect(); - const x = Math.max( - 0, - Math.min(1, (e.clientX - rect.left) / rect.width), - ); - const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - } - }; - - const handleMouseUp = () => { - if (latestDragColorRef.current !== null && onChangeEnd) { - onChangeEnd(latestDragColorRef.current); - latestDragColorRef.current = null; - } - setIsDragging(null); - }; - - if (isDragging) { - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - } - }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]); - - const handleSaturationMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - const saturationElement = saturationRef.current; - if (!saturationElement) return; - setIsDragging("saturation"); - const rect = saturationElement.getBoundingClientRect(); - const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); - const newHex = appendAlpha({ - rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }), - alpha, - }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - }; - - const handleHueMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - const hueElement = hueRef.current; - if (!hueElement) return; - setIsDragging("hue"); - const rect = hueElement.getBoundingClientRect(); - const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const newH = x * 360; - setInternalHue(newH); - if (s > 0) { - const newHex = appendAlpha({ - rgbHex: hsvToHex({ h: newH, s, v }), - alpha, - }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - } - }; - - const handleOpacityMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - const opacityElement = opacityRef.current; - if (!opacityElement) return; - setIsDragging("opacity"); - const rect = opacityElement.getBoundingClientRect(); - const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); - const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x }); - latestDragColorRef.current = newHex; - onChange?.(newHex); - }; - - const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { - setInputValue( - colorFormat === "hex" - ? e.target.value.replace("#", "") - : e.target.value, - ); - }; - - const commitInputValue = () => { - const parsed = parseColorInput({ - input: inputValue, - format: colorFormat, - }); - if (parsed) { - const nextHex = appendAlpha({ rgbHex: parsed, alpha }); - onChange?.(nextHex); - onChangeEnd?.(nextHex); - return; - } - - const extracted = extractColorFromText({ text: inputValue }); - if (extracted) { - const hasExplicitAlpha = extracted.length > 6; - const finalHex = hasExplicitAlpha - ? extracted - : appendAlpha({ rgbHex: extracted, alpha }); - onChange?.(finalHex); - onChangeEnd?.(finalHex); - } - }; - - const handleInputBlur = () => { - commitInputValue(); - }; - - const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { - if (e.key === "Enter") { - commitInputValue(); - e.currentTarget.blur(); - } - }; - - const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => { - const pastedText = event.clipboardData.getData("text"); - const extractedHex = extractColorFromText({ text: pastedText }); - if (!extractedHex) return; - - event.preventDefault(); - const hasExplicitAlpha = extractedHex.length > 6; - const finalHex = hasExplicitAlpha - ? extractedHex - : appendAlpha({ rgbHex: extractedHex, alpha }); - onChange?.(finalHex); - onChangeEnd?.(finalHex); - }; - - const saturationStyle = { - background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`, - }; - - const hueStyle = { - background: - "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)", - }; - - const checkerboardStyle = { - backgroundImage: ` - linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%), - linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%), - linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%) - `, - backgroundSize: "8px 8px", - backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px", - backgroundColor: "#fff", - }; - - - return ( - <Popover> - <div - ref={ref} - className={cn( - "bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]", - className, - )} - {...props} - > - <PopoverTrigger asChild> - <button - className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative" - type="button" - > - <span - className="absolute inset-0 dark:invert" - style={checkerboardStyle} - /> - <span - className="absolute inset-0" - style={{ backgroundColor: `#${value}` }} - /> - </button> - </PopoverTrigger> - <div className="flex flex-1 items-center"> - <Input - className={cn( - "border-0! bg-transparent p-0 ring-0! ring-offset-0!", - colorFormat === "hex" && "uppercase", - )} - size="sm" - containerClassName="w-full" - value={inputValue} - onChange={handleInputChange} - onBlur={handleInputBlur} - onKeyDown={handleInputKeyDown} - onPaste={handlePaste} - /> - </div> - </div> - <PopoverContent - className="w-64 px-0 select-none flex flex-col gap-3 py-2" - side="left" - sideOffset={8} - onOpenAutoFocus={(event) => { - event.preventDefault(); - }} - onCloseAutoFocus={(event) => { - event.preventDefault(); - }} - onInteractOutside={(event) => { - if (isDragging) event.preventDefault(); - }} - > - <header className="border-b flex justify-between items-center pb-2 px-2"> - <Select defaultValue="custom"> - <SelectTrigger variant="outline"> - <SelectValue placeholder="Select a mode" /> - </SelectTrigger> - <SelectContent position="popper"> - <SelectItem value="custom">Custom</SelectItem> - <SelectItem value="saved">Saved</SelectItem> - </SelectContent> - </Select> - <div> - {isEyeDropperSupported && ( - <Button - variant="ghost" - size="icon" - type="button" - onClick={handleEyeDropper} - > - <HugeiconsIcon icon={ColorPickerIcon} /> - </Button> - )} - <PopoverClose asChild> - <Button variant="ghost" size="icon" type="button"> - <HugeiconsIcon icon={Cancel01Icon} /> - </Button> - </PopoverClose> - </div> - </header> - <div className="px-2 flex flex-col gap-3"> - <button - ref={saturationRef} - className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0" - style={saturationStyle} - type="button" - onMouseDown={handleSaturationMouseDown} - > - <ColorCircle - size="sm" - position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }} - color={`#${value}`} - /> - </button> - - <button - ref={hueRef} - className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0" - style={hueStyle} - type="button" - onMouseDown={handleHueMouseDown} - > - <ColorCircle - size="md" - position={{ - left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`, - top: "50%", - }} - /> - </button> - - <button - ref={opacityRef} - className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0" - type="button" - onMouseDown={handleOpacityMouseDown} - > - <div className="absolute inset-0 dark:invert" style={checkerboardStyle} /> - <div - className="absolute inset-0 rounded-lg" - style={{ - background: `linear-gradient(to right, transparent, #${rgbValue})`, - }} - /> - <ColorCircle - size="md" - position={{ - left: `calc(0.5rem + (100% - 1rem) * ${alpha})`, - top: "50%", - }} - /> - </button> - - <div className="flex items-center gap-2"> - <Select - value={colorFormat} - onValueChange={(value) => setColorFormat(value as ColorFormat)} - > - <SelectTrigger variant="outline" className="min-w-18 max-w-18"> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="hex">HEX</SelectItem> - <SelectItem value="rgb">RGB</SelectItem> - <SelectItem value="hsl">HSL</SelectItem> - <SelectItem value="hsv">HSV</SelectItem> - </SelectContent> - </Select> - - <Input - className={cn( - "h-7 rounded-sm p-2.5", - colorFormat === "hex" && "uppercase", - )} - type="text" - value={inputValue} - onChange={handleInputChange} - onBlur={handleInputBlur} - onKeyDown={handleInputKeyDown} - onPaste={handlePaste} - /> - </div> - </div> - </PopoverContent> - </Popover> - ); - }, -); -ColorPicker.displayName = "ColorPicker"; - -const ColorCircle = ({ - size, - position, - color, -}: { - size: "sm" | "md"; - position: { left: string; top: string }; - color?: string; -}) => ( - <div - className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${ - size === "sm" ? "size-3" : "size-4" - }`} - style={{ - left: position.left, - top: position.top, - transform: "translate(-50%, -50%)", - backgroundColor: color, - }} - /> -); - -export { ColorPicker }; +import { forwardRef, useEffect, useRef, useState } from "react";
+import { cn } from "@/utils/ui";
+import { Input } from "./input";
+import {
+ Popover,
+ PopoverClose,
+ PopoverContent,
+ PopoverTrigger,
+} from "./popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./select";
+import { Button } from "./button";
+import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ type ColorFormat,
+ appendAlpha,
+ extractColorFromText,
+ formatColorValue,
+ hexToHsv,
+ hsvToHex,
+ parseColorInput,
+ parseHexAlpha,
+} from "@/utils/color";
+
+interface ColorPickerProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ onChangeEnd?: (value: string) => void;
+ className?: string;
+}
+
+const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
+ ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => {
+ const [isDragging, setIsDragging] = useState<
+ "saturation" | "hue" | "opacity" | null
+ >(null);
+ const [internalHue, setInternalHue] = useState(0);
+ const [inputValue, setInputValue] = useState(value);
+ const [colorFormat, setColorFormat] = useState<ColorFormat>("hex");
+
+ const saturationRef = useRef<HTMLButtonElement>(null);
+ const hueRef = useRef<HTMLButtonElement>(null);
+ const opacityRef = useRef<HTMLButtonElement>(null);
+ const latestDragColorRef = useRef<string | null>(null);
+
+ const isEyeDropperSupported =
+ typeof window !== "undefined" && "EyeDropper" in window;
+
+ const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value });
+ const [h, s, v] = hexToHsv({ hex: rgbValue });
+
+ const handleEyeDropper = async () => {
+ if (!isEyeDropperSupported || !EyeDropper) return;
+ try {
+ const dropper = new EyeDropper();
+ const result = await dropper.open();
+ const hex = result.sRGBHex.replace("#", "").toLowerCase();
+ const finalHex = appendAlpha({ rgbHex: hex, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ } catch {
+ // user cancelled the picker
+ }
+ };
+ const hueDiff = Math.abs(h - internalHue);
+ const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1;
+ const displayHue = s === 0 || isSameHueWrapped ? internalHue : h;
+
+ useEffect(() => {
+ setInputValue(formatColorValue({ hex: value, format: colorFormat }));
+ }, [value, colorFormat]);
+
+ useEffect(() => {
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isDragging) return;
+
+ if (isDragging === "saturation" && saturationRef.current) {
+ const rect = saturationRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (e.clientX - rect.left) / rect.width),
+ );
+ const y = Math.max(
+ 0,
+ Math.min(1, (e.clientY - rect.top) / rect.height),
+ );
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+
+ if (isDragging === "hue" && hueRef.current) {
+ const rect = hueRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (e.clientX - rect.left) / rect.width),
+ );
+ const newH = x * 360;
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: newH, s, v }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+ }
+
+ if (isDragging === "opacity" && opacityRef.current) {
+ const rect = opacityRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (e.clientX - rect.left) / rect.width),
+ );
+ const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+ };
+
+ const handleMouseUp = () => {
+ if (latestDragColorRef.current !== null && onChangeEnd) {
+ onChangeEnd(latestDragColorRef.current);
+ latestDragColorRef.current = null;
+ }
+ setIsDragging(null);
+ };
+
+ if (isDragging) {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }
+ }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]);
+
+ const handleSaturationMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ const saturationElement = saturationRef.current;
+ if (!saturationElement) return;
+ setIsDragging("saturation");
+ const rect = saturationElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ };
+
+ const handleHueMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ const hueElement = hueRef.current;
+ if (!hueElement) return;
+ setIsDragging("hue");
+ const rect = hueElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const newH = x * 360;
+ setInternalHue(newH);
+ if (s > 0) {
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: newH, s, v }),
+ alpha,
+ });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
+ };
+
+ const handleOpacityMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ const opacityElement = opacityRef.current;
+ if (!opacityElement) return;
+ setIsDragging("opacity");
+ const rect = opacityElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ };
+
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ setInputValue(
+ colorFormat === "hex"
+ ? e.target.value.replace("#", "")
+ : e.target.value,
+ );
+ };
+
+ const commitInputValue = () => {
+ const parsed = parseColorInput({
+ input: inputValue,
+ format: colorFormat,
+ });
+ if (parsed) {
+ const nextHex = appendAlpha({ rgbHex: parsed, alpha });
+ onChange?.(nextHex);
+ onChangeEnd?.(nextHex);
+ return;
+ }
+
+ const extracted = extractColorFromText({ text: inputValue });
+ if (extracted) {
+ const hasExplicitAlpha = extracted.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extracted
+ : appendAlpha({ rgbHex: extracted, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ }
+ };
+
+ const handleInputBlur = () => {
+ commitInputValue();
+ };
+
+ const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "Enter") {
+ commitInputValue();
+ e.currentTarget.blur();
+ }
+ };
+
+ const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
+ const pastedText = event.clipboardData.getData("text");
+ const extractedHex = extractColorFromText({ text: pastedText });
+ if (!extractedHex) return;
+
+ event.preventDefault();
+ const hasExplicitAlpha = extractedHex.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extractedHex
+ : appendAlpha({ rgbHex: extractedHex, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ };
+
+ const saturationStyle = {
+ background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
+ };
+
+ const hueStyle = {
+ background:
+ "linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
+ };
+
+ const checkerboardStyle = {
+ backgroundImage: `
+ linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
+ linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
+ `,
+ backgroundSize: "8px 8px",
+ backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
+ backgroundColor: "#fff",
+ };
+
+
+ return (
+ <Popover>
+ <div
+ ref={ref}
+ className={cn(
+ "bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]",
+ className,
+ )}
+ {...props}
+ >
+ <PopoverTrigger asChild>
+ <button
+ className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20 overflow-hidden relative"
+ type="button"
+ >
+ <span
+ className="absolute inset-0 dark:invert"
+ style={checkerboardStyle}
+ />
+ <span
+ className="absolute inset-0"
+ style={{ backgroundColor: `#${value}` }}
+ />
+ </button>
+ </PopoverTrigger>
+ <div className="flex flex-1 items-center">
+ <Input
+ className={cn(
+ "border-0! bg-transparent p-0 ring-0! ring-offset-0!",
+ colorFormat === "hex" && "uppercase",
+ )}
+ size="sm"
+ containerClassName="w-full"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleInputKeyDown}
+ onPaste={handlePaste}
+ />
+ </div>
+ </div>
+ <PopoverContent
+ className="w-64 px-0 select-none flex flex-col gap-3 py-2"
+ side="left"
+ sideOffset={8}
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ onInteractOutside={(event) => {
+ if (isDragging) event.preventDefault();
+ }}
+ >
+ <header className="border-b flex justify-between items-center pb-2 px-2">
+ <Select defaultValue="custom">
+ <SelectTrigger variant="outline">
+ <SelectValue placeholder="Select a mode" />
+ </SelectTrigger>
+ <SelectContent position="popper">
+ <SelectItem value="custom">Custom</SelectItem>
+ <SelectItem value="saved">Saved</SelectItem>
+ </SelectContent>
+ </Select>
+ <div>
+ {isEyeDropperSupported && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={handleEyeDropper}
+ >
+ <HugeiconsIcon icon={ColorPickerIcon} />
+ </Button>
+ )}
+ <PopoverClose asChild>
+ <Button variant="ghost" size="icon" type="button">
+ <HugeiconsIcon icon={Cancel01Icon} />
+ </Button>
+ </PopoverClose>
+ </div>
+ </header>
+ <div className="px-2 flex flex-col gap-3">
+ <button
+ ref={saturationRef}
+ className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0"
+ style={saturationStyle}
+ type="button"
+ onMouseDown={handleSaturationMouseDown}
+ >
+ <ColorCircle
+ size="sm"
+ position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
+ color={`#${value}`}
+ />
+ </button>
+
+ <button
+ ref={hueRef}
+ className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0"
+ style={hueStyle}
+ type="button"
+ onMouseDown={handleHueMouseDown}
+ >
+ <ColorCircle
+ size="md"
+ position={{
+ left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`,
+ top: "50%",
+ }}
+ />
+ </button>
+
+ <button
+ ref={opacityRef}
+ className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
+ type="button"
+ onMouseDown={handleOpacityMouseDown}
+ >
+ <div className="absolute inset-0 dark:invert" style={checkerboardStyle} />
+ <div
+ className="absolute inset-0 rounded-lg"
+ style={{
+ background: `linear-gradient(to right, transparent, #${rgbValue})`,
+ }}
+ />
+ <ColorCircle
+ size="md"
+ position={{
+ left: `calc(0.5rem + (100% - 1rem) * ${alpha})`,
+ top: "50%",
+ }}
+ />
+ </button>
+
+ <div className="flex items-center gap-2">
+ <Select
+ value={colorFormat}
+ onValueChange={(value) => setColorFormat(value as ColorFormat)}
+ >
+ <SelectTrigger variant="outline" className="min-w-18 max-w-18">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="hex">HEX</SelectItem>
+ <SelectItem value="rgb">RGB</SelectItem>
+ <SelectItem value="hsl">HSL</SelectItem>
+ <SelectItem value="hsv">HSV</SelectItem>
+ </SelectContent>
+ </Select>
+
+ <Input
+ className={cn(
+ "h-7 rounded-sm p-2.5",
+ colorFormat === "hex" && "uppercase",
+ )}
+ type="text"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleInputKeyDown}
+ onPaste={handlePaste}
+ />
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
+ );
+ },
+);
+ColorPicker.displayName = "ColorPicker";
+
+const ColorCircle = ({
+ size,
+ position,
+ color,
+}: {
+ size: "sm" | "md";
+ position: { left: string; top: string };
+ color?: string;
+}) => (
+ <div
+ className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${
+ size === "sm" ? "size-3" : "size-4"
+ }`}
+ style={{
+ left: position.left,
+ top: position.top,
+ transform: "translate(-50%, -50%)",
+ backgroundColor: color,
+ }}
+ />
+);
+
+export { ColorPicker };
diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index ec02de6b..2279de5a 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -1,303 +1,303 @@ -"use client"; - -import * as React from "react"; -import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - Tick02Icon, - ArrowRightIcon, - CircleIcon, -} from "@hugeicons/core-free-icons"; -import { useOverlayOpenChange } from "./use-overlay-open-change"; - -function ContextMenu({ - onOpenChange, - ...props -}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) { - const handleOpenChange = useOverlayOpenChange({ - source: "context-menu", - onOpenChange, - }); - return ( - <ContextMenuPrimitive.Root onOpenChange={handleOpenChange} {...props} /> - ); -} - -const ContextMenuTrigger = ContextMenuPrimitive.Trigger; - -const ContextMenuGroup = ContextMenuPrimitive.Group; - -const ContextMenuPortal = ContextMenuPrimitive.Portal; - -const ContextMenuSub = ContextMenuPrimitive.Sub; - -const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; - -const contextMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", - { - variants: { - variant: { - default: - "focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground", - destructive: - "text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -const ContextMenuSubTrigger = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & { - inset?: boolean; - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - icon?: React.ReactNode; - } ->( - ( - { className, inset, children, variant = "default", icon, ...props }, - ref, - ) => ( - <ContextMenuPrimitive.SubTrigger - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", - inset && "pl-8", - className, - )} - {...props} - > - {icon && ( - <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> - )} - {children} - <HugeiconsIcon - icon={ArrowRightIcon} - className="ml-auto text-muted-foreground/80" - /> - </ContextMenuPrimitive.SubTrigger> - ), -); -ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; - -const ContextMenuSubContent = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.SubContent>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> ->(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.SubContent - ref={ref} - className={cn( - "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5", - className, - )} - {...props} - /> -)); -ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName; - -const ContextMenuContent = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & { - container?: HTMLElement | null; - } ->(({ className, container, ...props }, ref) => ( - <ContextMenuPrimitive.Portal container={container ?? undefined}> - <ContextMenuPrimitive.Content - ref={ref} - className={cn( - "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5", - className, - )} - {...props} - /> - </ContextMenuPrimitive.Portal> -)); -ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName; - -const ContextMenuItem = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { - inset?: boolean; - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - icon?: React.ReactNode; - textRight?: string; - } ->( - ( - { - className, - inset, - variant = "default", - icon, - children, - textRight, - ...props - }, - ref, - ) => { - const shouldInsetContent = inset || Boolean(icon); - - return ( - <ContextMenuPrimitive.Item - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - shouldInsetContent && "pl-8", - className, - )} - {...props} - > - {icon && ( - <span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0"> - {icon} - </span> - )} - {children} - {textRight && ( - <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5"> - {textRight} - </span> - )} - </ContextMenuPrimitive.Item> - ); - }, -); -ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName; - -const ContextMenuCheckboxItem = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & { - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - icon?: React.ReactNode; - } ->( - ( - { className, children, checked, variant = "default", icon, ...props }, - ref, - ) => ( - <ContextMenuPrimitive.CheckboxItem - ref={ref} - className={cn( - contextMenuItemVariants({ variant }), - "pr-2 pl-8", - className, - )} - checked={checked} - {...props} - > - <span className="absolute left-3 flex size-3.5 items-center justify-center"> - <ContextMenuPrimitive.ItemIndicator> - <HugeiconsIcon icon={Tick02Icon} className="size-4" /> - </ContextMenuPrimitive.ItemIndicator> - </span> - {icon && ( - <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> - )} - {children} - </ContextMenuPrimitive.CheckboxItem> - ), -); -ContextMenuCheckboxItem.displayName = - ContextMenuPrimitive.CheckboxItem.displayName; - -const ContextMenuRadioItem = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & { - variant?: VariantProps<typeof contextMenuItemVariants>["variant"]; - icon?: React.ReactNode; - } ->(({ className, children, variant = "default", icon, ...props }, ref) => ( - <ContextMenuPrimitive.RadioItem - ref={ref} - className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)} - {...props} - > - <span className="absolute left-2 flex size-3.5 items-center justify-center"> - <ContextMenuPrimitive.ItemIndicator> - <HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" /> - </ContextMenuPrimitive.ItemIndicator> - </span> - {icon && ( - <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> - )} - {children} - </ContextMenuPrimitive.RadioItem> -)); -ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName; - -const ContextMenuLabel = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & { - inset?: boolean; - icon?: React.ReactNode; - } ->(({ className, inset, icon, children, ...props }, ref) => ( - <ContextMenuPrimitive.Label - ref={ref} - className={cn( - "flex items-center gap-2.5 px-4 py-1.5 text-sm font-semibold text-foreground", - inset && "pl-8", - className, - )} - {...props} - > - {icon && ( - <span className="size-4 shrink-0 text-muted-foreground">{icon}</span> - )} - {children} - </ContextMenuPrimitive.Label> -)); -ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName; - -const ContextMenuSeparator = React.forwardRef< - React.ElementRef<typeof ContextMenuPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> ->(({ className, ...props }, ref) => ( - <ContextMenuPrimitive.Separator - ref={ref} - className={cn("bg-border my-2 h-px", className)} - {...props} - /> -)); -ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName; - -const ContextMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn( - "ml-auto text-xs tracking-widest text-muted-foreground opacity-60", - className, - )} - {...props} - /> - ); -}; -ContextMenuShortcut.displayName = "ContextMenuShortcut"; - -export { - ContextMenu, - ContextMenuTrigger, - ContextMenuContent, - ContextMenuItem, - ContextMenuCheckboxItem, - ContextMenuRadioItem, - ContextMenuLabel, - ContextMenuSeparator, - ContextMenuShortcut, - ContextMenuGroup, - ContextMenuPortal, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, - ContextMenuRadioGroup, -}; +"use client";
+
+import * as React from "react";
+import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ Tick02Icon,
+ ArrowRightIcon,
+ CircleIcon,
+} from "@hugeicons/core-free-icons";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function ContextMenu({
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "context-menu",
+ onOpenChange,
+ });
+ return (
+ <ContextMenuPrimitive.Root onOpenChange={handleOpenChange} {...props} />
+ );
+}
+
+const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
+
+const ContextMenuGroup = ContextMenuPrimitive.Group;
+
+const ContextMenuPortal = ContextMenuPrimitive.Portal;
+
+const ContextMenuSub = ContextMenuPrimitive.Sub;
+
+const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
+
+const contextMenuItemVariants = cva(
+ "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default:
+ "focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground",
+ destructive:
+ "text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const ContextMenuSubTrigger = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
+ inset?: boolean;
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ icon?: React.ReactNode;
+ }
+>(
+ (
+ { className, inset, children, variant = "default", icon, ...props },
+ ref,
+ ) => (
+ <ContextMenuPrimitive.SubTrigger
+ ref={ref}
+ className={cn(
+ contextMenuItemVariants({ variant }),
+ "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ <HugeiconsIcon
+ icon={ArrowRightIcon}
+ className="ml-auto text-muted-foreground/80"
+ />
+ </ContextMenuPrimitive.SubTrigger>
+ ),
+);
+ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
+
+const ContextMenuSubContent = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
+>(({ className, ...props }, ref) => (
+ <ContextMenuPrimitive.SubContent
+ ref={ref}
+ className={cn(
+ "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
+ className,
+ )}
+ {...props}
+ />
+));
+ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
+
+const ContextMenuContent = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
+ container?: HTMLElement | null;
+ }
+>(({ className, container, ...props }, ref) => (
+ <ContextMenuPrimitive.Portal container={container ?? undefined}>
+ <ContextMenuPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5",
+ className,
+ )}
+ {...props}
+ />
+ </ContextMenuPrimitive.Portal>
+));
+ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
+
+const ContextMenuItem = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
+ inset?: boolean;
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ icon?: React.ReactNode;
+ textRight?: string;
+ }
+>(
+ (
+ {
+ className,
+ inset,
+ variant = "default",
+ icon,
+ children,
+ textRight,
+ ...props
+ },
+ ref,
+ ) => {
+ const shouldInsetContent = inset || Boolean(icon);
+
+ return (
+ <ContextMenuPrimitive.Item
+ ref={ref}
+ className={cn(
+ contextMenuItemVariants({ variant }),
+ shouldInsetContent && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {icon && (
+ <span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0">
+ {icon}
+ </span>
+ )}
+ {children}
+ {textRight && (
+ <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
+ {textRight}
+ </span>
+ )}
+ </ContextMenuPrimitive.Item>
+ );
+ },
+);
+ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
+
+const ContextMenuCheckboxItem = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ icon?: React.ReactNode;
+ }
+>(
+ (
+ { className, children, checked, variant = "default", icon, ...props },
+ ref,
+ ) => (
+ <ContextMenuPrimitive.CheckboxItem
+ ref={ref}
+ className={cn(
+ contextMenuItemVariants({ variant }),
+ "pr-2 pl-8",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span className="absolute left-3 flex size-3.5 items-center justify-center">
+ <ContextMenuPrimitive.ItemIndicator>
+ <HugeiconsIcon icon={Tick02Icon} className="size-4" />
+ </ContextMenuPrimitive.ItemIndicator>
+ </span>
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ </ContextMenuPrimitive.CheckboxItem>
+ ),
+);
+ContextMenuCheckboxItem.displayName =
+ ContextMenuPrimitive.CheckboxItem.displayName;
+
+const ContextMenuRadioItem = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
+ variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
+ icon?: React.ReactNode;
+ }
+>(({ className, children, variant = "default", icon, ...props }, ref) => (
+ <ContextMenuPrimitive.RadioItem
+ ref={ref}
+ className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
+ {...props}
+ >
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
+ <ContextMenuPrimitive.ItemIndicator>
+ <HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" />
+ </ContextMenuPrimitive.ItemIndicator>
+ </span>
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ </ContextMenuPrimitive.RadioItem>
+));
+ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
+
+const ContextMenuLabel = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
+ inset?: boolean;
+ icon?: React.ReactNode;
+ }
+>(({ className, inset, icon, children, ...props }, ref) => (
+ <ContextMenuPrimitive.Label
+ ref={ref}
+ className={cn(
+ "flex items-center gap-2.5 px-4 py-1.5 text-sm font-semibold text-foreground",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ </ContextMenuPrimitive.Label>
+));
+ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
+
+const ContextMenuSeparator = React.forwardRef<
+ React.ElementRef<typeof ContextMenuPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <ContextMenuPrimitive.Separator
+ ref={ref}
+ className={cn("bg-border my-2 h-px", className)}
+ {...props}
+ />
+));
+ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
+
+const ContextMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLSpanElement>) => {
+ return (
+ <span
+ className={cn(
+ "ml-auto text-xs tracking-widest text-muted-foreground opacity-60",
+ className,
+ )}
+ {...props}
+ />
+ );
+};
+ContextMenuShortcut.displayName = "ContextMenuShortcut";
+
+export {
+ ContextMenu,
+ ContextMenuTrigger,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuCheckboxItem,
+ ContextMenuRadioItem,
+ ContextMenuLabel,
+ ContextMenuSeparator,
+ ContextMenuShortcut,
+ ContextMenuGroup,
+ ContextMenuPortal,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+ ContextMenuRadioGroup,
+};
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index f5045f95..60ea46c1 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -1,145 +1,145 @@ -"use client"; - -import * as React from "react"; -import { Dialog as DialogPrimitive } from "radix-ui"; -import { X } from "lucide-react"; -import { cn } from "@/utils/ui"; -import { useOverlayOpenChange } from "./use-overlay-open-change"; - -function Dialog({ - open, - onOpenChange, - ...props -}: React.ComponentProps<typeof DialogPrimitive.Root>) { - const handleOpenChange = useOverlayOpenChange({ - source: "dialog", - open, - onOpenChange, - }); - return ( - <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} /> - ); -} - -const DialogTrigger = DialogPrimitive.Trigger; - -const DialogPortal = DialogPrimitive.Portal; - -const DialogClose = DialogPrimitive.Close; - -const DialogOverlay = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> ->(({ className, ...props }, ref) => ( - <DialogPrimitive.Overlay - ref={ref} - className={cn( - "fixed inset-0 z-250 backdrop-blur-sm bg-black/10", - className, - )} - {...props} - /> -)); -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; - -const DialogContent = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> ->(({ className, children, ...props }, ref) => ( - <DialogPortal> - <DialogOverlay /> - <DialogPrimitive.Content - ref={ref} - className={cn( - "bg-popover fixed top-[50%] left-[50%] z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200", - className, - )} - onCloseAutoFocus={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - {...props} - > - {children} - <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-6 right-6 cursor-pointer opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> - <X className="size-5 text-muted-foreground" /> - <span className="sr-only">Close</span> - </DialogPrimitive.Close> - </DialogPrimitive.Content> - </DialogPortal> -)); -DialogContent.displayName = DialogPrimitive.Content.displayName; - -const DialogHeader = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn("flex flex-col space-y-2 text-left border-b p-6", className)} - {...props} - /> -); -DialogHeader.displayName = "DialogHeader"; - -const DialogBody = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div className={cn("p-6 flex flex-col gap-4", className)} {...props} /> -); -DialogBody.displayName = "DialogBody"; - -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex gap-3 flex-col-reverse sm:flex-row sm:justify-end p-6 py-5 border-t", - className, - )} - {...props} - /> -); -DialogFooter.displayName = "DialogFooter"; - -const DialogTitle = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> ->(({ className, ...props }, ref) => ( - <DialogPrimitive.Title - ref={ref} - className={cn( - "text-lg leading-none font-semibold tracking-tight", - className, - )} - {...props} - /> -)); -DialogTitle.displayName = DialogPrimitive.Title.displayName; - -const DialogDescription = React.forwardRef< - React.ElementRef<typeof DialogPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> ->(({ className, ...props }, ref) => ( - <DialogPrimitive.Description - ref={ref} - className={cn("text-muted-foreground text-sm", className)} - {...props} - /> -)); -DialogDescription.displayName = DialogPrimitive.Description.displayName; - -export { - Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, - DialogClose, - DialogContent, - DialogHeader, - DialogBody, - DialogFooter, - DialogTitle, - DialogDescription, -}; +"use client";
+
+import * as React from "react";
+import { Dialog as DialogPrimitive } from "radix-ui";
+import { X } from "lucide-react";
+import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function Dialog({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dialog",
+ open,
+ onOpenChange,
+ });
+ return (
+ <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Overlay
+ ref={ref}
+ className={cn(
+ "fixed inset-0 z-250 backdrop-blur-sm bg-black/10",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+ <DialogPortal>
+ <DialogOverlay />
+ <DialogPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-popover fixed top-[50%] left-[50%] z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200",
+ className,
+ )}
+ onCloseAutoFocus={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ {...props}
+ >
+ {children}
+ <DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-6 right-6 cursor-pointer opacity-70 hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
+ <X className="size-5 text-muted-foreground" />
+ <span className="sr-only">Close</span>
+ </DialogPrimitive.Close>
+ </DialogPrimitive.Content>
+ </DialogPortal>
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn("flex flex-col space-y-2 text-left border-b p-6", className)}
+ {...props}
+ />
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogBody = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div className={cn("p-6 flex flex-col gap-4", className)} {...props} />
+);
+DialogBody.displayName = "DialogBody";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex gap-3 flex-col-reverse sm:flex-row sm:justify-end p-6 py-5 border-t",
+ className,
+ )}
+ {...props}
+ />
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Title
+ ref={ref}
+ className={cn(
+ "text-lg leading-none font-semibold tracking-tight",
+ className,
+ )}
+ {...props}
+ />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef<typeof DialogPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <DialogPrimitive.Description
+ ref={ref}
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogTrigger,
+ DialogClose,
+ DialogContent,
+ DialogHeader,
+ DialogBody,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 91913e25..62e69cf1 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -1,293 +1,293 @@ -"use client"; - -import * as React from "react"; -import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; -import { Check, ChevronRight, Circle } from "lucide-react"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/utils/ui"; -import { useOverlayOpenChange } from "./use-overlay-open-change"; - -function DropdownMenu({ - open, - onOpenChange, - ...props -}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) { - const handleOpenChange = useOverlayOpenChange({ - source: "dropdown-menu", - open, - onOpenChange, - }); - return ( - <DropdownMenuPrimitive.Root - open={open} - onOpenChange={handleOpenChange} - {...props} - /> - ); -} - -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; - -const DropdownMenuGroup = DropdownMenuPrimitive.Group; - -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; - -const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const dropdownMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "", - destructive: - "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { - inset?: boolean; - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } ->(({ className, inset, children, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.SubTrigger - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - "data-[state=open]:bg-muted data-[state=open]:text-foreground", - inset && "pl-8", - className, - )} - {...props} - > - {children} - <ChevronRight className="ml-auto" /> - </DropdownMenuPrimitive.SubTrigger> -)); -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> ->(({ className, ...props }, ref) => ( - <DropdownMenuPrimitive.SubContent - ref={ref} - className={cn( - "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg", - className, - )} - {...props} - /> -)); -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> ->(({ className, sideOffset = 4, ...props }, ref) => ( - <DropdownMenuPrimitive.Portal> - <DropdownMenuPrimitive.Content - ref={ref} - sideOffset={sideOffset} - onCloseAutoFocus={(e) => { - e.stopPropagation(); - e.preventDefault(); - }} - className={cn( - "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg", - className, - )} - {...props} - /> - </DropdownMenuPrimitive.Portal> -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { - inset?: boolean; - icon?: React.ReactNode; - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } ->( - ( - { - className, - inset, - icon, - variant = "default", - children, - asChild, - ...props - }, - ref, - ) => { - const iconSlot = ( - <span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex"> - {icon} - </span> - ); - - const renderedChildren = - asChild && React.isValidElement(children) ? ( - React.cloneElement( - children as React.ReactElement<{ children?: React.ReactNode }>, - {}, - iconSlot, - (children as React.ReactElement<{ children?: React.ReactNode }>).props - .children, - ) - ) : ( - <> - {iconSlot} - {children} - </> - ); - - return ( - <DropdownMenuPrimitive.Item - ref={ref} - asChild={asChild} - data-has-icon={icon ? "" : undefined} - className={cn( - dropdownMenuItemVariants({ variant }), - inset && "pl-8", - className, - )} - {...props} - > - {renderedChildren} - </DropdownMenuPrimitive.Item> - ); - }, -); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & { - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } ->(({ className, children, checked, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.CheckboxItem - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - "pr-8 pl-2", - className, - )} - checked={checked} - onSelect={(e) => { - e.preventDefault(); - }} - {...props} - > - {children} - <span className="absolute right-2 flex size-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <Check className="size-4" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - </DropdownMenuPrimitive.CheckboxItem> -)); - -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & { - variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"]; - } ->(({ className, children, variant = "default", ...props }, ref) => ( - <DropdownMenuPrimitive.RadioItem - ref={ref} - className={cn( - dropdownMenuItemVariants({ variant }), - "pr-2 pl-8", - className, - )} - {...props} - > - <span className="absolute left-2 flex size-3.5 items-center justify-center"> - <DropdownMenuPrimitive.ItemIndicator> - <Circle className="size-2 fill-current" /> - </DropdownMenuPrimitive.ItemIndicator> - </span> - {children} - </DropdownMenuPrimitive.RadioItem> -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - <DropdownMenuPrimitive.Label - ref={ref} - className={cn( - "px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground", - inset && "pl-8", - className, - )} - {...props} - /> -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef<typeof DropdownMenuPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> ->(({ className, ...props }, ref) => ( - <DropdownMenuPrimitive.Separator - ref={ref} - className={cn("bg-border/60 mx-1 my-2 h-px", className)} - {...props} - /> -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes<HTMLSpanElement>) => { - return ( - <span - className={cn("ml-auto text-xs tracking-widest opacity-60", className)} - {...props} - /> - ); -}; -DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -}; +"use client";
+
+import * as React from "react";
+import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function DropdownMenu({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "dropdown-menu",
+ open,
+ onOpenChange,
+ });
+ return (
+ <DropdownMenuPrimitive.Root
+ open={open}
+ onOpenChange={handleOpenChange}
+ {...props}
+ />
+ );
+}
+
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
+
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
+
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
+
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+const dropdownMenuItemVariants = cva(
+ "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "",
+ destructive:
+ "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const DropdownMenuSubTrigger = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
+ inset?: boolean;
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(({ className, inset, children, variant = "default", ...props }, ref) => (
+ <DropdownMenuPrimitive.SubTrigger
+ ref={ref}
+ className={cn(
+ dropdownMenuItemVariants({ variant }),
+ "data-[state=open]:bg-muted data-[state=open]:text-foreground",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ <ChevronRight className="ml-auto" />
+ </DropdownMenuPrimitive.SubTrigger>
+));
+DropdownMenuSubTrigger.displayName =
+ DropdownMenuPrimitive.SubTrigger.displayName;
+
+const DropdownMenuSubContent = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
+>(({ className, ...props }, ref) => (
+ <DropdownMenuPrimitive.SubContent
+ ref={ref}
+ className={cn(
+ "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
+ className,
+ )}
+ {...props}
+ />
+));
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName;
+
+const DropdownMenuContent = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
+>(({ className, sideOffset = 4, ...props }, ref) => (
+ <DropdownMenuPrimitive.Portal>
+ <DropdownMenuPrimitive.Content
+ ref={ref}
+ sideOffset={sideOffset}
+ onCloseAutoFocus={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ className={cn(
+ "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg",
+ className,
+ )}
+ {...props}
+ />
+ </DropdownMenuPrimitive.Portal>
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
+
+const DropdownMenuItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
+ inset?: boolean;
+ icon?: React.ReactNode;
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(
+ (
+ {
+ className,
+ inset,
+ icon,
+ variant = "default",
+ children,
+ asChild,
+ ...props
+ },
+ ref,
+ ) => {
+ const iconSlot = (
+ <span className="hidden size-4 shrink-0 items-center justify-center group-has-data-has-icon/menu:flex">
+ {icon}
+ </span>
+ );
+
+ const renderedChildren =
+ asChild && React.isValidElement(children) ? (
+ React.cloneElement(
+ children as React.ReactElement<{ children?: React.ReactNode }>,
+ {},
+ iconSlot,
+ (children as React.ReactElement<{ children?: React.ReactNode }>).props
+ .children,
+ )
+ ) : (
+ <>
+ {iconSlot}
+ {children}
+ </>
+ );
+
+ return (
+ <DropdownMenuPrimitive.Item
+ ref={ref}
+ asChild={asChild}
+ data-has-icon={icon ? "" : undefined}
+ className={cn(
+ dropdownMenuItemVariants({ variant }),
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {renderedChildren}
+ </DropdownMenuPrimitive.Item>
+ );
+ },
+);
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
+
+const DropdownMenuCheckboxItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> & {
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(({ className, children, checked, variant = "default", ...props }, ref) => (
+ <DropdownMenuPrimitive.CheckboxItem
+ ref={ref}
+ className={cn(
+ dropdownMenuItemVariants({ variant }),
+ "pr-8 pl-2",
+ className,
+ )}
+ checked={checked}
+ onSelect={(e) => {
+ e.preventDefault();
+ }}
+ {...props}
+ >
+ {children}
+ <span className="absolute right-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Check className="size-4" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ </DropdownMenuPrimitive.CheckboxItem>
+));
+
+DropdownMenuCheckboxItem.displayName =
+ DropdownMenuPrimitive.CheckboxItem.displayName;
+
+const DropdownMenuRadioItem = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> & {
+ variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
+ }
+>(({ className, children, variant = "default", ...props }, ref) => (
+ <DropdownMenuPrimitive.RadioItem
+ ref={ref}
+ className={cn(
+ dropdownMenuItemVariants({ variant }),
+ "pr-2 pl-8",
+ className,
+ )}
+ {...props}
+ >
+ <span className="absolute left-2 flex size-3.5 items-center justify-center">
+ <DropdownMenuPrimitive.ItemIndicator>
+ <Circle className="size-2 fill-current" />
+ </DropdownMenuPrimitive.ItemIndicator>
+ </span>
+ {children}
+ </DropdownMenuPrimitive.RadioItem>
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
+
+const DropdownMenuLabel = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
+ inset?: boolean;
+ }
+>(({ className, inset, ...props }, ref) => (
+ <DropdownMenuPrimitive.Label
+ ref={ref}
+ className={cn(
+ "px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ />
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
+
+const DropdownMenuSeparator = React.forwardRef<
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <DropdownMenuPrimitive.Separator
+ ref={ref}
+ className={cn("bg-border/60 mx-1 my-2 h-px", className)}
+ {...props}
+ />
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
+
+const DropdownMenuShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLSpanElement>) => {
+ return (
+ <span
+ className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
+ {...props}
+ />
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuGroup,
+ DropdownMenuPortal,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+};
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx index 77d8ab7f..016bbaf4 100644 --- a/apps/web/src/components/ui/font-picker.tsx +++ b/apps/web/src/components/ui/font-picker.tsx @@ -1,258 +1,258 @@ -"use client"; - -import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties } from "react"; -import { List, type RowComponentProps } from "react-window"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { loadFullFont } from "@/lib/fonts/google-fonts"; -import { SYSTEM_FONTS } from "@/constants/font-constants"; -import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types"; -import { useFontAtlas } from "@/hooks/use-font-atlas"; -import { cn } from "@/utils/ui"; -import { ChevronDown, Search } from "lucide-react"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { TextIcon } from "@hugeicons/core-free-icons"; - -const FONT_TABS = [ - { key: "all", label: "All fonts" }, - { key: "my-fonts", label: "My fonts" }, - { key: "favorites", label: "Favorites" }, -] as const; - -type FontTab = (typeof FONT_TABS)[number]["key"]; - -const ROW_HEIGHT = 40; -const PREVIEW_SCALE = 0.8; -const LIST_WIDTH = 288; -const MAX_LIST_HEIGHT = 288; -const OVERSCAN = 15; - -interface FontPickerProps { - defaultValue?: string; - onValueChange?: (value: string) => void; - className?: string; -} - -export function FontPicker({ - defaultValue, - onValueChange, - className, -}: FontPickerProps) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const [activeTab, setActiveTab] = useState<FontTab>("all"); - const searchInputRef = useRef<HTMLInputElement>(null); - const { atlas, status, fontNames, retry: handleRetry } = useFontAtlas({ open }); - - const filteredFonts = useMemo(() => { - if (!search) return fontNames; - const query = search.toLowerCase(); - return fontNames.filter((name) => name.toLowerCase().includes(query)); - }, [fontNames, search]); - - const listHeight = Math.min( - MAX_LIST_HEIGHT, - filteredFonts.length * ROW_HEIGHT, - ); - - const handleSelect = useCallback( - async ({ family }: { family: string }) => { - if (!SYSTEM_FONTS.has(family)) { - try { - await loadFullFont({ family }); - } catch { - // ignore load failure, font will fall back to system default - } - } - onValueChange?.(family); - setOpen(false); - }, - [onValueChange], - ); - - useEffect(() => { - if (!open) { - setSearch(""); - setActiveTab("all"); - } - }, [open]); - - const activeTabLabel = - FONT_TABS.find((t) => t.key === activeTab)?.label.toLowerCase() ?? ""; - - return ( - <Popover open={open} onOpenChange={setOpen}> - <PopoverTrigger - className={cn( - "border-border bg-accent flex h-7 w-full cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap focus-visible:border-primary focus-visible:ring-0 focus:outline-hidden", - className, - )} - > - <div className="flex min-w-0 items-center gap-1.5"> - <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> - <HugeiconsIcon icon={TextIcon} /> - </span> - <span className="truncate" style={{ fontFamily: defaultValue }}> - {defaultValue ?? "Select a font"} - </span> - </div> - <ChevronDown className="size-3 shrink-0 opacity-50" /> - </PopoverTrigger> - <PopoverContent - className="w-72 p-0 overflow-hidden" - align="start" - side="left" - onOpenAutoFocus={(event) => { - event.preventDefault(); - searchInputRef.current?.focus(); - }} - onCloseAutoFocus={(event) => { - event.preventDefault(); - event.stopPropagation(); - }} - > - <div className="relative px-3 py-1.5"> - <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" /> - <Input - ref={searchInputRef} - placeholder={`Search ${activeTabLabel}...`} - value={search} - onChange={(event) => setSearch(event.target.value)} - size="xs" - className="w-full pl-5 bg-transparent border-none! shadow-none!" - /> - </div> - <div className="flex border-b px-3"> - {FONT_TABS.map((tab) => ( - <button - key={tab.key} - type="button" - className={cn( - "px-3 py-1.5 text-xs border-b-2 -mb-px", - activeTab === tab.key - ? "border-foreground text-foreground" - : "border-transparent text-muted-foreground hover:text-foreground", - )} - onClick={() => setActiveTab(tab.key)} - > - {tab.label} - </button> - ))} - </div> - {status === "loading" && ( - <div className="py-8 text-center text-sm text-muted-foreground"> - Loading fonts... - </div> - )} - {status === "error" && ( - <div className="flex flex-col items-center gap-3 py-8 px-4"> - <p className="text-sm text-muted-foreground text-center"> - Failed to load font previews. - </p> - <Button variant="outline" size="sm" onClick={handleRetry}> - Retry - </Button> - </div> - )} - {status === "idle" && - fontNames.length > 0 && - filteredFonts.length === 0 && ( - <div className="py-6 text-center text-sm text-muted-foreground"> - No fonts found. - </div> - )} - {status === "idle" && atlas && filteredFonts.length > 0 && ( - <List - rowCount={filteredFonts.length} - rowHeight={ROW_HEIGHT} - overscanCount={OVERSCAN} - rowComponent={FontRow} - rowProps={{ - atlas, - filteredFonts, - selectedFont: defaultValue, - onFontSelect: handleSelect, - }} - style={{ height: listHeight, width: LIST_WIDTH }} - /> - )} - </PopoverContent> - </Popover> - ); -} - -function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) { - return ( - <div - className="shrink-0" - style={{ - width: entry.w, - height: ROW_HEIGHT, - backgroundColor: "currentColor", - WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`, - WebkitMaskPosition: `-${entry.x}px -${entry.y}px`, - WebkitMaskRepeat: "no-repeat", - maskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`, - maskPosition: `-${entry.x}px -${entry.y}px`, - maskRepeat: "no-repeat", - transform: `scale(${PREVIEW_SCALE})`, - transformOrigin: "left center", - }} - /> - ); -} - -type FontRowProps = { - atlas: FontAtlas; - filteredFonts: string[]; - selectedFont: string | undefined; - onFontSelect: (params: { family: string }) => void; -}; - -function FontRow({ - index, - style, - atlas, - filteredFonts, - selectedFont, - onFontSelect, -}: RowComponentProps<FontRowProps>) { - const fontName = filteredFonts[index]; - const entry = atlas.fonts[fontName]; - const isSelected = fontName === selectedFont; - const isSystemFont = SYSTEM_FONTS.has(fontName); - - return ( - <button - type="button" - style={style as CSSProperties} - className={cn( - "flex w-full cursor-pointer items-center gap-2 px-3 outline-hidden hover:bg-popover-hover", - isSelected && "bg-popover-hover", - )} - onClick={() => onFontSelect({ family: fontName })} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onFontSelect({ family: fontName }); - } - }} - aria-label={fontName} - > - <div className="min-w-0 overflow-hidden"> - {isSystemFont ? ( - <span className="text-xl text-foreground/85" style={{ fontFamily: fontName }}> - {fontName} - </span> - ) : ( - <FontSpritePreview entry={entry} /> - )} - </div> - </button> - ); -} +"use client";
+
+import { useState, useMemo, useRef, useEffect, useCallback, type CSSProperties } from "react";
+import { List, type RowComponentProps } from "react-window";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { loadFullFont } from "@/lib/fonts/google-fonts";
+import { SYSTEM_FONTS } from "@/constants/font-constants";
+import type { FontAtlas, FontAtlasEntry } from "@/lib/fonts/types";
+import { useFontAtlas } from "@/hooks/use-font-atlas";
+import { cn } from "@/utils/ui";
+import { ChevronDown, Search } from "lucide-react";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { TextIcon } from "@hugeicons/core-free-icons";
+
+const FONT_TABS = [
+ { key: "all", label: "All fonts" },
+ { key: "my-fonts", label: "My fonts" },
+ { key: "favorites", label: "Favorites" },
+] as const;
+
+type FontTab = (typeof FONT_TABS)[number]["key"];
+
+const ROW_HEIGHT = 40;
+const PREVIEW_SCALE = 0.8;
+const LIST_WIDTH = 288;
+const MAX_LIST_HEIGHT = 288;
+const OVERSCAN = 15;
+
+interface FontPickerProps {
+ defaultValue?: string;
+ onValueChange?: (value: string) => void;
+ className?: string;
+}
+
+export function FontPicker({
+ defaultValue,
+ onValueChange,
+ className,
+}: FontPickerProps) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const [activeTab, setActiveTab] = useState<FontTab>("all");
+ const searchInputRef = useRef<HTMLInputElement>(null);
+ const { atlas, status, fontNames, retry: handleRetry } = useFontAtlas({ open });
+
+ const filteredFonts = useMemo(() => {
+ if (!search) return fontNames;
+ const query = search.toLowerCase();
+ return fontNames.filter((name) => name.toLowerCase().includes(query));
+ }, [fontNames, search]);
+
+ const listHeight = Math.min(
+ MAX_LIST_HEIGHT,
+ filteredFonts.length * ROW_HEIGHT,
+ );
+
+ const handleSelect = useCallback(
+ async ({ family }: { family: string }) => {
+ if (!SYSTEM_FONTS.has(family)) {
+ try {
+ await loadFullFont({ family });
+ } catch {
+ // ignore load failure, font will fall back to system default
+ }
+ }
+ onValueChange?.(family);
+ setOpen(false);
+ },
+ [onValueChange],
+ );
+
+ useEffect(() => {
+ if (!open) {
+ setSearch("");
+ setActiveTab("all");
+ }
+ }, [open]);
+
+ const activeTabLabel =
+ FONT_TABS.find((t) => t.key === activeTab)?.label.toLowerCase() ?? "";
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger
+ className={cn(
+ "border-border bg-accent flex h-7 w-full cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap focus-visible:border-primary focus-visible:ring-0 focus:outline-hidden",
+ className,
+ )}
+ >
+ <div className="flex min-w-0 items-center gap-1.5">
+ <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
+ <HugeiconsIcon icon={TextIcon} />
+ </span>
+ <span className="truncate" style={{ fontFamily: defaultValue }}>
+ {defaultValue ?? "Select a font"}
+ </span>
+ </div>
+ <ChevronDown className="size-3 shrink-0 opacity-50" />
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-72 p-0 overflow-hidden"
+ align="start"
+ side="left"
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ searchInputRef.current?.focus();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+ <div className="relative px-3 py-1.5">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" />
+ <Input
+ ref={searchInputRef}
+ placeholder={`Search ${activeTabLabel}...`}
+ value={search}
+ onChange={(event) => setSearch(event.target.value)}
+ size="xs"
+ className="w-full pl-5 bg-transparent border-none! shadow-none!"
+ />
+ </div>
+ <div className="flex border-b px-3">
+ {FONT_TABS.map((tab) => (
+ <button
+ key={tab.key}
+ type="button"
+ className={cn(
+ "px-3 py-1.5 text-xs border-b-2 -mb-px",
+ activeTab === tab.key
+ ? "border-foreground text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground",
+ )}
+ onClick={() => setActiveTab(tab.key)}
+ >
+ {tab.label}
+ </button>
+ ))}
+ </div>
+ {status === "loading" && (
+ <div className="py-8 text-center text-sm text-muted-foreground">
+ Loading fonts...
+ </div>
+ )}
+ {status === "error" && (
+ <div className="flex flex-col items-center gap-3 py-8 px-4">
+ <p className="text-sm text-muted-foreground text-center">
+ Failed to load font previews.
+ </p>
+ <Button variant="outline" size="sm" onClick={handleRetry}>
+ Retry
+ </Button>
+ </div>
+ )}
+ {status === "idle" &&
+ fontNames.length > 0 &&
+ filteredFonts.length === 0 && (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ No fonts found.
+ </div>
+ )}
+ {status === "idle" && atlas && filteredFonts.length > 0 && (
+ <List
+ rowCount={filteredFonts.length}
+ rowHeight={ROW_HEIGHT}
+ overscanCount={OVERSCAN}
+ rowComponent={FontRow}
+ rowProps={{
+ atlas,
+ filteredFonts,
+ selectedFont: defaultValue,
+ onFontSelect: handleSelect,
+ }}
+ style={{ height: listHeight, width: LIST_WIDTH }}
+ />
+ )}
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) {
+ return (
+ <div
+ className="shrink-0"
+ style={{
+ width: entry.w,
+ height: ROW_HEIGHT,
+ backgroundColor: "currentColor",
+ WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
+ WebkitMaskPosition: `-${entry.x}px -${entry.y}px`,
+ WebkitMaskRepeat: "no-repeat",
+ maskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
+ maskPosition: `-${entry.x}px -${entry.y}px`,
+ maskRepeat: "no-repeat",
+ transform: `scale(${PREVIEW_SCALE})`,
+ transformOrigin: "left center",
+ }}
+ />
+ );
+}
+
+type FontRowProps = {
+ atlas: FontAtlas;
+ filteredFonts: string[];
+ selectedFont: string | undefined;
+ onFontSelect: (params: { family: string }) => void;
+};
+
+function FontRow({
+ index,
+ style,
+ atlas,
+ filteredFonts,
+ selectedFont,
+ onFontSelect,
+}: RowComponentProps<FontRowProps>) {
+ const fontName = filteredFonts[index];
+ const entry = atlas.fonts[fontName];
+ const isSelected = fontName === selectedFont;
+ const isSystemFont = SYSTEM_FONTS.has(fontName);
+
+ return (
+ <button
+ type="button"
+ style={style as CSSProperties}
+ className={cn(
+ "flex w-full cursor-pointer items-center gap-2 px-3 outline-hidden hover:bg-popover-hover",
+ isSelected && "bg-popover-hover",
+ )}
+ onClick={() => onFontSelect({ family: fontName })}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onFontSelect({ family: fontName });
+ }
+ }}
+ aria-label={fontName}
+ >
+ <div className="min-w-0 overflow-hidden">
+ {isSystemFont ? (
+ <span className="text-xl text-foreground/85" style={{ fontFamily: fontName }}>
+ {fontName}
+ </span>
+ ) : (
+ <FontSpritePreview entry={entry} />
+ )}
+ </div>
+ </button>
+ );
+}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx index 1dce2f24..6be8e2bf 100644 --- a/apps/web/src/components/ui/input.tsx +++ b/apps/web/src/components/ui/input.tsx @@ -1,143 +1,143 @@ -"use client"; - -import { Eye, EyeOff, X } from "lucide-react"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/utils/ui"; -import { Button } from "./button"; -import { forwardRef, type ComponentProps } from "react"; -import { useState } from "react"; - -const inputVariants = cva( - "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - { - variants: { - variant: { - default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20", - outline: "bg-background", - destructive: - "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10", - }, - size: { - default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm", - xs: "h-7 px-3 text-xs file:h-6 file:text-xs", - sm: "h-7 px-3 text-sm file:h-6 file:text-xs", - lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm", - }, - }, - defaultVariants: { - size: "default", - variant: "default", - }, - }, -); - -interface InputProps - extends Omit<ComponentProps<"input">, "size">, - VariantProps<typeof inputVariants> { - showPassword?: boolean; - onShowPasswordChange?: (show: boolean) => void; - showClearIcon?: boolean; - onClear?: () => void; - containerClassName?: string; -} - -const Input = forwardRef<HTMLInputElement, InputProps>( - ( - { - className, - type, - size, - variant, - containerClassName, - showPassword, - onShowPasswordChange, - showClearIcon, - onClear, - value, - onFocus, - onBlur, - ...props - }, - ref, - ) => { - const [isFocused, setIsFocused] = useState(false); - - const isPassword = type === "password"; - const showPasswordToggle = isPassword && onShowPasswordChange; - const showClear = - showClearIcon && - onClear && - value && - String(value).length > 0 && - isFocused; - const inputType = isPassword && showPassword ? "text" : type; - - const hasIcons = showPasswordToggle || showClear; - const iconCount = Number(showPasswordToggle) + Number(showClear); - const paddingRight = - iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : ""; - - return ( - <div - className={cn(hasIcons ? "relative w-full" : "", containerClassName)} - > - <input - type={inputType} - className={cn( - inputVariants({ - size, - className: cn(paddingRight, className), - variant, - }), - )} - ref={ref} - value={value} - onFocus={(e) => { - setIsFocused(true); - onFocus?.(e); - }} - onBlur={(e) => { - setIsFocused(false); - onBlur?.(e); - }} - {...props} - /> - {showClear && ( - <Button - variant="text" - size="icon" - onMouseDown={(e) => { - e.preventDefault(); - onClear?.(); - }} - className="text-muted-foreground absolute top-0 right-0 h-full px-3 !opacity-100" - aria-label="Clear input" - > - <X className="!size-[0.85]" /> - </Button> - )} - {showPasswordToggle && ( - <Button - variant="text" - size="icon" - onClick={() => onShowPasswordChange?.(!showPassword)} - className={cn( - "text-muted-foreground hover:text-foreground absolute top-0 h-full px-3", - showClear ? "right-10" : "right-0", - )} - aria-label={showPassword ? "Hide password" : "Show password"} - > - {showPassword ? ( - <Eye className="size-4" /> - ) : ( - <EyeOff className="size-4" /> - )} - </Button> - )} - </div> - ); - }, -); -Input.displayName = "Input"; - -export { Input }; +"use client";
+
+import { Eye, EyeOff, X } from "lucide-react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { Button } from "./button";
+import { forwardRef, type ComponentProps } from "react";
+import { useState } from "react";
+
+const inputVariants = cva(
+ "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-offset-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20",
+ outline: "bg-background",
+ destructive:
+ "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
+ },
+ size: {
+ default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
+ xs: "h-7 px-3 text-xs file:h-6 file:text-xs",
+ sm: "h-7 px-3 text-sm file:h-6 file:text-xs",
+ lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
+ },
+ },
+ defaultVariants: {
+ size: "default",
+ variant: "default",
+ },
+ },
+);
+
+interface InputProps
+ extends Omit<ComponentProps<"input">, "size">,
+ VariantProps<typeof inputVariants> {
+ showPassword?: boolean;
+ onShowPasswordChange?: (show: boolean) => void;
+ showClearIcon?: boolean;
+ onClear?: () => void;
+ containerClassName?: string;
+}
+
+const Input = forwardRef<HTMLInputElement, InputProps>(
+ (
+ {
+ className,
+ type,
+ size,
+ variant,
+ containerClassName,
+ showPassword,
+ onShowPasswordChange,
+ showClearIcon,
+ onClear,
+ value,
+ onFocus,
+ onBlur,
+ ...props
+ },
+ ref,
+ ) => {
+ const [isFocused, setIsFocused] = useState(false);
+
+ const isPassword = type === "password";
+ const showPasswordToggle = isPassword && onShowPasswordChange;
+ const showClear =
+ showClearIcon &&
+ onClear &&
+ value &&
+ String(value).length > 0 &&
+ isFocused;
+ const inputType = isPassword && showPassword ? "text" : type;
+
+ const hasIcons = showPasswordToggle || showClear;
+ const iconCount = Number(showPasswordToggle) + Number(showClear);
+ const paddingRight =
+ iconCount === 2 ? "pr-20" : iconCount === 1 ? "pr-10" : "";
+
+ return (
+ <div
+ className={cn(hasIcons ? "relative w-full" : "", containerClassName)}
+ >
+ <input
+ type={inputType}
+ className={cn(
+ inputVariants({
+ size,
+ className: cn(paddingRight, className),
+ variant,
+ }),
+ )}
+ ref={ref}
+ value={value}
+ onFocus={(e) => {
+ setIsFocused(true);
+ onFocus?.(e);
+ }}
+ onBlur={(e) => {
+ setIsFocused(false);
+ onBlur?.(e);
+ }}
+ {...props}
+ />
+ {showClear && (
+ <Button
+ variant="text"
+ size="icon"
+ onMouseDown={(e) => {
+ e.preventDefault();
+ onClear?.();
+ }}
+ className="text-muted-foreground absolute top-0 right-0 h-full px-3 !opacity-100"
+ aria-label="Clear input"
+ >
+ <X className="!size-[0.85]" />
+ </Button>
+ )}
+ {showPasswordToggle && (
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => onShowPasswordChange?.(!showPassword)}
+ className={cn(
+ "text-muted-foreground hover:text-foreground absolute top-0 h-full px-3",
+ showClear ? "right-10" : "right-0",
+ )}
+ aria-label={showPassword ? "Hide password" : "Show password"}
+ >
+ {showPassword ? (
+ <Eye className="size-4" />
+ ) : (
+ <EyeOff className="size-4" />
+ )}
+ </Button>
+ )}
+ </div>
+ );
+ },
+);
+Input.displayName = "Input";
+
+export { Input };
diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx index acedff10..eae16b9b 100644 --- a/apps/web/src/components/ui/number-field.tsx +++ b/apps/web/src/components/ui/number-field.tsx @@ -1,318 +1,318 @@ -"use client"; - -import { cn } from "@/utils/ui"; -import { clamp } from "@/utils/math"; -import { useRef, useState, useLayoutEffect, type ComponentProps } from "react"; -import { useFocusLock } from "@/hooks/use-focus-lock"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons"; - -const SUFFIX_GAP_PX = 6; - -const DRAG_SENSITIVITIES = { - default: 1, - slow: 0.5, -} as const; - -type DragSensitivity = "default" | "slow"; - -type ScrubRange = { - from: number; - to: number; - pixelsPerUnit: number; -}; - -type ScrubClamp = { - min?: number; - max?: number; -}; - -function clampScrubValue({ - value, - min, - max, -}: { - value: number; - min?: number; - max?: number; -}): number { - if (min != null && max != null) return clamp({ value, min, max }); - if (min != null) return Math.max(min, value); - if (max != null) return Math.min(max, value); - return value; -} - -function getActiveRange({ - value, - direction, - ranges, -}: { - value: number; - direction: number; - ranges: readonly ScrubRange[]; -}): ScrubRange | undefined { - return ranges.find((range) => - direction > 0 - ? value >= range.from && value < range.to - : value > range.from && value <= range.to, - ); -} - -function scrubAcrossRanges({ - startValue, - pixelDelta, - ranges, - min, - max, -}: { - startValue: number; - pixelDelta: number; - ranges: readonly ScrubRange[]; - min?: number; - max?: number; -}): number { - let currentValue = clampScrubValue({ value: startValue, min, max }); - let remainingPixels = pixelDelta; - - while (remainingPixels !== 0) { - const direction = Math.sign(remainingPixels); - - const range = getActiveRange({ value: currentValue, direction, ranges }); - if (!range) break; - - const boundary = direction > 0 ? range.to : range.from; - const pixelsToBoundary = - Math.abs(boundary - currentValue) * range.pixelsPerUnit; - - if (Math.abs(remainingPixels) <= pixelsToBoundary) { - currentValue += remainingPixels / range.pixelsPerUnit; - break; - } - - currentValue = boundary; - remainingPixels -= direction * pixelsToBoundary; - } - - return clampScrubValue({ value: currentValue, min, max }); -} - -interface NumberFieldProps - extends Omit<ComponentProps<"input">, "size" | "type"> { - icon?: React.ReactNode; - suffix?: string; - suffixClassName?: string; - dragSensitivity?: DragSensitivity; - scrubRanges?: readonly ScrubRange[]; - scrubClamp?: ScrubClamp; - onScrub?: (value: number) => void; - onScrubEnd?: () => void; - allowExpressions?: boolean; - onReset?: () => void; - isDefault?: boolean; -} - -function NumberField({ - className, - icon, - suffix, - suffixClassName, - disabled, - dragSensitivity = "default", - scrubRanges, - scrubClamp, - onScrub, - onScrubEnd, - value, - allowExpressions = true, - onKeyDown, - onFocus, - onBlur, - onMouseDown, - onReset, - isDefault = false, - ref, - ...props -}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) { - const iconRef = useRef<HTMLButtonElement>(null); - const inputRef = useRef<HTMLInputElement>(null); - const ghostRef = useRef<HTMLSpanElement>(null); - const startValueRef = useRef(0); - const cumulativeDeltaRef = useRef(0); - const [isInputFocused, setIsInputFocused] = useState(false); - const [suffixLeft, setSuffixLeft] = useState(0); - const ghostValue = Array.isArray(value) ? value.join(", ") : String(value ?? ""); - - useLayoutEffect(() => { - if (!suffix) { - setSuffixLeft(0); - return; - } - if (!ghostRef.current || !inputRef.current) return; - if (ghostRef.current.textContent !== ghostValue) { - ghostRef.current.textContent = ghostValue; - } - const paddingLeft = - parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0; - setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth); - }, [ghostValue, suffix]); - - const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({ - isActive: isInputFocused, - onDismiss: () => inputRef.current?.blur(), - cursor: "text", - allowSelector: "input, textarea, [contenteditable]", - }); - - const handleIconPointerDown = (event: React.PointerEvent) => { - if (!onScrub || disabled || event.button !== 0) return; - const parsed = parseFloat(String(value ?? "0")); - startValueRef.current = Number.isNaN(parsed) ? 0 : parsed; - cumulativeDeltaRef.current = 0; - let hasReceivedFirstMove = false; - iconRef.current?.requestPointerLock(); - - const handlePointerMove = (moveEvent: PointerEvent) => { - // first movementX after pointer lock often contains a bogus warp delta - if (!hasReceivedFirstMove) { - hasReceivedFirstMove = true; - return; - } - cumulativeDeltaRef.current += moveEvent.movementX; - const newValue = scrubRanges - ? scrubAcrossRanges({ - startValue: startValueRef.current, - pixelDelta: cumulativeDeltaRef.current, - ranges: scrubRanges, - min: scrubClamp?.min, - max: scrubClamp?.max, - }) - : startValueRef.current + - cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity]; - onScrub(newValue); - }; - - const handlePointerUp = () => { - document.removeEventListener("pointermove", handlePointerMove); - document.removeEventListener("pointerup", handlePointerUp); - document.exitPointerLock(); - onScrubEnd?.(); - }; - - document.addEventListener("pointermove", handlePointerMove); - document.addEventListener("pointerup", handlePointerUp); - }; - - const canScrub = Boolean(icon && onScrub); - - const inputNode = ( - <input - type={allowExpressions ? "text" : "number"} - inputMode={allowExpressions ? "decimal" : undefined} - ref={inputRef} - disabled={disabled} - value={value} - className="text-sm leading-none bg-transparent outline-none min-w-0 flex-1 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none" - onMouseDown={(event) => { - const inputElement = event.currentTarget; - const shouldPreventNativeCaretPlacement = - event.button === 0 && document.activeElement !== inputElement; - if (shouldPreventNativeCaretPlacement) { - event.preventDefault(); - inputElement.focus(); - inputElement.select(); - } - onMouseDown?.(event); - }} - onFocus={(event) => { - setIsInputFocused(true); - event.currentTarget.select(); - onFocus?.(event); - }} - onKeyDown={(event) => { - const shouldBlurInput = event.key === "Enter" || event.key === "Escape"; - if (shouldBlurInput) event.currentTarget.blur(); - onKeyDown?.(event); - }} - onBlur={(event) => { - setIsInputFocused(false); - onBlur?.(event); - }} - {...props} - /> - ); - - return ( - <div - ref={wrapperRef} - className={cn( - "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none cursor-text disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", - disabled && "pointer-events-none cursor-not-allowed opacity-50", - className, - )} - > - {icon && - (canScrub ? ( - <button - ref={iconRef} - type="button" - aria-label="Drag to adjust value" - disabled={disabled} - className="text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none cursor-ew-resize" - onMouseDown={(event) => event.preventDefault()} - onPointerDown={handleIconPointerDown} - > - {icon} - </button> - ) : ( - <span className="text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none"> - {icon} - </span> - ))} - <span - className={cn( - "relative flex flex-1 min-w-0 items-center", - icon ? "px-1.5" : "pl-2.5", - onReset ? "pr-0" : "pr-2.5", - )} - > - {inputNode} - {suffix && ( - <> - {/* Ghost mirrors value text to measure width for suffix positioning */} - <span - ref={ghostRef} - className="invisible absolute text-sm leading-none whitespace-pre pointer-events-none" - aria-hidden="true" - > - {ghostValue} - </span> - <span - className={cn( - "absolute top-1/2 -translate-y-1/2 select-none pointer-events-none text-sm leading-none", - suffixClassName, - )} - style={{ left: suffixLeft + SUFFIX_GAP_PX }} - > - {suffix} - </span> - </> - )} - </span> - {onReset && !isDefault && ( - <div className="shrink-0 pr-2 flex items-center"> - <Button - variant="text" - size="text" - aria-label="Reset to default" - onClick={onReset} - > - <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="size-3.5!" /> - </Button> - </div> - )} - </div> - ); -} - -export { NumberField }; +"use client";
+
+import { cn } from "@/utils/ui";
+import { clamp } from "@/utils/math";
+import { useRef, useState, useLayoutEffect, type ComponentProps } from "react";
+import { useFocusLock } from "@/hooks/use-focus-lock";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons";
+
+const SUFFIX_GAP_PX = 6;
+
+const DRAG_SENSITIVITIES = {
+ default: 1,
+ slow: 0.5,
+} as const;
+
+type DragSensitivity = "default" | "slow";
+
+type ScrubRange = {
+ from: number;
+ to: number;
+ pixelsPerUnit: number;
+};
+
+type ScrubClamp = {
+ min?: number;
+ max?: number;
+};
+
+function clampScrubValue({
+ value,
+ min,
+ max,
+}: {
+ value: number;
+ min?: number;
+ max?: number;
+}): number {
+ if (min != null && max != null) return clamp({ value, min, max });
+ if (min != null) return Math.max(min, value);
+ if (max != null) return Math.min(max, value);
+ return value;
+}
+
+function getActiveRange({
+ value,
+ direction,
+ ranges,
+}: {
+ value: number;
+ direction: number;
+ ranges: readonly ScrubRange[];
+}): ScrubRange | undefined {
+ return ranges.find((range) =>
+ direction > 0
+ ? value >= range.from && value < range.to
+ : value > range.from && value <= range.to,
+ );
+}
+
+function scrubAcrossRanges({
+ startValue,
+ pixelDelta,
+ ranges,
+ min,
+ max,
+}: {
+ startValue: number;
+ pixelDelta: number;
+ ranges: readonly ScrubRange[];
+ min?: number;
+ max?: number;
+}): number {
+ let currentValue = clampScrubValue({ value: startValue, min, max });
+ let remainingPixels = pixelDelta;
+
+ while (remainingPixels !== 0) {
+ const direction = Math.sign(remainingPixels);
+
+ const range = getActiveRange({ value: currentValue, direction, ranges });
+ if (!range) break;
+
+ const boundary = direction > 0 ? range.to : range.from;
+ const pixelsToBoundary =
+ Math.abs(boundary - currentValue) * range.pixelsPerUnit;
+
+ if (Math.abs(remainingPixels) <= pixelsToBoundary) {
+ currentValue += remainingPixels / range.pixelsPerUnit;
+ break;
+ }
+
+ currentValue = boundary;
+ remainingPixels -= direction * pixelsToBoundary;
+ }
+
+ return clampScrubValue({ value: currentValue, min, max });
+}
+
+interface NumberFieldProps
+ extends Omit<ComponentProps<"input">, "size" | "type"> {
+ icon?: React.ReactNode;
+ suffix?: string;
+ suffixClassName?: string;
+ dragSensitivity?: DragSensitivity;
+ scrubRanges?: readonly ScrubRange[];
+ scrubClamp?: ScrubClamp;
+ onScrub?: (value: number) => void;
+ onScrubEnd?: () => void;
+ allowExpressions?: boolean;
+ onReset?: () => void;
+ isDefault?: boolean;
+}
+
+function NumberField({
+ className,
+ icon,
+ suffix,
+ suffixClassName,
+ disabled,
+ dragSensitivity = "default",
+ scrubRanges,
+ scrubClamp,
+ onScrub,
+ onScrubEnd,
+ value,
+ allowExpressions = true,
+ onKeyDown,
+ onFocus,
+ onBlur,
+ onMouseDown,
+ onReset,
+ isDefault = false,
+ ref,
+ ...props
+}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) {
+ const iconRef = useRef<HTMLButtonElement>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const ghostRef = useRef<HTMLSpanElement>(null);
+ const startValueRef = useRef(0);
+ const cumulativeDeltaRef = useRef(0);
+ const [isInputFocused, setIsInputFocused] = useState(false);
+ const [suffixLeft, setSuffixLeft] = useState(0);
+ const ghostValue = Array.isArray(value) ? value.join(", ") : String(value ?? "");
+
+ useLayoutEffect(() => {
+ if (!suffix) {
+ setSuffixLeft(0);
+ return;
+ }
+ if (!ghostRef.current || !inputRef.current) return;
+ if (ghostRef.current.textContent !== ghostValue) {
+ ghostRef.current.textContent = ghostValue;
+ }
+ const paddingLeft =
+ parseFloat(getComputedStyle(inputRef.current).paddingLeft) || 0;
+ setSuffixLeft(paddingLeft + ghostRef.current.offsetWidth);
+ }, [ghostValue, suffix]);
+
+ const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({
+ isActive: isInputFocused,
+ onDismiss: () => inputRef.current?.blur(),
+ cursor: "text",
+ allowSelector: "input, textarea, [contenteditable]",
+ });
+
+ const handleIconPointerDown = (event: React.PointerEvent) => {
+ if (!onScrub || disabled || event.button !== 0) return;
+ const parsed = parseFloat(String(value ?? "0"));
+ startValueRef.current = Number.isNaN(parsed) ? 0 : parsed;
+ cumulativeDeltaRef.current = 0;
+ let hasReceivedFirstMove = false;
+ iconRef.current?.requestPointerLock();
+
+ const handlePointerMove = (moveEvent: PointerEvent) => {
+ // first movementX after pointer lock often contains a bogus warp delta
+ if (!hasReceivedFirstMove) {
+ hasReceivedFirstMove = true;
+ return;
+ }
+ cumulativeDeltaRef.current += moveEvent.movementX;
+ const newValue = scrubRanges
+ ? scrubAcrossRanges({
+ startValue: startValueRef.current,
+ pixelDelta: cumulativeDeltaRef.current,
+ ranges: scrubRanges,
+ min: scrubClamp?.min,
+ max: scrubClamp?.max,
+ })
+ : startValueRef.current +
+ cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
+ onScrub(newValue);
+ };
+
+ const handlePointerUp = () => {
+ document.removeEventListener("pointermove", handlePointerMove);
+ document.removeEventListener("pointerup", handlePointerUp);
+ document.exitPointerLock();
+ onScrubEnd?.();
+ };
+
+ document.addEventListener("pointermove", handlePointerMove);
+ document.addEventListener("pointerup", handlePointerUp);
+ };
+
+ const canScrub = Boolean(icon && onScrub);
+
+ const inputNode = (
+ <input
+ type={allowExpressions ? "text" : "number"}
+ inputMode={allowExpressions ? "decimal" : undefined}
+ ref={inputRef}
+ disabled={disabled}
+ value={value}
+ className="text-sm leading-none bg-transparent outline-none min-w-0 flex-1 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ onMouseDown={(event) => {
+ const inputElement = event.currentTarget;
+ const shouldPreventNativeCaretPlacement =
+ event.button === 0 && document.activeElement !== inputElement;
+ if (shouldPreventNativeCaretPlacement) {
+ event.preventDefault();
+ inputElement.focus();
+ inputElement.select();
+ }
+ onMouseDown?.(event);
+ }}
+ onFocus={(event) => {
+ setIsInputFocused(true);
+ event.currentTarget.select();
+ onFocus?.(event);
+ }}
+ onKeyDown={(event) => {
+ const shouldBlurInput = event.key === "Enter" || event.key === "Escape";
+ if (shouldBlurInput) event.currentTarget.blur();
+ onKeyDown?.(event);
+ }}
+ onBlur={(event) => {
+ setIsInputFocused(false);
+ onBlur?.(event);
+ }}
+ {...props}
+ />
+ );
+
+ return (
+ <div
+ ref={wrapperRef}
+ className={cn(
+ "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none cursor-text disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
+ disabled && "pointer-events-none cursor-not-allowed opacity-50",
+ className,
+ )}
+ >
+ {icon &&
+ (canScrub ? (
+ <button
+ ref={iconRef}
+ type="button"
+ aria-label="Drag to adjust value"
+ disabled={disabled}
+ className="text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none cursor-ew-resize"
+ onMouseDown={(event) => event.preventDefault()}
+ onPointerDown={handleIconPointerDown}
+ >
+ {icon}
+ </button>
+ ) : (
+ <span className="text-muted-foreground [&_svg]:size-3.5! shrink-0 select-none pl-2.5 text-sm leading-none">
+ {icon}
+ </span>
+ ))}
+ <span
+ className={cn(
+ "relative flex flex-1 min-w-0 items-center",
+ icon ? "px-1.5" : "pl-2.5",
+ onReset ? "pr-0" : "pr-2.5",
+ )}
+ >
+ {inputNode}
+ {suffix && (
+ <>
+ {/* Ghost mirrors value text to measure width for suffix positioning */}
+ <span
+ ref={ghostRef}
+ className="invisible absolute text-sm leading-none whitespace-pre pointer-events-none"
+ aria-hidden="true"
+ >
+ {ghostValue}
+ </span>
+ <span
+ className={cn(
+ "absolute top-1/2 -translate-y-1/2 select-none pointer-events-none text-sm leading-none",
+ suffixClassName,
+ )}
+ style={{ left: suffixLeft + SUFFIX_GAP_PX }}
+ >
+ {suffix}
+ </span>
+ </>
+ )}
+ </span>
+ {onReset && !isDefault && (
+ <div className="shrink-0 pr-2 flex items-center">
+ <Button
+ variant="text"
+ size="text"
+ aria-label="Reset to default"
+ onClick={onReset}
+ >
+ <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="size-3.5!" />
+ </Button>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export { NumberField };
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index cf480c8c..5a0495fc 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -1,52 +1,52 @@ -"use client"; - -import * as React from "react"; -import { Popover as PopoverPrimitive } from "radix-ui"; -import { cn } from "@/utils/ui"; -import { useOverlayOpenChange } from "./use-overlay-open-change"; - -function Popover({ - open, - onOpenChange, - ...props -}: React.ComponentProps<typeof PopoverPrimitive.Root>) { - const handleOpenChange = useOverlayOpenChange({ - source: "popover", - open, - onOpenChange, - }); - return ( - <PopoverPrimitive.Root - open={open} - onOpenChange={handleOpenChange} - {...props} - /> - ); -} - -const PopoverTrigger = PopoverPrimitive.Trigger; - -const PopoverAnchor = PopoverPrimitive.Anchor; - -const PopoverClose = PopoverPrimitive.Close; - -const PopoverContent = React.forwardRef< - React.ElementRef<typeof PopoverPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> ->(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( - <PopoverPrimitive.Portal> - <PopoverPrimitive.Content - ref={ref} - align={align} - sideOffset={sideOffset} - className={cn( - "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden", - className, - )} - {...props} - /> - </PopoverPrimitive.Portal> -)); -PopoverContent.displayName = PopoverPrimitive.Content.displayName; - -export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose }; +"use client";
+
+import * as React from "react";
+import { Popover as PopoverPrimitive } from "radix-ui";
+import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function Popover({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "popover",
+ open,
+ onOpenChange,
+ });
+ return (
+ <PopoverPrimitive.Root
+ open={open}
+ onOpenChange={handleOpenChange}
+ {...props}
+ />
+ );
+}
+
+const PopoverTrigger = PopoverPrimitive.Trigger;
+
+const PopoverAnchor = PopoverPrimitive.Anchor;
+
+const PopoverClose = PopoverPrimitive.Close;
+
+const PopoverContent = React.forwardRef<
+ React.ElementRef<typeof PopoverPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
+>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+ <PopoverPrimitive.Portal>
+ <PopoverPrimitive.Content
+ ref={ref}
+ align={align}
+ sideOffset={sideOffset}
+ className={cn(
+ "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden",
+ className,
+ )}
+ {...props}
+ />
+ </PopoverPrimitive.Portal>
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
+
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose };
diff --git a/apps/web/src/components/ui/prose.tsx b/apps/web/src/components/ui/prose.tsx index 88d017fe..74bb14fd 100644 --- a/apps/web/src/components/ui/prose.tsx +++ b/apps/web/src/components/ui/prose.tsx @@ -1,111 +1,111 @@ -import { cn } from "@/utils/ui"; -import rehypeParse from "rehype-parse"; -import { unified } from "unified"; -import { createElement } from "react"; -import type React from "react"; - -type ProseProps = React.HTMLAttributes<HTMLElement> & { - as?: "article"; - html: string; -}; - -type HastTextNode = { - type: "text"; - value: string; -}; - -type HastElementNode = { - type: "element"; - tagName: string; - properties?: Record<string, unknown>; - children?: HastNode[]; -}; - -type HastRootNode = { - type: "root"; - children?: HastNode[]; -}; - -type HastNode = HastTextNode | HastElementNode | HastRootNode; - -function toReactProps({ - properties, -}: { - properties: HastElementNode["properties"]; -}): Record<string, unknown> { - if (!properties) { - return {}; - } - - const props: Record<string, unknown> = {}; - - for (const [propertyName, propertyValue] of Object.entries(properties)) { - if (propertyValue === null || propertyValue === undefined) { - continue; - } - - if (propertyName.startsWith("on")) { - continue; - } - - if (propertyName === "class") { - props.className = Array.isArray(propertyValue) - ? propertyValue.join(" ") - : propertyValue; - continue; - } - - if (propertyName === "for") { - props.htmlFor = propertyValue; - continue; - } - - props[propertyName] = propertyValue; - } - - return props; -} - -function renderHastNode({ - node, - key, -}: { - node: HastNode; - key: string; -}): React.ReactNode { - if (node.type === "text") { - return node.value; - } - - if (node.type !== "element") { - return null; - } - - const children = (node.children ?? []).map((childNode, index) => - renderHastNode({ node: childNode, key: `${key}-${index}` }), - ); - - return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children); -} - -function renderHtmlNodes({ html }: { html: string }): React.ReactNode { - const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode; - return (rootNode.children ?? []).map((childNode, index) => - renderHastNode({ node: childNode, key: `prose-node-${index}` }), - ); -} - -function Prose({ children, html, className }: ProseProps) { - return ( - <article - className={cn( - "prose prose-h2:font-semibold prose-h1:text-xl prose-a:text-blue-600 prose-p:first:mt-0 dark:prose-invert mx-auto max-w-none px-2", - className, - )} - > - {html ? renderHtmlNodes({ html }) : children} - </article> - ); -} - -export default Prose; +import { cn } from "@/utils/ui";
+import rehypeParse from "rehype-parse";
+import { unified } from "unified";
+import { createElement } from "react";
+import type React from "react";
+
+type ProseProps = React.HTMLAttributes<HTMLElement> & {
+ as?: "article";
+ html: string;
+};
+
+type HastTextNode = {
+ type: "text";
+ value: string;
+};
+
+type HastElementNode = {
+ type: "element";
+ tagName: string;
+ properties?: Record<string, unknown>;
+ children?: HastNode[];
+};
+
+type HastRootNode = {
+ type: "root";
+ children?: HastNode[];
+};
+
+type HastNode = HastTextNode | HastElementNode | HastRootNode;
+
+function toReactProps({
+ properties,
+}: {
+ properties: HastElementNode["properties"];
+}): Record<string, unknown> {
+ if (!properties) {
+ return {};
+ }
+
+ const props: Record<string, unknown> = {};
+
+ for (const [propertyName, propertyValue] of Object.entries(properties)) {
+ if (propertyValue === null || propertyValue === undefined) {
+ continue;
+ }
+
+ if (propertyName.startsWith("on")) {
+ continue;
+ }
+
+ if (propertyName === "class") {
+ props.className = Array.isArray(propertyValue)
+ ? propertyValue.join(" ")
+ : propertyValue;
+ continue;
+ }
+
+ if (propertyName === "for") {
+ props.htmlFor = propertyValue;
+ continue;
+ }
+
+ props[propertyName] = propertyValue;
+ }
+
+ return props;
+}
+
+function renderHastNode({
+ node,
+ key,
+}: {
+ node: HastNode;
+ key: string;
+}): React.ReactNode {
+ if (node.type === "text") {
+ return node.value;
+ }
+
+ if (node.type !== "element") {
+ return null;
+ }
+
+ const children = (node.children ?? []).map((childNode, index) =>
+ renderHastNode({ node: childNode, key: `${key}-${index}` }),
+ );
+
+ return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children);
+}
+
+function renderHtmlNodes({ html }: { html: string }): React.ReactNode {
+ const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode;
+ return (rootNode.children ?? []).map((childNode, index) =>
+ renderHastNode({ node: childNode, key: `prose-node-${index}` }),
+ );
+}
+
+function Prose({ children, html, className }: ProseProps) {
+ return (
+ <article
+ className={cn(
+ "prose prose-h2:font-semibold prose-h1:text-xl prose-a:text-blue-600 prose-p:first:mt-0 dark:prose-invert mx-auto max-w-none px-2",
+ className,
+ )}
+ >
+ {html ? renderHtmlNodes({ html }) : children}
+ </article>
+ );
+}
+
+export default Prose;
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index b7092b87..b53dada1 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -1,221 +1,221 @@ -"use client"; - -import * as React from "react"; -import { Select as SelectPrimitive } from "radix-ui"; -import { Check } from "lucide-react"; -import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/utils/ui"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { useOverlayOpenChange } from "./use-overlay-open-change"; - -function Select({ - open, - onOpenChange, - ...props -}: React.ComponentProps<typeof SelectPrimitive.Root>) { - const handleOpenChange = useOverlayOpenChange({ - source: "select", - open, - onOpenChange, - }); - return ( - <SelectPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} /> - ); -} - -const SelectGroup = SelectPrimitive.Group; - -const SelectValue = SelectPrimitive.Value; - -const selectItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "", - destructive: - "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -const selectTriggerVariants = cva( - "border-border ring-offset-background placeholder:text-muted-foreground flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap transition-none focus:border-primary focus:ring-0 focus:ring-primary/10 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", - { - variants: { - variant: { - default: "bg-accent", - outline: "bg-background hover:bg-accent/50", - }, - size: { - default: "", - sm: "rounded-sm", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - }, -); - -const SelectTrigger = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & - VariantProps<typeof selectTriggerVariants> & { - icon?: React.ReactNode; - } ->(({ className, children, icon, variant, size, ...props }, ref) => ( - <SelectPrimitive.Trigger - ref={ref} - className={cn(selectTriggerVariants({ variant, size }), className)} - {...props} - > - <div className="flex items-center gap-1.5"> - {icon && ( - <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0"> - {icon} - </span> - )} - {children} - </div> - <SelectPrimitive.Icon asChild> - <HugeiconsIcon icon={ArrowDownIcon} className="size-4" /> - </SelectPrimitive.Icon> - </SelectPrimitive.Trigger> -)); -SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; - -const SelectScrollUpButton = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.ScrollUpButton>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton> ->(({ className, ...props }, ref) => ( - <SelectPrimitive.ScrollUpButton - ref={ref} - className={cn( - "flex cursor-default items-center justify-center py-1", - className, - )} - {...props} - > - <HugeiconsIcon icon={ArrowUpIcon} className="size-4" /> - </SelectPrimitive.ScrollUpButton> -)); -SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; - -const SelectScrollDownButton = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.ScrollDownButton>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton> ->(({ className, ...props }, ref) => ( - <SelectPrimitive.ScrollDownButton - ref={ref} - className={cn( - "flex cursor-default items-center justify-center py-1", - className, - )} - {...props} - > - <HugeiconsIcon icon={ArrowDownIcon} className="size-4" /> - </SelectPrimitive.ScrollDownButton> -)); -SelectScrollDownButton.displayName = - SelectPrimitive.ScrollDownButton.displayName; - -const SelectContent = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> ->(({ className, children, position = "popper", ...props }, ref) => ( - <SelectPrimitive.Portal> - <SelectPrimitive.Content - ref={ref} - className={cn( - "bg-popover text-popover-foreground z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-hidden rounded-md border p-1 shadow-lg", - className, - )} - position={position} - onCloseAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - {...props} - > - <SelectScrollUpButton /> - <SelectPrimitive.Viewport - className={cn( - position === "popper" && - "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)", - )} - > - {children} - </SelectPrimitive.Viewport> - <SelectScrollDownButton /> - </SelectPrimitive.Content> - </SelectPrimitive.Portal> -)); -SelectContent.displayName = SelectPrimitive.Content.displayName; - -const SelectLabel = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Label>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label> ->(({ className, ...props }, ref) => ( - <SelectPrimitive.Label - ref={ref} - className={cn( - "px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground", - className, - )} - {...props} - /> -)); -SelectLabel.displayName = SelectPrimitive.Label.displayName; - -const SelectItem = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Item>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & { - variant?: VariantProps<typeof selectItemVariants>["variant"]; - } ->(({ className, children, variant = "default", ...props }, ref) => ( - <SelectPrimitive.Item - ref={ref} - className={cn(selectItemVariants({ variant }), "pl-6 pr-2", className)} - {...props} - > - <span className="absolute left-1.5 flex size-3.5 items-center justify-center"> - <SelectPrimitive.ItemIndicator> - <Check className="size-3.5" /> - </SelectPrimitive.ItemIndicator> - </span> - <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> - </SelectPrimitive.Item> -)); -SelectItem.displayName = SelectPrimitive.Item.displayName; - -const SelectSeparator = React.forwardRef< - React.ElementRef<typeof SelectPrimitive.Separator>, - React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator> ->(({ className, ...props }, ref) => ( - <SelectPrimitive.Separator - ref={ref} - className={cn("bg-border mx-1 my-1 h-px", className)} - {...props} - /> -)); -SelectSeparator.displayName = SelectPrimitive.Separator.displayName; - -export { - Select, - SelectGroup, - SelectValue, - SelectTrigger, - SelectContent, - SelectLabel, - SelectItem, - SelectSeparator, - SelectScrollUpButton, - SelectScrollDownButton, -}; +"use client";
+
+import * as React from "react";
+import { Select as SelectPrimitive } from "radix-ui";
+import { Check } from "lucide-react";
+import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function Select({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "select",
+ open,
+ onOpenChange,
+ });
+ return (
+ <SelectPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const selectItemVariants = cva(
+ "relative flex cursor-pointer select-none items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "",
+ destructive:
+ "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const selectTriggerVariants = cva(
+ "border-border ring-offset-background placeholder:text-muted-foreground flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap transition-none focus:border-primary focus:ring-0 focus:ring-primary/10 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
+ {
+ variants: {
+ variant: {
+ default: "bg-accent",
+ outline: "bg-background hover:bg-accent/50",
+ },
+ size: {
+ default: "",
+ sm: "rounded-sm",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
+ VariantProps<typeof selectTriggerVariants> & {
+ icon?: React.ReactNode;
+ }
+>(({ className, children, icon, variant, size, ...props }, ref) => (
+ <SelectPrimitive.Trigger
+ ref={ref}
+ className={cn(selectTriggerVariants({ variant, size }), className)}
+ {...props}
+ >
+ <div className="flex items-center gap-1.5">
+ {icon && (
+ <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
+ {icon}
+ </span>
+ )}
+ {children}
+ </div>
+ <SelectPrimitive.Icon asChild>
+ <HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
+ </SelectPrimitive.Icon>
+ </SelectPrimitive.Trigger>
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollUpButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className,
+ )}
+ {...props}
+ >
+ <HugeiconsIcon icon={ArrowUpIcon} className="size-4" />
+ </SelectPrimitive.ScrollUpButton>
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.ScrollDownButton
+ ref={ref}
+ className={cn(
+ "flex cursor-default items-center justify-center py-1",
+ className,
+ )}
+ {...props}
+ >
+ <HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
+ </SelectPrimitive.ScrollDownButton>
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
+>(({ className, children, position = "popper", ...props }, ref) => (
+ <SelectPrimitive.Portal>
+ <SelectPrimitive.Content
+ ref={ref}
+ className={cn(
+ "bg-popover text-popover-foreground z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
+ className,
+ )}
+ position={position}
+ onCloseAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ {...props}
+ >
+ <SelectScrollUpButton />
+ <SelectPrimitive.Viewport
+ className={cn(
+ position === "popper" &&
+ "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)",
+ )}
+ >
+ {children}
+ </SelectPrimitive.Viewport>
+ <SelectScrollDownButton />
+ </SelectPrimitive.Content>
+ </SelectPrimitive.Portal>
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Label>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Label
+ ref={ref}
+ className={cn(
+ "px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
+ className,
+ )}
+ {...props}
+ />
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
+ variant?: VariantProps<typeof selectItemVariants>["variant"];
+ }
+>(({ className, children, variant = "default", ...props }, ref) => (
+ <SelectPrimitive.Item
+ ref={ref}
+ className={cn(selectItemVariants({ variant }), "pl-6 pr-2", className)}
+ {...props}
+ >
+ <span className="absolute left-1.5 flex size-3.5 items-center justify-center">
+ <SelectPrimitive.ItemIndicator>
+ <Check className="size-3.5" />
+ </SelectPrimitive.ItemIndicator>
+ </span>
+ <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+ </SelectPrimitive.Item>
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef<typeof SelectPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <SelectPrimitive.Separator
+ ref={ref}
+ className={cn("bg-border mx-1 my-1 h-px", className)}
+ {...props}
+ />
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx index d56cd0ac..675e6b96 100644 --- a/apps/web/src/components/ui/sheet.tsx +++ b/apps/web/src/components/ui/sheet.tsx @@ -1,157 +1,157 @@ -"use client"; - -import * as React from "react"; -import { Dialog as SheetPrimitive } from "radix-ui"; -import { cva, type VariantProps } from "class-variance-authority"; -import { X } from "lucide-react"; -import { cn } from "@/utils/ui"; -import { useOverlayOpenChange } from "./use-overlay-open-change"; - -function Sheet({ - open, - onOpenChange, - ...props -}: React.ComponentProps<typeof SheetPrimitive.Root>) { - const handleOpenChange = useOverlayOpenChange({ - source: "sheet", - open, - onOpenChange, - }); - return ( - <SheetPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} /> - ); -} - -const SheetTrigger = SheetPrimitive.Trigger; - -const SheetClose = SheetPrimitive.Close; - -const SheetPortal = SheetPrimitive.Portal; - -const SheetOverlay = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Overlay>, - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay> ->(({ className, ...props }, ref) => ( - <SheetPrimitive.Overlay - className={cn( - "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-200 bg-black/50", - className, - )} - {...props} - ref={ref} - /> -)); -SheetOverlay.displayName = SheetPrimitive.Overlay.displayName; - -const sheetVariants = cva( - "fixed z-250 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out", - { - variants: { - side: { - top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", - bottom: - "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", - left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", - right: - "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", - }, - }, - defaultVariants: { - side: "right", - }, - }, -); - -interface SheetContentProps - extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>, - VariantProps<typeof sheetVariants> {} - -const SheetContent = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Content>, - SheetContentProps ->(({ side = "right", className, children, ...props }, ref) => ( - <SheetPortal> - <SheetOverlay /> - <SheetPrimitive.Content - ref={ref} - className={cn(sheetVariants({ side }), className)} - onOpenAutoFocus={(e) => { - e.preventDefault(); - e.stopPropagation(); - }} - {...props} - > - <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> - <X className="size-5" /> - <span className="sr-only">Close</span> - </SheetPrimitive.Close> - {children} - </SheetPrimitive.Content> - </SheetPortal> -)); -SheetContent.displayName = SheetPrimitive.Content.displayName; - -const SheetHeader = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col space-y-2 text-center sm:text-left", - className, - )} - {...props} - /> -); -SheetHeader.displayName = "SheetHeader"; - -const SheetFooter = ({ - className, - ...props -}: React.HTMLAttributes<HTMLDivElement>) => ( - <div - className={cn( - "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4", - className, - )} - {...props} - /> -); -SheetFooter.displayName = "SheetFooter"; - -const SheetTitle = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Title>, - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title> ->(({ className, ...props }, ref) => ( - <SheetPrimitive.Title - ref={ref} - className={cn("text-foreground text-lg font-semibold", className)} - {...props} - /> -)); -SheetTitle.displayName = SheetPrimitive.Title.displayName; - -const SheetDescription = React.forwardRef< - React.ElementRef<typeof SheetPrimitive.Description>, - React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description> ->(({ className, ...props }, ref) => ( - <SheetPrimitive.Description - ref={ref} - className={cn("text-muted-foreground text-sm", className)} - {...props} - /> -)); -SheetDescription.displayName = SheetPrimitive.Description.displayName; - -export { - Sheet, - SheetPortal, - SheetOverlay, - SheetTrigger, - SheetClose, - SheetContent, - SheetHeader, - SheetFooter, - SheetTitle, - SheetDescription, -}; +"use client";
+
+import * as React from "react";
+import { Dialog as SheetPrimitive } from "radix-ui";
+import { cva, type VariantProps } from "class-variance-authority";
+import { X } from "lucide-react";
+import { cn } from "@/utils/ui";
+import { useOverlayOpenChange } from "./use-overlay-open-change";
+
+function Sheet({
+ open,
+ onOpenChange,
+ ...props
+}: React.ComponentProps<typeof SheetPrimitive.Root>) {
+ const handleOpenChange = useOverlayOpenChange({
+ source: "sheet",
+ open,
+ onOpenChange,
+ });
+ return (
+ <SheetPrimitive.Root open={open} onOpenChange={handleOpenChange} {...props} />
+ );
+}
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = SheetPrimitive.Portal;
+
+const SheetOverlay = React.forwardRef<
+ React.ElementRef<typeof SheetPrimitive.Overlay>,
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+ <SheetPrimitive.Overlay
+ className={cn(
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-200 bg-black/50",
+ className,
+ )}
+ {...props}
+ ref={ref}
+ />
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ "fixed z-250 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ },
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
+ VariantProps<typeof sheetVariants> {}
+
+const SheetContent = React.forwardRef<
+ React.ElementRef<typeof SheetPrimitive.Content>,
+ SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+ <SheetPortal>
+ <SheetOverlay />
+ <SheetPrimitive.Content
+ ref={ref}
+ className={cn(sheetVariants({ side }), className)}
+ onOpenAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ {...props}
+ >
+ <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
+ <X className="size-5" />
+ <span className="sr-only">Close</span>
+ </SheetPrimitive.Close>
+ {children}
+ </SheetPrimitive.Content>
+ </SheetPortal>
+));
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+const SheetHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col space-y-2 text-center sm:text-left",
+ className,
+ )}
+ {...props}
+ />
+);
+SheetHeader.displayName = "SheetHeader";
+
+const SheetFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+ <div
+ className={cn(
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
+ className,
+ )}
+ {...props}
+ />
+);
+SheetFooter.displayName = "SheetFooter";
+
+const SheetTitle = React.forwardRef<
+ React.ElementRef<typeof SheetPrimitive.Title>,
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
+>(({ className, ...props }, ref) => (
+ <SheetPrimitive.Title
+ ref={ref}
+ className={cn("text-foreground text-lg font-semibold", className)}
+ {...props}
+ />
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = React.forwardRef<
+ React.ElementRef<typeof SheetPrimitive.Description>,
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
+>(({ className, ...props }, ref) => (
+ <SheetPrimitive.Description
+ ref={ref}
+ className={cn("text-muted-foreground text-sm", className)}
+ {...props}
+ />
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/apps/web/src/components/ui/slider.tsx b/apps/web/src/components/ui/slider.tsx index 700be644..aa14dfbd 100644 --- a/apps/web/src/components/ui/slider.tsx +++ b/apps/web/src/components/ui/slider.tsx @@ -1,30 +1,30 @@ -"use client"; - -import * as React from "react"; -import { Slider as SliderPrimitive } from "radix-ui"; - -import { cn } from "@/utils/ui"; - -const Slider = React.forwardRef< - React.ElementRef<typeof SliderPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & { - className?: string; - } ->(({ className, ...props }, ref) => ( - <SliderPrimitive.Root - ref={ref} - className={cn( - "relative flex w-full touch-none items-center select-none", - className, - )} - {...props} - > - <SliderPrimitive.Track className="bg-accent relative h-1.5 w-full grow overflow-hidden rounded-full"> - <SliderPrimitive.Range className="bg-primary absolute h-full" /> - </SliderPrimitive.Track> - <SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" /> - </SliderPrimitive.Root> -)); -Slider.displayName = SliderPrimitive.Root.displayName; - -export { Slider }; +"use client";
+
+import * as React from "react";
+import { Slider as SliderPrimitive } from "radix-ui";
+
+import { cn } from "@/utils/ui";
+
+const Slider = React.forwardRef<
+ React.ElementRef<typeof SliderPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> & {
+ className?: string;
+ }
+>(({ className, ...props }, ref) => (
+ <SliderPrimitive.Root
+ ref={ref}
+ className={cn(
+ "relative flex w-full touch-none items-center select-none",
+ className,
+ )}
+ {...props}
+ >
+ <SliderPrimitive.Track className="bg-accent relative h-1.5 w-full grow overflow-hidden rounded-full">
+ <SliderPrimitive.Range className="bg-primary absolute h-full" />
+ </SliderPrimitive.Track>
+ <SliderPrimitive.Thumb className="border-primary/50 bg-background focus-visible:ring-ring block size-4 rounded-full border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50" />
+ </SliderPrimitive.Root>
+));
+Slider.displayName = SliderPrimitive.Root.displayName;
+
+export { Slider };
diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index f0c8197a..980dcd42 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -1,35 +1,35 @@ -"use client"; - -import { useTheme } from "next-themes"; -import { Toaster as Sonner } from "sonner"; - -type ToasterProps = React.ComponentProps<typeof Sonner>; - -const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme(); - - return ( - <Sonner - theme={theme as ToasterProps["theme"]} - className="toaster group" - position="bottom-right" - offset={20} - toastOptions={{ - classNames: { - toast: - "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", - description: "group-[.toast]:text-muted-foreground", - actionButton: - "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", - cancelButton: - "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", - }, - }} - expand={false} - richColors - {...props} - /> - ); -}; - -export { Toaster }; +"use client";
+
+import { useTheme } from "next-themes";
+import { Toaster as Sonner } from "sonner";
+
+type ToasterProps = React.ComponentProps<typeof Sonner>;
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+ <Sonner
+ theme={theme as ToasterProps["theme"]}
+ className="toaster group"
+ position="bottom-right"
+ offset={20}
+ toastOptions={{
+ classNames: {
+ toast:
+ "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
+ description: "group-[.toast]:text-muted-foreground",
+ actionButton:
+ "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
+ cancelButton:
+ "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
+ },
+ }}
+ expand={false}
+ richColors
+ {...props}
+ />
+ );
+};
+
+export { Toaster };
diff --git a/apps/web/src/components/ui/spinner.tsx b/apps/web/src/components/ui/spinner.tsx index 5546d539..3722a218 100644 --- a/apps/web/src/components/ui/spinner.tsx +++ b/apps/web/src/components/ui/spinner.tsx @@ -1,17 +1,17 @@ -import { HugeiconsIcon, type HugeiconsIconProps } from "@hugeicons/react"; -import { Loading03Icon } from "@hugeicons/core-free-icons"; -import { cn } from "@/utils/ui"; - -function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) { - return ( - <HugeiconsIcon - icon={Loading03Icon} - role="status" - aria-label="Loading" - className={cn("size-4 animate-spin", className)} - {...props} - /> - ); -} - -export { Spinner }; +import { HugeiconsIcon, type HugeiconsIconProps } from "@hugeicons/react";
+import { Loading03Icon } from "@hugeicons/core-free-icons";
+import { cn } from "@/utils/ui";
+
+function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) {
+ return (
+ <HugeiconsIcon
+ icon={Loading03Icon}
+ role="status"
+ aria-label="Loading"
+ className={cn("size-4 animate-spin", className)}
+ {...props}
+ />
+ );
+}
+
+export { Spinner };
diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx index dfc4cdfe..aa8b331a 100644 --- a/apps/web/src/components/ui/split-button.tsx +++ b/apps/web/src/components/ui/split-button.tsx @@ -1,90 +1,90 @@ -import { Button, type ButtonProps } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { type ReactNode, forwardRef } from "react"; -import { cn } from "@/utils/ui"; - -interface SplitButtonProps { - children: ReactNode; - className?: string; -} - -interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> { - children: ReactNode; -} - -const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>( - ({ children, className, ...props }, ref) => { - return ( - <div - ref={ref} - className={cn( - "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border", - className, - )} - {...props} - > - {children} - </div> - ); - }, -); -SplitButton.displayName = "SplitButton"; - -const SplitButtonSide = forwardRef< - HTMLButtonElement, - SplitButtonSideProps & { paddingClass: string } ->(({ children, className, paddingClass, onClick, ...props }, ref) => { - return ( - <Button - ref={ref} - variant="text" - className={cn( - "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100", - onClick - ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100" - : "cursor-default select-text", - paddingClass, - className, - )} - onClick={onClick} - {...props} - > - {typeof children === "string" ? ( - <span className="cursor-text font-normal">{children}</span> - ) : ( - children - )} - </Button> - ); -}); -SplitButtonSide.displayName = "SplitButtonSide"; - -const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>( - ({ ...props }, ref) => { - return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />; - }, -); -SplitButtonLeft.displayName = "SplitButtonLeft"; - -const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>( - ({ ...props }, ref) => { - return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />; - }, -); -SplitButtonRight.displayName = "SplitButtonRight"; - -const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>( - ({ className, ...props }, ref) => { - return ( - <Separator - ref={ref} - orientation="vertical" - className={cn("bg-foreground/15 h-full", className)} - {...props} - /> - ); - }, -); -SplitButtonSeparator.displayName = "SplitButtonSeparator"; - -export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator }; +import { Button, type ButtonProps } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { type ReactNode, forwardRef } from "react";
+import { cn } from "@/utils/ui";
+
+interface SplitButtonProps {
+ children: ReactNode;
+ className?: string;
+}
+
+interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
+ children: ReactNode;
+}
+
+const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
+ ({ children, className, ...props }, ref) => {
+ return (
+ <div
+ ref={ref}
+ className={cn(
+ "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ </div>
+ );
+ },
+);
+SplitButton.displayName = "SplitButton";
+
+const SplitButtonSide = forwardRef<
+ HTMLButtonElement,
+ SplitButtonSideProps & { paddingClass: string }
+>(({ children, className, paddingClass, onClick, ...props }, ref) => {
+ return (
+ <Button
+ ref={ref}
+ variant="text"
+ className={cn(
+ "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
+ onClick
+ ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
+ : "cursor-default select-text",
+ paddingClass,
+ className,
+ )}
+ onClick={onClick}
+ {...props}
+ >
+ {typeof children === "string" ? (
+ <span className="cursor-text font-normal">{children}</span>
+ ) : (
+ children
+ )}
+ </Button>
+ );
+});
+SplitButtonSide.displayName = "SplitButtonSide";
+
+const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
+ },
+);
+SplitButtonLeft.displayName = "SplitButtonLeft";
+
+const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
+ },
+);
+SplitButtonRight.displayName = "SplitButtonRight";
+
+const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
+ ({ className, ...props }, ref) => {
+ return (
+ <Separator
+ ref={ref}
+ orientation="vertical"
+ className={cn("bg-foreground/15 h-full", className)}
+ {...props}
+ />
+ );
+ },
+);
+SplitButtonSeparator.displayName = "SplitButtonSeparator";
+
+export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index c5cc02d7..e436e876 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -1,85 +1,85 @@ -"use client"; - -import * as React from "react"; -import { Tabs as TabsPrimitive } from "radix-ui"; - -import { cn } from "@/utils/ui"; - -type TabsVariant = "default" | "underline"; - -const TabsVariantContext = React.createContext<TabsVariant>("default"); - -const Tabs = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.Root>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & { - variant?: TabsVariant; - } ->(({ variant = "default", ...props }, ref) => ( - <TabsVariantContext.Provider value={variant}> - <TabsPrimitive.Root ref={ref} {...props} /> - </TabsVariantContext.Provider> -)); -Tabs.displayName = TabsPrimitive.Root.displayName; - -const TabsList = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.List>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> ->(({ className, ...props }, ref) => { - const variant = React.useContext(TabsVariantContext); - return ( - <TabsPrimitive.List - ref={ref} - className={cn( - "text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0", - variant === "default" && "rounded-lg", - variant === "underline" && "border-b border-border w-full gap-0 px-2", - className, - )} - {...props} - /> - ); -}); -TabsList.displayName = TabsPrimitive.List.displayName; - -const TabsTrigger = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.Trigger>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> ->(({ className, ...props }, ref) => { - const variant = React.useContext(TabsVariantContext); - return ( - <TabsPrimitive.Trigger - ref={ref} - className={cn( - "ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent", - variant === "default" && - "data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5", - variant === "underline" && - "text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px", - className, - )} - {...props} - /> - ); -}); -TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; - -const TabsContent = React.forwardRef< - React.ElementRef<typeof TabsPrimitive.Content>, - React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> ->(({ className, ...props }, ref) => { - const variant = React.useContext(TabsVariantContext); - return ( - <TabsPrimitive.Content - ref={ref} - className={cn( - "ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden", - variant === "underline" && "px-4", - className, - )} - {...props} - /> - ); -}); -TabsContent.displayName = TabsPrimitive.Content.displayName; - -export { Tabs, TabsList, TabsTrigger, TabsContent }; +"use client";
+
+import * as React from "react";
+import { Tabs as TabsPrimitive } from "radix-ui";
+
+import { cn } from "@/utils/ui";
+
+type TabsVariant = "default" | "underline";
+
+const TabsVariantContext = React.createContext<TabsVariant>("default");
+
+const Tabs = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Root> & {
+ variant?: TabsVariant;
+ }
+>(({ variant = "default", ...props }, ref) => (
+ <TabsVariantContext.Provider value={variant}>
+ <TabsPrimitive.Root ref={ref} {...props} />
+ </TabsVariantContext.Provider>
+));
+Tabs.displayName = TabsPrimitive.Root.displayName;
+
+const TabsList = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.List>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.List
+ ref={ref}
+ className={cn(
+ "text-muted-foreground inline-flex h-auto items-center gap-0 bg-transparent p-0",
+ variant === "default" && "rounded-lg",
+ variant === "underline" && "border-b border-border w-full gap-0 px-2",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+TabsList.displayName = TabsPrimitive.List.displayName;
+
+const TabsTrigger = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Trigger>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.Trigger
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center text-sm font-medium whitespace-nowrap focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 border border-transparent",
+ variant === "default" &&
+ "data-[state=active]:bg-secondary data-[state=active]:border-secondary-border data-[state=active]:text-secondary-foreground rounded-md px-2.5 h-6.5",
+ variant === "underline" &&
+ "text-muted-foreground data-[state=active]:text-primary border-b-2 border-transparent data-[state=active]:border-primary rounded-none px-3 py-2 -mb-px",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
+
+const TabsContent = React.forwardRef<
+ React.ElementRef<typeof TabsPrimitive.Content>,
+ React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
+>(({ className, ...props }, ref) => {
+ const variant = React.useContext(TabsVariantContext);
+ return (
+ <TabsPrimitive.Content
+ ref={ref}
+ className={cn(
+ "ring-offset-background focus-visible:ring-ring mt-4 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden",
+ variant === "underline" && "px-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+});
+TabsContent.displayName = TabsPrimitive.Content.displayName;
+
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx index 2ae59697..bc3056e8 100644 --- a/apps/web/src/components/ui/textarea.tsx +++ b/apps/web/src/components/ui/textarea.tsx @@ -1,24 +1,24 @@ -import * as React from "react"; - -import { cn } from "@/utils/ui"; - -const Textarea = React.forwardRef< - HTMLTextAreaElement, - React.ComponentProps<"textarea"> ->(({ className, ...props }, ref) => { - return ( - <textarea - className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", - "focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-primary/10", - "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", - className, - )} - ref={ref} - {...props} - /> - ); -}); -Textarea.displayName = "Textarea"; - -export { Textarea }; +import * as React from "react";
+
+import { cn } from "@/utils/ui";
+
+const Textarea = React.forwardRef<
+ HTMLTextAreaElement,
+ React.ComponentProps<"textarea">
+>(({ className, ...props }, ref) => {
+ return (
+ <textarea
+ className={cn(
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-primary/10",
+ "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ className,
+ )}
+ ref={ref}
+ {...props}
+ />
+ );
+});
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 2e7b53d4..b6902402 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -1,76 +1,76 @@ -import { cva, type VariantProps } from "class-variance-authority"; -import { Tooltip as TooltipPrimitive } from "radix-ui"; -import * as React from "react"; - -import { cn } from "@/utils/ui"; - -const TooltipProvider = TooltipPrimitive.Provider; - -const Tooltip = TooltipPrimitive.Root; - -const TooltipTrigger = TooltipPrimitive.Trigger; - -const tooltipVariants = cva( - "z-50 overflow-visible rounded-sm text-sm shadow-md", - { - variants: { - variant: { - default: "bg-popover text-popover-foreground border px-3 py-1.5", - destructive: - "bg-destructive/10 text-destructive dark:bg-destructive/20 border-destructive [border-width:0.5px]", - outline: "border-border", - important: - "bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]", - promotions: - "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]", - personal: - "bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]", - updates: - "bg-purple-100/90 text-purple-900 dark:bg-purple-900/20 dark:text-purple-300 border-purple-900 [border-width:0.5px]", - forums: - "bg-blue-100/90 text-blue-900 dark:bg-blue-900/20 dark:text-blue-300 border-blue-900 [border-width:0.5px]", - sidebar: "bg-white dark:bg-[#413F3E] p-2.5 flex flex-col gap-2", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -interface TooltipContentProps - extends React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>, - VariantProps<typeof tooltipVariants> {} - -const TooltipContent = React.forwardRef< - React.ElementRef<typeof TooltipPrimitive.Content>, - TooltipContentProps ->(({ className, sideOffset = 4, variant, ...props }, ref) => ( - <TooltipPrimitive.Content - ref={ref} - sideOffset={sideOffset} - className={cn(tooltipVariants({ variant }), className)} - {...props} - > - {variant === "sidebar" && ( - <svg - width="6" - height="10" - viewBox="0 0 6 10" - fill="none" - xmlns="http://www.w3.org/2000/svg" - className="absolute top-1/2 left-[-6px] -translate-y-1/2" - aria-hidden="true" - > - <path - d="M6 0L0 5L6 10V0Z" - className="fill-white/80 dark:fill-[#413F3E]" - /> - </svg> - )} - {props.children} - </TooltipPrimitive.Content> -)); -TooltipContent.displayName = TooltipPrimitive.Content.displayName; - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +import { cva, type VariantProps } from "class-variance-authority";
+import { Tooltip as TooltipPrimitive } from "radix-ui";
+import * as React from "react";
+
+import { cn } from "@/utils/ui";
+
+const TooltipProvider = TooltipPrimitive.Provider;
+
+const Tooltip = TooltipPrimitive.Root;
+
+const TooltipTrigger = TooltipPrimitive.Trigger;
+
+const tooltipVariants = cva(
+ "z-50 overflow-visible rounded-sm text-sm shadow-md",
+ {
+ variants: {
+ variant: {
+ default: "bg-popover text-popover-foreground border px-3 py-1.5",
+ destructive:
+ "bg-destructive/10 text-destructive dark:bg-destructive/20 border-destructive [border-width:0.5px]",
+ outline: "border-border",
+ important:
+ "bg-amber-100/90 text-amber-900 dark:bg-amber-900/20 dark:text-amber-300 border-amber-900 [border-width:0.5px]",
+ promotions:
+ "bg-red-100/90 text-redb-900 dark:bg-red-900/20 dark:text-red-300 border-red-900 [border-width:0.5px]",
+ personal:
+ "bg-green-100/90 text-green-900 dark:bg-green-900/20 dark:text-green-300 border-green-900 [border-width:0.5px]",
+ updates:
+ "bg-purple-100/90 text-purple-900 dark:bg-purple-900/20 dark:text-purple-300 border-purple-900 [border-width:0.5px]",
+ forums:
+ "bg-blue-100/90 text-blue-900 dark:bg-blue-900/20 dark:text-blue-300 border-blue-900 [border-width:0.5px]",
+ sidebar: "bg-white dark:bg-[#413F3E] p-2.5 flex flex-col gap-2",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+interface TooltipContentProps
+ extends React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>,
+ VariantProps<typeof tooltipVariants> {}
+
+const TooltipContent = React.forwardRef<
+ React.ElementRef<typeof TooltipPrimitive.Content>,
+ TooltipContentProps
+>(({ className, sideOffset = 4, variant, ...props }, ref) => (
+ <TooltipPrimitive.Content
+ ref={ref}
+ sideOffset={sideOffset}
+ className={cn(tooltipVariants({ variant }), className)}
+ {...props}
+ >
+ {variant === "sidebar" && (
+ <svg
+ width="6"
+ height="10"
+ viewBox="0 0 6 10"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ className="absolute top-1/2 left-[-6px] -translate-y-1/2"
+ aria-hidden="true"
+ >
+ <path
+ d="M6 0L0 5L6 10V0Z"
+ className="fill-white/80 dark:fill-[#413F3E]"
+ />
+ </svg>
+ )}
+ {props.children}
+ </TooltipPrimitive.Content>
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
diff --git a/apps/web/src/components/ui/use-overlay-open-change.ts b/apps/web/src/components/ui/use-overlay-open-change.ts index 165efc79..c5ef5331 100644 --- a/apps/web/src/components/ui/use-overlay-open-change.ts +++ b/apps/web/src/components/ui/use-overlay-open-change.ts @@ -1,78 +1,78 @@ -import { useCallback, useEffect, useId, useRef } from "react"; -import { useKeybindingsStore } from "@/stores/keybindings-store"; - -export function useOverlayOpenChange({ - source, - open, - onOpenChange, -}: { - source: string; - open?: boolean; - onOpenChange?: (open: boolean) => void; -}) { - const { openOverlay, closeOverlay } = useKeybindingsStore(); - const isTrackedRef = useRef(false); - const isControlled = typeof open === "boolean"; - const overlayId = useId(); - - useEffect(() => { - if (!isControlled) return; - - if (open && !isTrackedRef.current) { - openOverlay(overlayId, source); - isTrackedRef.current = true; - return; - } - - if (!open && isTrackedRef.current) { - closeOverlay(overlayId, source); - isTrackedRef.current = false; - } - }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]); - - useEffect(() => { - return () => { - if (!isTrackedRef.current) return; - // #region agent log - fetch( - "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7", - { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Debug-Session-Id": "3997d9", - }, - body: JSON.stringify({ - sessionId: "3997d9", - runId: "post-fix", - hypothesisId: "H2", - location: "use-overlay-open-change.ts:cleanup", - message: "Overlay closed during unmount cleanup", - data: { source, overlayId }, - timestamp: Date.now(), - }), - }, - ).catch(() => {}); - // #endregion - closeOverlay(overlayId, source); - isTrackedRef.current = false; - }; - }, [closeOverlay, overlayId, source]); - - return useCallback( - (nextOpen: boolean) => { - if (!isControlled) { - if (nextOpen && !isTrackedRef.current) { - openOverlay(overlayId, source); - isTrackedRef.current = true; - } else if (!nextOpen && isTrackedRef.current) { - closeOverlay(overlayId, source); - isTrackedRef.current = false; - } - } - - onOpenChange?.(nextOpen); - }, - [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source], - ); -} +import { useCallback, useEffect, useId, useRef } from "react";
+import { useKeybindingsStore } from "@/stores/keybindings-store";
+
+export function useOverlayOpenChange({
+ source,
+ open,
+ onOpenChange,
+}: {
+ source: string;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const { openOverlay, closeOverlay } = useKeybindingsStore();
+ const isTrackedRef = useRef(false);
+ const isControlled = typeof open === "boolean";
+ const overlayId = useId();
+
+ useEffect(() => {
+ if (!isControlled) return;
+
+ if (open && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ return;
+ }
+
+ if (!open && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }, [closeOverlay, isControlled, open, openOverlay, overlayId, source]);
+
+ useEffect(() => {
+ return () => {
+ if (!isTrackedRef.current) return;
+ // #region agent log
+ fetch(
+ "http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Debug-Session-Id": "3997d9",
+ },
+ body: JSON.stringify({
+ sessionId: "3997d9",
+ runId: "post-fix",
+ hypothesisId: "H2",
+ location: "use-overlay-open-change.ts:cleanup",
+ message: "Overlay closed during unmount cleanup",
+ data: { source, overlayId },
+ timestamp: Date.now(),
+ }),
+ },
+ ).catch(() => {});
+ // #endregion
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ };
+ }, [closeOverlay, overlayId, source]);
+
+ return useCallback(
+ (nextOpen: boolean) => {
+ if (!isControlled) {
+ if (nextOpen && !isTrackedRef.current) {
+ openOverlay(overlayId, source);
+ isTrackedRef.current = true;
+ } else if (!nextOpen && isTrackedRef.current) {
+ closeOverlay(overlayId, source);
+ isTrackedRef.current = false;
+ }
+ }
+
+ onOpenChange?.(nextOpen);
+ },
+ [closeOverlay, isControlled, onOpenChange, openOverlay, overlayId, source],
+ );
+}
|
