diff --git a/package-lock.json b/package-lock.json index 37e17ef0e..512b6a896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "plantuml-encoder": "^1.4.0", "prismjs": "^1.29.0", "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", "react-katex": "^3.0.1", "react-markdown": "^9.0.1", @@ -54,6 +55,7 @@ "@types/plantuml-encoder": "^1.4.2", "@types/prismjs": "^1.26.3", "@types/react": "^18.2.46", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.2.18", "@types/react-katex": "^3.0.4", "@types/react-timeago": "^4.1.7", @@ -1326,6 +1328,15 @@ "@types/unist": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -1395,6 +1406,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.8", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz", + "integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.2.18", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.18.tgz", @@ -1413,6 +1433,17 @@ "@types/react": "*" } }, + "node_modules/@types/react-redux": { + "version": "7.1.33", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.33.tgz", + "integrity": "sha512-NF8m5AjWCkert+fosDsN3hAlHzpjSiXlVy9EgQEmLoBhaNXbmyeGs/aj5dQzKuF+/q+S7JQagorGDW8pJ28Hmg==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "node_modules/@types/react-timeago": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/react-timeago/-/react-timeago-4.1.7.tgz", @@ -2226,6 +2257,14 @@ "node": ">= 8" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -4585,6 +4624,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5835,6 +5879,11 @@ } ] }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -5846,6 +5895,24 @@ "node": ">=0.10.0" } }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -5900,6 +5967,35 @@ "react": ">=18" } }, + "node_modules/react-redux": { + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", + "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "node_modules/react-resizable-panels": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-1.0.5.tgz", @@ -5952,6 +6048,14 @@ "string_decoder": "~0.10.x" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", @@ -6672,6 +6776,11 @@ "xtend": "~2.1.1" } }, + "node_modules/tiny-invariant": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", + "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -6994,6 +7103,14 @@ "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==", "dev": true }, + "node_modules/use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", diff --git a/package.json b/package.json index 2ca60d177..9a15ef9db 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "plantuml-encoder": "^1.4.0", "prismjs": "^1.29.0", "react": "^18.2.0", + "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", "react-katex": "^3.0.1", "react-markdown": "^9.0.1", @@ -58,6 +59,7 @@ "@types/plantuml-encoder": "^1.4.2", "@types/prismjs": "^1.26.3", "@types/react": "^18.2.46", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.2.18", "@types/react-katex": "^3.0.4", "@types/react-timeago": "^4.1.7", diff --git a/src/apps/chat/components/applayout/ChatDrawerItems.tsx b/src/apps/chat/components/applayout/ChatDrawerItems.tsx index 1cd079329..20e3f22bc 100644 --- a/src/apps/chat/components/applayout/ChatDrawerItems.tsx +++ b/src/apps/chat/components/applayout/ChatDrawerItems.tsx @@ -11,6 +11,7 @@ import { OpenAIIcon } from '~/common/components/icons/OpenAIIcon'; import { useOptimaDrawers } from '~/common/layout/optima/useOptimaDrawers'; import { useUIPreferencesStore } from '~/common/state/store-ui'; import { useUXLabsStore } from '~/common/state/store-ux-labs'; +import { useFolderStore } from '~/common/state/store-folders'; import { ChatNavigationItemMemo } from './ChatNavigationItem'; import { ChatFolderList } from './folder/ChatFolderList'; @@ -27,17 +28,21 @@ function ChatDrawerItems(props: { onConversationImportDialog: () => void, onConversationNew: () => void, onConversationsDeleteAll: () => void, + selectedFolderId: string | null, + setSelectedFolderId: (folderId: string | null) => void, }) { // local state const { onConversationDelete, onConversationNew, onConversationActivate } = props; // const [grouping] = React.useState('off'); + const { selectedFolderId, setSelectedFolderId } = props; // external state const { closeDrawer } = useOptimaDrawers(); const conversations = useChatStore(state => state.conversations, shallow); const showSymbols = useUIPreferencesStore(state => state.zenMode !== 'cleaner'); const labsEnhancedUI = useUXLabsStore(state => state.labsEnhancedUI); + const createFolder = useFolderStore((state) => state.createFolder); // derived state const maxChatMessages = conversations.reduce((longest, _c) => Math.max(longest, _c.messages.length), 1); @@ -62,6 +67,10 @@ function ChatDrawerItems(props: { !singleChat && conversationId && onConversationDelete(conversationId, true); }, [onConversationDelete, singleChat]); + const handleFolderSelect = (folderId: string | null) => { + // Logic to handle folder selection + setSelectedFolderId(folderId); + }; // grouping /*let sortedIds = conversationIDs; @@ -91,8 +100,12 @@ function ChatDrawerItems(props: { {/* */} {/**/} - - + state.folders)} + selectedFolderId={selectedFolderId} + conversationsByFolder={conversations} + /> diff --git a/src/apps/chat/components/applayout/folder/ChatFolderList.tsx b/src/apps/chat/components/applayout/folder/ChatFolderList.tsx index c303cdb65..2f0b9085f 100644 --- a/src/apps/chat/components/applayout/folder/ChatFolderList.tsx +++ b/src/apps/chat/components/applayout/folder/ChatFolderList.tsx @@ -2,26 +2,143 @@ import * as React from 'react'; import Sheet, { sheetClasses } from '@mui/joy/Sheet'; import Typography from '@mui/joy/Typography'; +import { List, ListItem, ListItemButton, ListItemContent, ListItemDecorator } from '@mui/joy'; +import OutlineFolderIcon from '@mui/icons-material/Folder'; +import { DragDropContext, Draggable, DropResult } from 'react-beautiful-dnd'; import { DFolder, useFolderStore } from '~/common/state/store-folders'; import { DConversation } from '~/common/state/store-chats'; -import { useState } from 'react'; import { AddFolderButton } from './AddFolderButton'; +import FolderListItem from './FolderListItem'; +import { StrictModeDroppable } from './StrictModeDroppable'; -export function ChatFolderList() { +export function ChatFolderList({ + onFolderSelect, + folders, + selectedFolderId, + }: { + onFolderSelect: (folderId: string | null) => void; + folders: DFolder[]; + selectedFolderId: string | null; + conversationsByFolder: DConversation[]; + }) { // local state // external state + const { moveFolder } = useFolderStore((state) => ({ + moveFolder: state.moveFolder, + })); + + // handlers + const onDragEnd = (result: DropResult) => { + if (!result.destination) return; + moveFolder(result.source.index, result.destination.index); + }; + return ( Folders -
Folder List
+ ({ + '& ul': { + '--List-gap': '0px', + bgcolor: 'background.surface', + '& > li:first-of-type > [role="button"]': { + borderTopRightRadius: 'var(--List-radius)', + borderTopLeftRadius: 'var(--List-radius)', + }, + '& > li:last-child > [role="button"]': { + borderBottomRightRadius: 'var(--List-radius)', + borderBottomLeftRadius: 'var(--List-radius)', + }, + }, + '--List-radius': '8px', + '--List-gap': '1rem', + '--ListDivider-gap': '0px', + '--ListItem-paddingY': '0.5rem', + // override global variant tokens + '--joy-palette-neutral-plainHoverBg': 'rgba(0 0 0 / 0.08)', + '--joy-palette-neutral-plainActiveBg': 'rgba(0 0 0 / 0.12)', + [theme.getColorSchemeSelector('light')]: { + '--joy-palette-divider': 'rgba(0 0 0 / 0.08)', + }, + [theme.getColorSchemeSelector('dark')]: { + '--joy-palette-neutral-plainHoverBg': 'rgba(255 255 255 / 0.1)', + '--joy-palette-neutral-plainActiveBg': 'rgba(255 255 255 / 0.16)', + }, + })} + > + + + ( + + )} + > + {(provided) => ( + + + { + event.stopPropagation(); // Prevent the ListItemButton's onClick from firing + onFolderSelect(null); + }} + selected={selectedFolderId === null} + sx={{ + justifyContent: 'space-between', + '&:hover .menu-icon': { + visibility: 'visible', // Hide delete icon for default folder + }, + }} + > + + + + + + All + + + + + {folders.map((folder, index) => ( + + {(provided, snapshot) => ( + + + + )} + + ))} + {provided.placeholder} + + )} + + + + +
); diff --git a/src/apps/chat/components/applayout/folder/FolderListItem.tsx b/src/apps/chat/components/applayout/folder/FolderListItem.tsx new file mode 100644 index 000000000..40d0796cb --- /dev/null +++ b/src/apps/chat/components/applayout/folder/FolderListItem.tsx @@ -0,0 +1,365 @@ +import React, { useState } from 'react'; +import { DraggableProvided, DraggableStateSnapshot } from 'react-beautiful-dnd'; +import { + ListItem, + ListItemButton, + ListItemDecorator, + ListItemContent, + Typography, + IconButton, + Dropdown, + Menu, + MenuButton, + MenuItem, + FormLabel, + RadioGroup, + Sheet, + Radio, + radioClasses, +} from '@mui/joy'; +import OutlineFolderIcon from '@mui/icons-material/Folder'; +import MoreVert from '@mui/icons-material/MoreVert'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import CloseIcon from '@mui/icons-material/Close'; +import Done from '@mui/icons-material/Done'; +import { DFolder, useFolderStore } from '~/common/state/store-folders'; +import { DraggingStyle, NotDraggingStyle } from 'react-beautiful-dnd'; + +// Define the type for your props if you're using TypeScript +type RenderItemProps = { + folder: DFolder; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; + onFolderSelect: (folderId: string | null) => void; + selectedFolderId: string | null; + // Include any other props that RenderItem needs +}; + +const FolderListItem: React.FC = ({ folder, provided, snapshot, onFolderSelect, selectedFolderId }) => { + // internal state + const [deleteArmed, setDeleteArmed] = useState(false); + const [deleteArmedFolderId, setDeleteArmedFolderId] = useState(null); + const [editingFolderId, setEditingFolderId] = useState(null); + const [editingFolderName, setEditingFolderName] = useState(''); + + // State to control the open state of the Menu + const [menuOpen, setMenuOpen] = useState(false); + + // external state + const { folders, moveFolder, updateFolderName, deleteFolder } = useFolderStore((state) => ({ + folders: state.folders, + moveFolder: state.moveFolder, + updateFolderName: state.updateFolderName, + deleteFolder: state.deleteFolder, + })); + + const { setFolderColor } = useFolderStore((state) => ({ + setFolderColor: state.setFolderColor, + })); + + const handleColorChange = (event: React.ChangeEvent) => { + setFolderColor(folder.id, event.target.value); + setMenuOpen(false); + }; + + // Handlers for editing and deleting + const handleEdit = (event: React.MouseEvent, folderId: string, folderTitle: string) => { + event.stopPropagation(); // Prevent the ListItemButton's onClick from firing + setEditingFolderId(folderId); + setEditingFolderName(folderTitle); + }; + + const handleSaveFolder = (folderId: string) => { + if (editingFolderName.trim() !== '') { + updateFolderName(folderId, editingFolderName.trim()); + } + setEditingFolderId(null); // Exit edit mode + // Blur the input element if it's currently focused + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + }; + + const handleInputChange = (event: React.ChangeEvent) => { + setEditingFolderName(event.target.value); + }; + + const handleInputKeyUp = (event: React.KeyboardEvent, folderId: string) => { + if (event.key === 'Enter') { + handleSaveFolder(folderId); + } else if (event.key === 'Escape') { + handleCancelEdit(); + } + }; + + const handleCancelEdit = () => { + setEditingFolderId(null); // Exit edit mode without saving + setEditingFolderName(''); // Reset editing name + }; + + // Modified handler to arm the delete action and keep the menu open + const handleDeleteButtonShow = (event: React.MouseEvent) => { + event.stopPropagation(); + setDeleteArmed(true); + setMenuOpen(true); // Keep the menu open + }; + + // Handler to close the menu + const handleCloseMenu = () => { + setMenuOpen(false); + setDeleteArmed(false); // Reset delete armed state + }; + + // Handler to disarm the delete action + const handleDeleteButtonHide = () => setDeleteArmed(false); + + // Handler to delete the folder + const handleDeleteConfirmed = (event: React.MouseEvent) => { + if (deleteArmed) { + setDeleteArmed(false); + event.stopPropagation(); + deleteFolder(folder.id); + setMenuOpen(false); + } + }; + + // Toggle the menu's open state + const toggleMenu = () => { + setMenuOpen(!menuOpen); + }; + + const getItemStyle = (isDragging: boolean, draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({ + userSelect: 'none', + borderRadius: '8px', + backgroundColor: isDragging ? 'rgba(0, 80, 80, 0.18)' : 'transparent', + + ...draggableStyle, + + // Any additional styles you want to apply during dragging + ...(isDragging && + { + // Apply any drag-specific styles here + // marginLeft: '12px', + }), + }); + + const getListItemContentStyle = (isDragging: boolean, draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({ + ...(isDragging && { + // Apply any drag-specific styles here + marginLeft: '20px', + }), + }); + + const getListItemDecoratorStyle = (isDragging: boolean, draggableStyle: DraggingStyle | NotDraggingStyle | undefined) => ({ + ...(isDragging && { + // Apply any drag-specific styles here + marginLeft: '12px', + }), + }); + + const handleFolderSelect = (folderId: string | null) => { + onFolderSelect(folderId); + }; + + return ( + + { + event.stopPropagation(); // Prevent the ListItemButton's onClick from firing + handleFolderSelect(folder.id); + }} + selected={folder.id === selectedFolderId} + sx={{ + justifyContent: 'space-between', + '&:hover .menu-icon': { + visibility: 'visible', // Hide delete icon for default folder + }, + }} + > + + + + + {editingFolderId === folder.id ? ( + handleInputKeyUp(event, folder.id)} + onBlur={() => handleSaveFolder(folder.id)} + autoFocus + style={{ + // Add styles for the input field + fontSize: 'inherit', + fontWeight: 'inherit', + color: 'inherit', + background: 'none', + border: 'none', + outline: 'none', + width: '100%', // Ensure the input field expands as needed + }} + /> + ) : ( + + {folder.title} + + )} + + + + + + + { + handleEdit(event, folder.id, folder.title); // Pass the folder's title here + handleCloseMenu(); + }} + > + + Edit + + {!deleteArmed ? ( + + + Delete + + ) : ( + <> + + + Confirm Delete + + + + Cancel + + + )} + + + + Color + + + {( + [ + '#ff0000', + '#ff8700', + '#ffd300', + '#deff0a', + '#a1ff0a', + '#8A0000', + '#8A3700', + '#8A5700', + '#7C6A05', + '#626906', + '#0aff99', + '#0aefff', + '#147df5', + '#580aff', + '#be0aff', + '#226D40', + '#22656D', + '#25346A', + '#440669', + '#6E0569', + ] as const + ).map((color, index) => ( + + } + value={color} + color="neutral" + slotProps={{ + input: { 'aria-label': color }, + radio: { + sx: { + display: 'contents', + '--variant-borderWidth': '2px', + }, + }, + }} + sx={{ + '--joy-focus-outlineOffset': '4px', + '--joy-palette-focusVisible': color, + [`& .${radioClasses.action}.${radioClasses.focusVisible}`]: { + outlineWidth: '2px', + }, + }} + /> + + ))} + + + + + + + ); +}; + +export default FolderListItem; diff --git a/src/apps/chat/components/applayout/folder/StrictModeDroppable.tsx b/src/apps/chat/components/applayout/folder/StrictModeDroppable.tsx new file mode 100644 index 000000000..d112be666 --- /dev/null +++ b/src/apps/chat/components/applayout/folder/StrictModeDroppable.tsx @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; +import { Droppable, DroppableProps } from "react-beautiful-dnd"; + +export const StrictModeDroppable = ({ children, ...props }: DroppableProps) => { + const [enabled, setEnabled] = useState(false); + + useEffect(() => { + const animation = requestAnimationFrame(() => setEnabled(true)); + + return () => { + cancelAnimationFrame(animation); + setEnabled(false); + }; + }, []); + + if (!enabled) { + return null; + } + + return {children}; + }; + \ No newline at end of file