[AI Chat]: Move sidebar animation to CSS (#26950)
This commit is contained in:
@@ -115,32 +115,35 @@ function ConversationItem(props: ConversationItemProps) {
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
<ButtonMenu
|
||||
className={styles.optionsMenu}
|
||||
onChange={handleButtonMenuChange}
|
||||
>
|
||||
<Button
|
||||
slot='anchor-content'
|
||||
className={styles.optionsButton}
|
||||
kind='plain-faint'
|
||||
fab
|
||||
size='small'
|
||||
{/* Stop propagation so clicks don't bubble to the Link and close the sidebar */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ButtonMenu
|
||||
className={styles.optionsMenu}
|
||||
onChange={handleButtonMenuChange}
|
||||
>
|
||||
<Icon name='more-vertical' />
|
||||
</Button>
|
||||
<leo-menu-item onClick={handleEditTitle}>
|
||||
<div className={styles.optionsMenuItemWithIcon}>
|
||||
<Icon name='edit-pencil' />
|
||||
<div>{getLocale(S.CHAT_UI_MENU_RENAME_CONVERSATION)}</div>
|
||||
</div>
|
||||
</leo-menu-item>
|
||||
<leo-menu-item onClick={handleDelete}>
|
||||
<div className={styles.optionsMenuItemWithIcon}>
|
||||
<Icon name='trash' />
|
||||
<div>{getLocale(S.CHAT_UI_MENU_DELETE_CONVERSATION)}</div>
|
||||
</div>
|
||||
</leo-menu-item>
|
||||
</ButtonMenu>
|
||||
<Button
|
||||
slot='anchor-content'
|
||||
className={styles.optionsButton}
|
||||
kind='plain-faint'
|
||||
fab
|
||||
size='small'
|
||||
>
|
||||
<Icon name='more-vertical' />
|
||||
</Button>
|
||||
<leo-menu-item onClick={handleEditTitle}>
|
||||
<div className={styles.optionsMenuItemWithIcon}>
|
||||
<Icon name='edit-pencil' />
|
||||
<div>{getLocale(S.CHAT_UI_MENU_RENAME_CONVERSATION)}</div>
|
||||
</div>
|
||||
</leo-menu-item>
|
||||
<leo-menu-item onClick={handleDelete}>
|
||||
<div className={styles.optionsMenuItemWithIcon}>
|
||||
<Icon name='trash' />
|
||||
<div>{getLocale(S.CHAT_UI_MENU_DELETE_CONVERSATION)}</div>
|
||||
</div>
|
||||
</leo-menu-item>
|
||||
</ButtonMenu>
|
||||
</div>
|
||||
</div>
|
||||
{uuid === aiChatContext.editingConversationId && (
|
||||
<div className={styles.editibleTitle}>
|
||||
|
||||
@@ -20,110 +20,54 @@ export default function FullScreen() {
|
||||
const { createNewConversation } = useActiveChat()
|
||||
const conversationContext = useConversation()
|
||||
|
||||
const asideAnimationRef = React.useRef<Animation | null>()
|
||||
const controllerRef = React.useRef(new AbortController())
|
||||
const asideRef = React.useRef<HTMLElement | null>(null)
|
||||
const isSmall = useIsSmall()
|
||||
const [isNavigationCollapsed, setIsNavigationCollapsed] =
|
||||
React.useState(isSmall)
|
||||
const [isNavigationRendered, setIsNavigationRendered] =
|
||||
React.useState(!isSmall)
|
||||
|
||||
// Enable CSS transitions after the first paint to avoid animating on mount
|
||||
const [isAnimated, setIsAnimated] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
const frame = requestAnimationFrame(() => setIsAnimated(true))
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [])
|
||||
|
||||
const canStartNewConversation =
|
||||
aiChatContext.hasAcceptedAgreement
|
||||
&& !!conversationContext.conversationHistory.length
|
||||
|
||||
const asideRef = React.useRef<HTMLElement | null>(null)
|
||||
|
||||
const initAsideAnimation = React.useCallback((node: HTMLElement | null) => {
|
||||
asideRef.current = node
|
||||
|
||||
if (!node) return
|
||||
const open = { width: 'var(--navigation-width)', opacity: 1 }
|
||||
const close = { width: '0px', opacity: 0 }
|
||||
const animationOptions: KeyframeAnimationOptions = {
|
||||
duration: 400,
|
||||
easing: 'cubic-bezier(0.77, 0, 0.175, 1)',
|
||||
fill: 'forwards',
|
||||
}
|
||||
asideAnimationRef.current = new Animation(
|
||||
new KeyframeEffect(node, [open, close], animationOptions),
|
||||
)
|
||||
|
||||
// Make sure we're in the right state for our screen size when
|
||||
asideAnimationRef.current.playbackRate = isSmall ? 1 : -1
|
||||
asideAnimationRef.current.finish()
|
||||
}, [])
|
||||
|
||||
const toggleAside = () => {
|
||||
const asideAnimation = asideAnimationRef.current
|
||||
|
||||
if (asideAnimation) {
|
||||
if (isNavigationCollapsed) {
|
||||
controllerRef.current.abort()
|
||||
controllerRef.current = new AbortController()
|
||||
asideAnimation.ready.then(() => setIsNavigationRendered(true))
|
||||
asideAnimation.playbackRate = -1
|
||||
} else {
|
||||
// 'finish' triggers in both directions, so we only need this once per close animation
|
||||
// user may rapidly toggle the aside, so we need to abort scheduled listener in open animation
|
||||
asideAnimation.addEventListener(
|
||||
'finish',
|
||||
() => setIsNavigationRendered(false),
|
||||
{ once: true, signal: controllerRef.current.signal },
|
||||
)
|
||||
asideAnimation.playbackRate = 1
|
||||
}
|
||||
|
||||
asideAnimation.play()
|
||||
setIsNavigationCollapsed(!isNavigationCollapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// When editing a conversation title, ensure the sidebar is open
|
||||
React.useEffect(() => {
|
||||
const isOpen = asideAnimationRef.current?.playbackRate === 1
|
||||
if (aiChatContext.editingConversationId && isOpen) {
|
||||
toggleAside()
|
||||
if (aiChatContext.editingConversationId && !aiChatContext.showSidebar) {
|
||||
aiChatContext.toggleSidebar()
|
||||
}
|
||||
}, [aiChatContext.editingConversationId, isNavigationCollapsed])
|
||||
}, [aiChatContext.editingConversationId])
|
||||
|
||||
// On iOS, close the sidebar when the delete-conversation dialog opens.
|
||||
// The sidebar overlaps the dialog and intercepts touch events, requiring
|
||||
// a double-tap to reach buttons inside the dialog. Uses toggleSidebar()
|
||||
// (not toggleAside()) so showSidebar stays in sync with the visual state;
|
||||
// the showSidebar effect above then drives the actual animation.
|
||||
// a double-tap to reach buttons inside a dialog. Uses toggleSidebar()
|
||||
// so showSidebar stays in sync with the visual state.
|
||||
// <if expr="is_ios">
|
||||
React.useEffect(() => {
|
||||
const isClosed = asideAnimationRef.current?.playbackRate === 1
|
||||
if (aiChatContext.deletingConversationId && !isClosed) {
|
||||
if (aiChatContext.deletingConversationId && aiChatContext.showSidebar) {
|
||||
aiChatContext.toggleSidebar()
|
||||
}
|
||||
}, [aiChatContext.deletingConversationId])
|
||||
// </if>
|
||||
|
||||
React.useEffect(() => {
|
||||
const isOpen = asideAnimationRef.current?.playbackRate === 1
|
||||
|
||||
// We've just changed to small and the sidebar was open, so close it
|
||||
if (isSmall && !isOpen) {
|
||||
if (isSmall && aiChatContext.showSidebar) {
|
||||
aiChatContext.toggleSidebar()
|
||||
}
|
||||
|
||||
// We've just changed to big and the sidebar was closed, so open it
|
||||
if (!isSmall && isOpen) {
|
||||
if (!isSmall && !aiChatContext.showSidebar) {
|
||||
aiChatContext.toggleSidebar()
|
||||
}
|
||||
}, [isSmall])
|
||||
|
||||
React.useEffect(() => {
|
||||
const isOpen = asideAnimationRef.current?.playbackRate === 1
|
||||
if (isOpen !== aiChatContext.showSidebar) {
|
||||
toggleAside()
|
||||
}
|
||||
}, [aiChatContext.showSidebar])
|
||||
|
||||
// Add handler for closing the sidebar when clicking outside of it.
|
||||
React.useEffect(() => {
|
||||
if (aiChatContext.showSidebar || !isSmall) return
|
||||
if (!aiChatContext.showSidebar || !isSmall) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const path = e.composedPath()
|
||||
// On iOS, the one-tap fix dispatches synthetic clicks at (0,0).
|
||||
@@ -173,7 +117,7 @@ export default function FullScreen() {
|
||||
>
|
||||
<Icon name='window-tabs-vertical-expanded' />
|
||||
</Button>
|
||||
{!isNavigationRendered && canStartNewConversation && (
|
||||
{!aiChatContext.showSidebar && canStartNewConversation && (
|
||||
<>
|
||||
<Button
|
||||
fab
|
||||
@@ -188,17 +132,23 @@ export default function FullScreen() {
|
||||
</div>
|
||||
)}
|
||||
<aside
|
||||
ref={initAsideAnimation}
|
||||
className={styles.aside}
|
||||
>
|
||||
{isNavigationRendered && (
|
||||
<div className={styles.nav}>
|
||||
<NavigationHeader />
|
||||
<ConversationsList
|
||||
setIsConversationsListOpen={setIsNavigationCollapsed}
|
||||
/>
|
||||
</div>
|
||||
ref={asideRef}
|
||||
className={classNames(
|
||||
styles.aside,
|
||||
aiChatContext.showSidebar && styles.open,
|
||||
isAnimated && styles.animated,
|
||||
)}
|
||||
>
|
||||
<div className={styles.nav}>
|
||||
<NavigationHeader />
|
||||
<ConversationsList
|
||||
setIsConversationsListOpen={(open) => {
|
||||
if (!open && aiChatContext.showSidebar) {
|
||||
aiChatContext.toggleSidebar()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
|
||||
@@ -42,10 +42,27 @@
|
||||
|
||||
.aside {
|
||||
--navigation-width: 340px;
|
||||
width: var(--navigation-width);
|
||||
--animation-duration: 200ms;
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--leo-color-page-background);
|
||||
padding-top: var(--top-padding);
|
||||
|
||||
&.open {
|
||||
width: var(--navigation-width);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&.animated {
|
||||
transition:
|
||||
width var(--animation-duration) cubic-bezier(0.77, 0, 0.175, 1),
|
||||
opacity var(--animation-duration) cubic-bezier(0.77, 0, 0.175, 1),
|
||||
visibility var(--animation-duration) allow-discrete;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
position: fixed;
|
||||
@@ -56,8 +73,6 @@
|
||||
0px var(--leo-elevation-xs, 4px) var(--leo-elevation-xs, 16px) -2px
|
||||
var(--leo-color-elevation-secondary, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
padding-top: var(--top-padding);
|
||||
}
|
||||
|
||||
.nav {
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function useProvideAIChatContext(props: AIChatContextProps) {
|
||||
string | null
|
||||
>(null)
|
||||
const isSmall = useIsSmall()
|
||||
const [showSidebar, setShowSidebar] = React.useState(isSmall)
|
||||
const [showSidebar, setShowSidebar] = React.useState(!isSmall)
|
||||
const [skillDialog, setSkillDialog] = React.useState<Mojom.Skill | null>(null)
|
||||
|
||||
const { getConversationsData, isPlaceholderData: isConversationsLoading } =
|
||||
|
||||
Reference in New Issue
Block a user