From d69c6f5c3f18667ebfbbbc9466a5a38d88504b44 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 20 Nov 2025 15:08:18 +0800 Subject: [PATCH] chore: refactor database view tags --- .../components/tabs/DatabaseTabItem.tsx | 166 +++++++++++ .../database/components/tabs/DatabaseTabs.tsx | 270 +++++------------- .../components/tabs/useTabScroller.ts | 65 +++++ 3 files changed, 306 insertions(+), 195 deletions(-) create mode 100644 src/components/database/components/tabs/DatabaseTabItem.tsx create mode 100644 src/components/database/components/tabs/useTabScroller.ts diff --git a/src/components/database/components/tabs/DatabaseTabItem.tsx b/src/components/database/components/tabs/DatabaseTabItem.tsx new file mode 100644 index 00000000..5eda43a5 --- /dev/null +++ b/src/components/database/components/tabs/DatabaseTabItem.tsx @@ -0,0 +1,166 @@ +import { + DatabaseViewLayout, + View, + ViewLayout, + YDatabaseView, + YjsDatabaseKey, +} from '@/application/types'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; +import { DatabaseViewActions } from '@/components/database/components/tabs/ViewActions'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { TabLabel, TabsTrigger } from '@/components/ui/tabs'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface DatabaseTabItemProps { + viewId: string; + view: YDatabaseView; + meta: View | null; + iidIndex: string; + menuViewId: string | null; + readOnly: boolean; + visibleViewIds: string[]; + onSetMenuViewId: (id: string | null) => void; + onOpenDeleteModal: (id: string) => void; + onOpenRenameModal: (id: string) => void; + onReloadView: () => void; + setTabRef: (id: string, el: HTMLElement | null) => void; +} + +export const DatabaseTabItem = memo( + ({ + viewId, + view, + meta, + iidIndex, + menuViewId, + readOnly, + visibleViewIds, + onSetMenuViewId, + onOpenDeleteModal, + onOpenRenameModal, + onReloadView, + setTabRef, + }: DatabaseTabItemProps) => { + const { t } = useTranslation(); + const databaseLayout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout; + const folderView = + viewId === iidIndex ? meta : meta?.children?.find((v) => v.view_id === viewId); + + const name = folderView?.name || view.get(YjsDatabaseKey.name) || t('untitled'); + + const menuView = useMemo(() => { + if (menuViewId === iidIndex) return meta; + return meta?.children.find((v) => v.view_id === menuViewId); + }, [iidIndex, menuViewId, meta]); + + return ( + { + setTabRef(viewId, el); + }} + > + { + // For left-click, let Radix UI tabs handle it via onValueChange + if (e.button === 0) { + return; + } + + // For right-click and other buttons, prevent default and handle menu + e.preventDefault(); + e.stopPropagation(); + + if (readOnly) return; + + if (viewId !== menuViewId) { + onSetMenuViewId(viewId); + } else { + onSetMenuViewId(null); + } + }} + className={'flex items-center gap-1.5 overflow-hidden'} + > + + + + + { + e.preventDefault(); + }} + className={'flex-1 truncate'} + > + {name || t('grid.title.placeholder')} + + + + {name} + + + + { + if (!open) { + onSetMenuViewId(null); + } + }} + open={menuViewId === viewId} + > + +
+ + e.preventDefault()} + > + {menuView && ( + { + onSetMenuViewId(null); + }} + onOpenDeleteModal={(viewId: string) => { + onOpenDeleteModal(viewId); + }} + onOpenRenameModal={(viewId: string) => { + onOpenRenameModal(viewId); + }} + deleteDisabled={viewId === iidIndex && visibleViewIds.length > 1} + view={menuView} + onUpdatedIcon={onReloadView} + /> + )} + + + + ); + } +); + +DatabaseTabItem.displayName = 'DatabaseTabItem'; + diff --git a/src/components/database/components/tabs/DatabaseTabs.tsx b/src/components/database/components/tabs/DatabaseTabs.tsx index 595e8a3f..719c9f84 100644 --- a/src/components/database/components/tabs/DatabaseTabs.tsx +++ b/src/components/database/components/tabs/DatabaseTabs.tsx @@ -12,7 +12,6 @@ import { ReactComponent as PlusIcon } from '@/assets/icons/plus.svg'; import { findView } from '@/components/_shared/outline/utils'; import { AFScroller } from '@/components/_shared/scroller'; import { ViewIcon } from '@/components/_shared/view-icon'; -import PageIcon from '@/components/_shared/view-icon/PageIcon'; import { SCROLL_DELAY, SCROLL_FALLBACK_DELAY, @@ -20,13 +19,13 @@ import { import { useDatabaseViewSync } from '@/components/app/hooks/useViewSync'; import RenameModal from '@/components/app/view-actions/RenameModal'; import { DatabaseActions } from '@/components/database/components/conditions'; +import { DatabaseTabItem } from '@/components/database/components/tabs/DatabaseTabItem'; import DeleteViewConfirm from '@/components/database/components/tabs/DeleteViewConfirm'; -import { DatabaseViewActions } from '@/components/database/components/tabs/ViewActions'; +import { useTabScroller } from '@/components/database/components/tabs/useTabScroller'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Progress } from '@/components/ui/progress'; -import { TabLabel, Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Tabs, TabsList } from '@/components/ui/tabs'; export interface DatabaseTabBarProps { viewIds: string[]; @@ -46,7 +45,7 @@ export const DatabaseTabs = forwardRef( const { loadViewMeta, readOnly, showActions = true, eventEmitter } = context; const updatePage = useUpdateDatabaseView(); const [meta, setMeta] = useState(null); - const scrollLeft = context.paddingStart; + const scrollLeftPadding = context.paddingStart; const [addLoading, setAddLoading] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(); const [renameViewId, setRenameViewId] = useState(); @@ -54,29 +53,32 @@ export const DatabaseTabs = forwardRef( const [tabsWidth, setTabsWidth] = useState(null); const [tabsContainer, setTabsContainer] = useState(null); - const [showScrollRightButton, setShowScrollRightButton] = useState(false); - const [showScrollLeftButton, setShowScrollLeftButton] = useState(false); - const [scrollerContainer, setScrollerContainer] = useState(null); const tabRefs = useRef>(new Map()); - const { waitForViewData } = useDatabaseViewSync(views as any); + const { + setScrollerContainer, + showScrollLeftButton, + showScrollRightButton, + scrollLeft, + scrollRight, + handleObserverScroller, + } = useTabScroller(); - const scrollToView = useCallback( - (viewId: string) => { - const element = tabRefs.current.get(viewId); + const { waitForViewData } = useDatabaseViewSync(views); - if (element) { - element.scrollIntoView({ - behavior: 'smooth', - block: 'nearest', - inline: 'center', - }); - return true; - } - return false; - }, - [] - ); + const scrollToView = useCallback((viewId: string) => { + const element = tabRefs.current.get(viewId); + + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }); + return true; + } + return false; + }, []); const navigateToView = useCallback( async (viewId: string) => { @@ -97,18 +99,6 @@ export const DatabaseTabs = forwardRef( [setSelectedViewId, scrollToView] ); - const handleObserverScroller = useCallback(() => { - if (scrollerContainer) { - const scrollWidth = scrollerContainer.scrollWidth; - const clientWidth = scrollerContainer.clientWidth; - - setShowScrollRightButton( - scrollWidth > clientWidth && scrollerContainer.scrollLeft + 1 < scrollWidth - clientWidth - ); - setShowScrollLeftButton(scrollerContainer.scrollLeft > 5); - } - }, [scrollerContainer]); - useEffect(() => { const onResize = () => { if (tabsContainer) { @@ -134,30 +124,13 @@ export const DatabaseTabs = forwardRef( }; }, [tabsContainer]); - useEffect(() => { - if (!scrollerContainer) return; - const onResize = () => { - handleObserverScroller(); - }; - - // Initial call to set the width - onResize(); - - const observer = new ResizeObserver(onResize); - - observer.observe(scrollerContainer); - - return () => { - observer.disconnect(); - }; - }, [handleObserverScroller, scrollerContainer]); - const reloadView = useCallback(async () => { if (loadViewMeta) { try { const meta = await loadViewMeta(iidIndex); setMeta(meta); + return meta; } catch (e) { // do nothing } @@ -189,11 +162,6 @@ export const DatabaseTabs = forwardRef( return meta?.children.find((v) => v.view_id === renameViewId); }, [iidIndex, meta, renameViewId]); - const menuView = useMemo(() => { - if (menuViewId === iidIndex) return meta; - return meta?.children.find((v) => v.view_id === menuViewId); - }, [iidIndex, menuViewId, meta]); - const visibleViewIds = useMemo(() => { return viewIds.filter((viewId) => { const databaseView = views?.get(viewId) as YDatabaseView | null; @@ -212,7 +180,14 @@ export const DatabaseTabs = forwardRef( const synced = await waitForViewData(viewId); // Reload view metadata to ensure folder structure is updated - await reloadView(); + const meta = await reloadView(); + + // Sometimes the view metadata is not immediately available after creation due to race conditions on the backend/sync + // If the new view is not found in the children list, wait a bit and try reloading again + if (meta && !meta.children.some((v) => v.view_id === viewId)) { + await new Promise((resolve) => setTimeout(resolve, 500)); + await reloadView(); + } if (synced) { await navigateToView(viewId); @@ -258,17 +233,28 @@ export const DatabaseTabs = forwardRef( document.removeEventListener('contextmenu', preventDefault); }; }, [menuViewId]); + + const setTabRef = useCallback((viewId: string, el: HTMLElement | null) => { + if (el) { + tabRefs.current.set(viewId, el); + } else { + tabRefs.current.delete(viewId); + } + }, []); + if (viewIds.length === 0) return null; return (
-
+
{showScrollLeftButton && ( @@ -304,15 +282,7 @@ export const DatabaseTabs = forwardRef( 'absolute right-9 top-0 z-10 bg-surface-primary text-icon-secondary hover:bg-surface-primary-hover' } variant={'ghost'} - tabIndex={-1} - onClick={() => { - if (scrollerContainer) { - scrollerContainer.scrollTo({ - left: scrollerContainer.scrollLeft + 200, - behavior: 'smooth', - }); - } - }} + onClick={scrollRight} > @@ -325,13 +295,8 @@ export const DatabaseTabs = forwardRef( }} className={'relative flex h-full flex-1'} overflowYHidden - ref={(el: HTMLDivElement | null) => { - setScrollerContainer(el); - handleObserverScroller(); - }} - onScroll={() => { - handleObserverScroller(); - }} + ref={setScrollerContainer} + onScroll={handleObserverScroller} >
( const view = views?.get(viewId) as YDatabaseView | null; if (!view) return null; - const databaseLayout = Number(view.get(YjsDatabaseKey.layout)) as DatabaseViewLayout; - const folderView = viewId === iidIndex ? meta : meta?.children?.find((v) => v.view_id === viewId); - - const name = folderView?.name || view.get(YjsDatabaseKey.name) || t('untitled'); return ( - { - if (el) { - tabRefs.current.set(viewId, el); - } else { - tabRefs.current.delete(viewId); - } - }} - > - { - // For left-click, let Radix UI tabs handle it via onValueChange - if (e.button === 0) { - return; - } - - // For right-click and other buttons, prevent default and handle menu - e.preventDefault(); - e.stopPropagation(); - - if (readOnly) return; - - if (viewId !== menuViewId) { - setMenuViewId(viewId); - } else { - setMenuViewId(null); - } - }} - className={'flex items-center gap-1.5 overflow-hidden'} - > - - - - - { - e.preventDefault(); - }} - className={'flex-1 truncate'} - > - {name || t('grid.title.placeholder')} - - - - {name} - - - - { - if (!open) { - setMenuViewId(null); - } - }} - open={menuViewId === viewId} - > - -
- - e.preventDefault()} - > - {menuView && ( - { - setMenuViewId(null); - }} - onOpenDeleteModal={(viewId: string) => { - setDeleteConfirmOpen(viewId); - }} - onOpenRenameModal={(viewId: string) => { - setRenameViewId(viewId); - }} - deleteDisabled={viewId === iidIndex && visibleViewIds.length > 1} - view={menuView} - onUpdatedIcon={reloadView} - /> - )} - - - + viewId={viewId} + view={view} + meta={meta} + iidIndex={iidIndex} + menuViewId={menuViewId} + readOnly={!!readOnly} + visibleViewIds={visibleViewIds} + onSetMenuViewId={setMenuViewId} + onOpenDeleteModal={setDeleteConfirmOpen} + onOpenRenameModal={setRenameViewId} + onReloadView={reloadView} + setTabRef={setTabRef} + /> ); })} @@ -473,13 +347,17 @@ export const DatabaseTabs = forwardRef( ( ); } ); + +DatabaseTabs.displayName = 'DatabaseTabs'; diff --git a/src/components/database/components/tabs/useTabScroller.ts b/src/components/database/components/tabs/useTabScroller.ts new file mode 100644 index 00000000..e61f4a5b --- /dev/null +++ b/src/components/database/components/tabs/useTabScroller.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect, useState } from 'react'; + +export const useTabScroller = () => { + const [scrollerContainer, setScrollerContainer] = useState(null); + const [showScrollRightButton, setShowScrollRightButton] = useState(false); + const [showScrollLeftButton, setShowScrollLeftButton] = useState(false); + + const handleObserverScroller = useCallback(() => { + if (scrollerContainer) { + const scrollWidth = scrollerContainer.scrollWidth; + const clientWidth = scrollerContainer.clientWidth; + + setShowScrollRightButton( + scrollWidth > clientWidth && scrollerContainer.scrollLeft + 1 < scrollWidth - clientWidth + ); + setShowScrollLeftButton(scrollerContainer.scrollLeft > 5); + } + }, [scrollerContainer]); + + useEffect(() => { + if (!scrollerContainer) return; + const onResize = () => { + handleObserverScroller(); + }; + + // Initial call + onResize(); + + const observer = new ResizeObserver(onResize); + + observer.observe(scrollerContainer); + + return () => { + observer.disconnect(); + }; + }, [handleObserverScroller, scrollerContainer]); + + const scrollLeft = useCallback(() => { + if (scrollerContainer) { + scrollerContainer.scrollTo({ + left: scrollerContainer.scrollLeft - 200, + behavior: 'smooth', + }); + } + }, [scrollerContainer]); + + const scrollRight = useCallback(() => { + if (scrollerContainer) { + scrollerContainer.scrollTo({ + left: scrollerContainer.scrollLeft + 200, + behavior: 'smooth', + }); + } + }, [scrollerContainer]); + + return { + setScrollerContainer, + showScrollLeftButton, + showScrollRightButton, + scrollLeft, + scrollRight, + handleObserverScroller, + }; +}; +