Files
AppFlowy-Web/src/components/database/DatabaseViews.tsx
2025-11-18 14:18:12 +08:00

282 lines
8.4 KiB
TypeScript

import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useDatabaseViewsSelector } from '@/application/database-yjs';
import { DatabaseViewLayout, YjsDatabaseKey } from '@/application/types';
import { Board } from '@/components/database/board';
import { DatabaseConditionsContext } from '@/components/database/components/conditions/context';
import { DatabaseTabs } from '@/components/database/components/tabs';
import { Calendar } from '@/components/database/fullcalendar';
import { Grid } from '@/components/database/grid';
import { ElementFallbackRender } from '@/components/error/ElementFallbackRender';
import { Progress } from '@/components/ui/progress';
import DatabaseConditions from 'src/components/database/components/conditions/DatabaseConditions';
const logDebug = (...args: Parameters<typeof console.debug>) => {
if (import.meta.env.DEV) {
console.debug(...args);
}
};
const getScrollElement = () =>
document.querySelector('.appflowy-scroll-container') as HTMLElement | null;
function DatabaseViews({
onChangeView,
viewId,
iidIndex,
viewName,
visibleViewIds,
fixedHeight,
}: {
onChangeView: (viewId: string) => void;
viewId: string;
iidIndex: string;
viewName?: string;
visibleViewIds?: string[];
fixedHeight?: number;
}) {
const { childViews, viewIds } = useDatabaseViewsSelector(iidIndex, visibleViewIds);
const [isLoading, setIsLoading] = useState(false);
const [layout, setLayout] = useState<DatabaseViewLayout | null>(null);
const [viewVisible, setViewVisible] = useState(true);
const viewContainerRef = useRef<HTMLDivElement | null>(null);
const [lockedHeight, setLockedHeight] = useState<number | null>(fixedHeight ?? null);
const lastScrollRef = useRef<number | null>(null);
const value = useMemo(() => {
return Math.max(
0,
viewIds.findIndex((id) => id === viewId)
);
}, [viewId, viewIds]);
const [conditionsExpanded, setConditionsExpanded] = useState<boolean>(false);
const toggleExpanded = useCallback(() => {
setConditionsExpanded((prev) => !prev);
}, []);
const [openFilterId, setOpenFilterId] = useState<string>();
const activeView = useMemo(() => {
return childViews[value];
}, [childViews, value]);
useEffect(() => {
if (!activeView) return;
const observerEvent = () => {
setLayout(Number(activeView.get(YjsDatabaseKey.layout)) as DatabaseViewLayout);
setIsLoading(false);
const currentHeight = viewContainerRef.current?.offsetHeight ?? null;
logDebug('[DatabaseViews] layout set', {
layout: Number(activeView.get(YjsDatabaseKey.layout)),
viewId,
iidIndex,
currentHeight,
});
};
observerEvent();
activeView.observe(observerEvent);
return () => {
activeView.unobserve(observerEvent);
};
}, [activeView]);
const handleViewChange = useCallback(
(newViewId: string) => {
const scrollElement = getScrollElement();
lastScrollRef.current = scrollElement?.scrollTop ?? null;
logDebug('[DatabaseViews] captured scroll before view change', {
scrollTop: lastScrollRef.current,
});
const currentHeight = viewContainerRef.current?.offsetHeight;
const heightToLock = fixedHeight ?? currentHeight ?? null;
setLockedHeight(heightToLock ?? null);
logDebug('[DatabaseViews] handleViewChange height lock', {
currentHeight,
fixedHeight,
heightToLock,
});
setIsLoading(true);
setViewVisible(false); // Hide view during transition to prevent flash
onChangeView(newViewId);
},
[fixedHeight, onChangeView]
);
const view = useMemo(() => {
if (isLoading) return null;
switch (layout) {
case DatabaseViewLayout.Grid:
return <Grid />;
case DatabaseViewLayout.Board:
return <Board />;
case DatabaseViewLayout.Calendar:
return <Calendar />;
}
}, [layout, isLoading]);
useEffect(() => {
if (!isLoading && viewContainerRef.current) {
const h = viewContainerRef.current.offsetHeight;
if (h > 0) {
logDebug('[DatabaseViews] measured container height', {
height: h,
viewId,
iidIndex,
layout,
});
}
}
}, [isLoading, viewVisible, layout, viewId]);
// Scroll restoration with RAF enforcement
// Board's autoScrollForElements interferes with scroll, so we enforce for multiple frames
useEffect(() => {
if (isLoading) return;
if (lastScrollRef.current == null) return;
const scrollElement = getScrollElement();
if (!scrollElement) {
lastScrollRef.current = null;
return;
}
const targetScroll = lastScrollRef.current;
let rafCount = 0;
let rafId: number;
// Temporarily prevent scroll events during restoration
const preventScroll = (e: Event) => {
if (scrollElement.scrollTop !== targetScroll) {
e.preventDefault();
scrollElement.scrollTop = targetScroll;
}
};
scrollElement.addEventListener('scroll', preventScroll, { passive: false });
// Use RAF loop to enforce scroll position
const enforceScroll = () => {
const currentScroll = scrollElement.scrollTop;
const delta = Math.abs(currentScroll - targetScroll);
if (delta > 0.5) {
scrollElement.scrollTop = targetScroll;
logDebug('[DatabaseViews] RAF restore scroll', {
frame: rafCount,
target: targetScroll,
current: currentScroll,
delta,
});
}
rafCount++;
// Run for 5 frames (~80ms) to catch delayed scroll changes from Board mount
if (rafCount < 5) {
rafId = requestAnimationFrame(enforceScroll);
} else {
logDebug('[DatabaseViews] scroll restoration completed', {
final: scrollElement.scrollTop,
target: targetScroll,
});
// Remove scroll listener and clean up
scrollElement.removeEventListener('scroll', preventScroll);
lastScrollRef.current = null;
setViewVisible(true);
// Release height lock to allow view to resize to its natural height
if (!fixedHeight) {
setLockedHeight(null);
}
}
};
rafId = requestAnimationFrame(enforceScroll);
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
scrollElement.removeEventListener('scroll', preventScroll);
};
}, [isLoading, viewId]);
useEffect(() => {
setLockedHeight(fixedHeight ?? null);
}, [fixedHeight]);
useEffect(() => {
if (!viewContainerRef.current) return;
const rect = viewContainerRef.current.getBoundingClientRect();
logDebug('[DatabaseViews] container render', {
height: rect.height,
lockedHeight,
fixedHeight,
isLoading,
viewVisible,
viewId,
layout,
});
}, [lockedHeight, fixedHeight, isLoading, viewVisible, viewId, layout]);
const effectiveHeight = lockedHeight ?? fixedHeight ?? null;
return (
<>
<DatabaseConditionsContext.Provider
value={{
expanded: conditionsExpanded,
toggleExpanded,
openFilterId,
setOpenFilterId,
}}
>
<DatabaseTabs
viewName={viewName}
iidIndex={iidIndex}
selectedViewId={viewId}
setSelectedViewId={handleViewChange}
viewIds={viewIds}
/>
<DatabaseConditions />
<div
ref={viewContainerRef}
className={'relative flex h-full w-full flex-1 flex-col overflow-hidden'}
style={
effectiveHeight !== null
? { height: `${effectiveHeight}px`, maxHeight: `${effectiveHeight}px` }
: undefined
}
>
<div
className='h-full w-full'
style={
effectiveHeight !== null
? { height: `${effectiveHeight}px`, maxHeight: `${effectiveHeight}px` }
: {}
}
>
<Suspense fallback={null}>
<ErrorBoundary fallbackRender={ElementFallbackRender}>{view}</ErrorBoundary>
</Suspense>
</div>
{isLoading && (
<div className='absolute inset-0 z-50 flex items-center justify-center bg-background-primary/70 backdrop-blur-sm'>
<Progress />
</div>
)}
</div>
</DatabaseConditionsContext.Provider>
</>
);
}
export default DatabaseViews;