fix: scroll to top when swithc embeded database view

This commit is contained in:
Nathan
2025-11-18 13:13:13 +08:00
parent 861f3e5532
commit b1ec6d45ac
7 changed files with 206 additions and 47 deletions

View File

@@ -56,8 +56,11 @@ export function useViewOperations() {
)?.database_id;
if (databaseId) {
console.debug('[useViewOperations] mapped view to database', { viewId: id, databaseId });
resolve(databaseId);
return;
}
console.warn('[useViewOperations] databaseId not found for view', { viewId: id });
};
observeEvent();
@@ -165,6 +168,7 @@ export function useViewOperations() {
return Promise.reject(new Error('Invalid view layout'));
}
console.debug('[useViewOperations] loadView resolved layout', { viewId: id, layout: view?.layout, collabType });
if (collabType === Types.Document) {
let awareness: Awareness | undefined;

View File

@@ -47,6 +47,7 @@ export interface Database2Props {
showActions?: boolean;
createFolderView?: (payload: CreateFolderViewPayload) => Promise<string>;
getViewIdFromDatabaseId?: (databaseId: string) => Promise<string | null>;
embeddedHeight?: number;
}
function Database(props: Database2Props) {
@@ -66,6 +67,7 @@ function Database(props: Database2Props) {
navigateToView,
modalRowId,
isDocumentBlock,
embeddedHeight,
} = props;
const database = doc.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase | null;
@@ -155,8 +157,14 @@ function Database(props: Database2Props) {
const rowOrdersData = rowOrders?.toJSON() || [];
const ids = rowOrdersData.map(({ id }: { id: string }) => id);
console.debug('[Database] row orders updated', {
viewId,
iidIndex,
ids,
raw: rowOrdersData,
});
setRowIds(ids);
}, [rowOrders]);
}, [iidIndex, rowOrders, viewId]);
useEffect(() => {
void handleUpdateRowDocMap();
@@ -252,13 +260,14 @@ function Database(props: Database2Props) {
{rowId ? (
<DatabaseRow appendBreadcrumb={appendBreadcrumb} rowId={rowId} />
) : (
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-y-hidden'>
<div className='appflowy-database relative flex w-full flex-1 select-text flex-col overflow-hidden'>
<DatabaseViews
visibleViewIds={visibleViewIds}
iidIndex={iidIndex}
viewName={iidName}
onChangeView={onChangeView}
viewId={viewId}
fixedHeight={embeddedHeight}
/>
</div>
)}

View File

@@ -31,22 +31,23 @@ function DatabaseViews({
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 pendingScrollTopRef = useRef<number | null>(null);
const restoreRafRef = useRef<number>();
const [viewVisible, setViewVisible] = useState(true);
const viewContainerRef = useRef<HTMLDivElement | null>(null);
const [lockedHeight, setLockedHeight] = useState<number | null>(null);
const [lockedHeight, setLockedHeight] = useState<number | null>(fixedHeight ?? null);
const lastScrollRef = useRef<number | null>(null);
const value = useMemo(() => {
return Math.max(
0,
@@ -70,6 +71,13 @@ function DatabaseViews({
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();
@@ -84,17 +92,24 @@ function DatabaseViews({
const handleViewChange = useCallback(
(newViewId: string) => {
const scrollElement = getScrollElement();
pendingScrollTopRef.current = scrollElement?.scrollTop ?? null;
lastScrollRef.current = scrollElement?.scrollTop ?? null;
logDebug('[DatabaseViews] captured scroll before view change', {
scrollTop: pendingScrollTopRef.current,
scrollTop: lastScrollRef.current,
});
setLockedHeight(viewContainerRef.current?.offsetHeight ?? null);
setViewVisible(false);
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);
},
[onChangeView]
[fixedHeight, onChangeView]
);
const skeleton = useMemo(() => {
@@ -110,6 +125,7 @@ function DatabaseViews({
}
}, [layout]);
// Simple conditional rendering - Board's autoScrollForElements doesn't support keep-alive
const view = useMemo(() => {
if (isLoading) return skeleton;
switch (layout) {
@@ -122,53 +138,94 @@ function DatabaseViews({
}
}, [layout, isLoading, skeleton]);
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
// Even with keep-mounted, Board's autoScrollForElements can still interfere on first switch
useEffect(() => {
if (isLoading) return;
if (pendingScrollTopRef.current == null) return;
if (lastScrollRef.current == null) return;
const scrollElement = getScrollElement();
if (!scrollElement) return;
const target = pendingScrollTopRef.current;
if (restoreRafRef.current !== undefined) {
cancelAnimationFrame(restoreRafRef.current);
if (!scrollElement) {
lastScrollRef.current = null;
return;
}
let start = performance.now();
setViewVisible(false);
const enforce = () => {
const delta = scrollElement.scrollTop - target;
if (Math.abs(delta) > 0.5) {
scrollElement.scrollTop = target;
logDebug('[DatabaseViews] enforcing scroll position', {
target,
applied: scrollElement.scrollTop,
const targetScroll = lastScrollRef.current;
let rafCount = 0;
let rafId: number;
// Use RAF loop to enforce scroll position
// This handles Board's autoScrollForElements which may still interfere on first display
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,
});
}
if (performance.now() - start < 350) {
restoreRafRef.current = requestAnimationFrame(enforce);
rafCount++;
// Run for 3 frames (~48ms) - shorter than before since views stay mounted
if (rafCount < 3) {
rafId = requestAnimationFrame(enforceScroll);
} else {
pendingScrollTopRef.current = null;
restoreRafRef.current = undefined;
setViewVisible(true);
setLockedHeight(null);
logDebug('[DatabaseViews] scroll enforcement completed', {
logDebug('[DatabaseViews] scroll restoration completed', {
final: scrollElement.scrollTop,
target: targetScroll,
});
lastScrollRef.current = null;
setViewVisible(true);
}
};
restoreRafRef.current = requestAnimationFrame(enforce);
rafId = requestAnimationFrame(enforceScroll);
return () => {
if (restoreRafRef.current !== undefined) {
cancelAnimationFrame(restoreRafRef.current);
restoreRafRef.current = undefined;
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [isLoading, layout, viewId]);
}, [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 (
<>
@@ -192,11 +249,21 @@ function DatabaseViews({
<div
ref={viewContainerRef}
className={'relative flex h-full w-full flex-1 flex-col overflow-hidden'}
style={lockedHeight !== null ? { height: `${lockedHeight}px` } : undefined}
style={
effectiveHeight !== null
? { height: `${effectiveHeight}px` }
: undefined
}
>
<div
className='h-full w-full transition-opacity duration-75'
style={{ opacity: viewVisible ? 1 : 0, pointerEvents: viewVisible ? undefined : 'none' }}
className='h-full w-full transition-opacity duration-100'
style={{
...(effectiveHeight !== null
? { minHeight: `${effectiveHeight}px`, height: `${effectiveHeight}px` }
: {}),
opacity: viewVisible ? 1 : 0,
pointerEvents: viewVisible ? undefined : 'none',
}}
aria-hidden={!viewVisible}
>
<Suspense fallback={skeleton}>

View File

@@ -50,7 +50,13 @@ export const DatabaseBlock = memo(
if (!sharedRoot) return;
const setStatus = () => {
setHasDatabase(!!sharedRoot.get(YjsEditorKey.database));
const hasDb = !!sharedRoot.get(YjsEditorKey.database);
setHasDatabase(hasDb);
if (hasDb) {
console.debug('[DatabaseBlock] database found in doc', { viewId });
} else {
console.warn('[DatabaseBlock] database missing in doc', { viewId });
}
};
setStatus();
@@ -59,7 +65,26 @@ export const DatabaseBlock = memo(
return () => {
sharedRoot.unobserve(setStatus);
};
}, [doc]);
}, [doc, viewId]);
const EMBEDDED_DB_HEIGHT = 560;
const logContainerSize = () => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
console.debug('[DatabaseBlock] container size', {
viewId,
selectedViewId,
width,
height: rect.height,
paddingStart,
paddingEnd,
});
};
useEffect(() => {
logContainerSize();
}, [selectedViewId, width, paddingStart, paddingEnd]);
return (
<div {...attributes} contentEditable={readOnly ? false : undefined} className='relative w-full cursor-pointer'>
@@ -70,6 +95,11 @@ export const DatabaseBlock = memo(
contentEditable={false}
ref={containerRef}
className='container-bg relative my-1 flex w-full select-none flex-col'
style={{
minHeight: EMBEDDED_DB_HEIGHT,
height: EMBEDDED_DB_HEIGHT,
width: '100%',
}}
>
<DatabaseContent
selectedViewId={selectedViewId}
@@ -91,6 +121,7 @@ export const DatabaseBlock = memo(
onChangeView={onChangeView}
// eslint-disable-next-line
context={context as DatabaseContextState}
fixedHeight={EMBEDDED_DB_HEIGHT}
/>
</div>
</div>

View File

@@ -24,6 +24,7 @@ interface DatabaseContentProps {
visibleViewIds: string[];
onChangeView: (viewId: string) => void;
context: DatabaseContextState;
fixedHeight?: number;
}
export const DatabaseContent = ({
@@ -45,6 +46,7 @@ export const DatabaseContent = ({
visibleViewIds,
onChangeView,
context,
fixedHeight,
}: DatabaseContentProps) => {
const { t } = useTranslation();
const isPublishVarient = context?.variant === UIVariant.Publish;
@@ -76,6 +78,7 @@ export const DatabaseContent = ({
paddingStart={paddingStart}
paddingEnd={paddingEnd}
isDocumentBlock={true}
embeddedHeight={fixedHeight}
/>
</div>
);
@@ -92,4 +95,4 @@ export const DatabaseContent = ({
)}
</div>
);
};
};

View File

@@ -75,10 +75,12 @@ export const useDatabaseLoading = ({ viewId, loadView, loadViewMeta }: UseDataba
const loadViewData = async () => {
try {
const view = await retryLoadView(viewId);
console.debug('[DatabaseBlock] loaded view doc', { viewId });
setDoc(view);
setNotFound(false);
} catch (error) {
console.error('[DatabaseBlock] failed to load view doc', { viewId, error });
setNotFound(true);
}
};
@@ -91,15 +93,26 @@ export const useDatabaseLoading = ({ viewId, loadView, loadViewMeta }: UseDataba
}, [visibleViewIds]);
useLayoutEffect(() => {
void loadViewMetaWithCallback(viewId).then(() => {
void loadViewMetaWithCallback(viewId).then((meta) => {
if (!viewIdsRef.current.includes(viewId) && viewIdsRef.current.length > 0) {
setSelectedViewId(viewIdsRef.current[0]);
console.debug('[DatabaseBlock] selected first child view', { viewId, selected: viewIdsRef.current[0] });
} else {
setSelectedViewId(viewId);
console.debug('[DatabaseBlock] selected requested view', { viewId });
}
if (meta) {
console.debug('[DatabaseBlock] loaded view meta', {
viewId,
children: meta.children?.map((c) => c.view_id),
name: meta.name,
});
}
setNotFound(false);
}).catch(() => {
}).catch((error) => {
console.error('[DatabaseBlock] failed to load view meta', { viewId, error });
setNotFound(true);
});
}, [loadViewMetaWithCallback, viewId]);

View File

@@ -439,13 +439,45 @@ export function SlashPanel({
try {
const baseViewId =
(await getViewIdFromDatabaseId?.(option.databaseId)) || option.view.view_id;
const baseName =
option.view.name ||
t('document.view.placeholder', { defaultValue: 'Untitled' });
const prefix = (() => {
switch (linkedPicker.layout) {
case ViewLayout.Grid:
return t('document.grid.referencedGridPrefix', {
defaultValue: 'View of',
});
case ViewLayout.Board:
return t('document.board.referencedBoardPrefix', {
defaultValue: 'View of',
});
case ViewLayout.Calendar:
return t('document.calendar.referencedCalendarPrefix', {
defaultValue: 'View of',
});
default:
return '';
}
})();
const referencedName = prefix ? `${prefix} ${baseName}` : baseName;
const newViewId = await createFolderView({
parentViewId: viewId,
parentViewId: baseViewId,
layout: linkedPicker.layout,
name: option.view.name,
name: referencedName,
databaseId: option.databaseId,
});
console.debug('[SlashPanel] created linked database', {
optionKey: linkedPicker.layout,
baseViewId,
databaseId: option.databaseId,
newViewId,
referencedName,
});
turnInto(blockType, {
view_id: newViewId,
parent_id: baseViewId,