Skip to content

Commit 9e41918

Browse files
committed
feat(core): remember the scroll position of doc when routing forward and backward (#8631)
close AF-1011 https://github.com/user-attachments/assets/d2dfeee2-926f-4760-b3fb-8baf5ff90aa9
1 parent 15749de commit 9e41918

File tree

7 files changed

+190
-65
lines changed

7 files changed

+190
-65
lines changed

packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page.tsx

+16-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { notify, Scrollable, useHasScrollTop } from '@affine/component';
1+
import { notify, Scrollable } from '@affine/component';
22
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
33
import type { ChatPanel } from '@affine/core/blocksuite/presets/ai';
44
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
@@ -33,7 +33,7 @@ import {
3333
WorkspaceService,
3434
} from '@toeverything/infra';
3535
import clsx from 'clsx';
36-
import { memo, useCallback, useEffect, useRef } from 'react';
36+
import { memo, useCallback, useEffect, useRef, useState } from 'react';
3737
import { useParams } from 'react-router-dom';
3838

3939
import { AffineErrorBoundary } from '../../../../components/affine/affine-error-boundary';
@@ -227,28 +227,36 @@ const DetailPageImpl = memo(function DetailPageImpl() {
227227
})
228228
);
229229

230-
editor.setEditorContainer(editorContainer);
231230
const unbind = editor.bindEditorContainer(
232231
editorContainer,
233-
(editorContainer as any).docTitle // set from proxy
232+
(editorContainer as any).docTitle, // set from proxy
233+
scrollViewportRef.current
234234
);
235235

236236
return () => {
237237
unbind();
238-
editor.setEditorContainer(null);
239238
disposable.dispose();
240239
};
241240
},
242241
[editor, openPage, docCollection.id, jumpToPageBlock, t]
243242
);
244243

245-
const [refCallback, hasScrollTop] = useHasScrollTop();
244+
const [hasScrollTop, setHasScrollTop] = useState(false);
246245

247246
const openOutlinePanel = useCallback(() => {
248247
workbench.openSidebar();
249248
view.activeSidebarTab('outline');
250249
}, [workbench, view]);
251250

251+
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
252+
253+
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
254+
const scrollTop = e.currentTarget.scrollTop;
255+
256+
const hasScrollTop = scrollTop > 0;
257+
setHasScrollTop(hasScrollTop);
258+
}, []);
259+
252260
return (
253261
<FrameworkScope scope={editor.scope}>
254262
<ViewHeader>
@@ -265,7 +273,8 @@ const DetailPageImpl = memo(function DetailPageImpl() {
265273
<TopTip pageId={doc.id} workspace={workspace} />
266274
<Scrollable.Root>
267275
<Scrollable.Viewport
268-
ref={refCallback}
276+
onScroll={handleScroll}
277+
ref={scrollViewportRef}
269278
className={clsx(
270279
'affine-page-viewport',
271280
styles.affineDocViewport,

packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx

+1-6
Original file line numberDiff line numberDiff line change
@@ -234,11 +234,7 @@ const SharePageInner = ({
234234
if (!editor) {
235235
return;
236236
}
237-
editor.setEditorContainer(editorContainer);
238-
const unbind = editor.bindEditorContainer(
239-
editorContainer,
240-
(editorContainer as any).docTitle
241-
);
237+
const unbind = editor.bindEditorContainer(editorContainer);
242238

243239
const disposable = new DisposableGroup();
244240
const refNodeSlots =
@@ -263,7 +259,6 @@ const SharePageInner = ({
263259

264260
return () => {
265261
unbind();
266-
editor.setEditorContainer(null);
267262
};
268263
},
269264
[editor, setActiveBlocksuiteEditor, jumpToPageBlock, openPage, workspaceId]

packages/frontend/core/src/mobile/pages/workspace/detail/mobile-detail-page.tsx

+9-2
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
WorkspaceService,
3333
} from '@toeverything/infra';
3434
import clsx from 'clsx';
35-
import { useCallback, useEffect } from 'react';
35+
import { useCallback, useEffect, useRef } from 'react';
3636
import { useParams } from 'react-router-dom';
3737

3838
import { PageHeader } from '../../../components';
@@ -67,6 +67,8 @@ const DetailPageImpl = () => {
6767

6868
const isInTrash = useLiveData(doc.meta$.map(meta => meta.trash));
6969
const { openPage, jumpToPageBlock } = useNavigateHelper();
70+
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
71+
7072
const editorContainer = useLiveData(editor.editorContainer$);
7173

7274
const enableKeyboardToolbar =
@@ -157,7 +159,11 @@ const DetailPageImpl = () => {
157159
);
158160
}
159161

160-
editor.setEditorContainer(editorContainer);
162+
editor.bindEditorContainer(
163+
editorContainer,
164+
null,
165+
scrollViewportRef.current
166+
);
161167

162168
return () => {
163169
disposable.dispose();
@@ -171,6 +177,7 @@ const DetailPageImpl = () => {
171177
<div className={styles.mainContainer}>
172178
<div
173179
data-mode={mode}
180+
ref={scrollViewportRef}
174181
className={clsx(
175182
'affine-page-viewport',
176183
styles.affineDocViewport,

packages/frontend/core/src/modules/editor/entities/editor.ts

+115-28
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { DefaultOpenProperty } from '@affine/core/components/doc-properties';
2-
import type {
3-
DocMode,
2+
import {
3+
type DocMode,
44
EdgelessRootService,
5-
ReferenceParams,
5+
type ReferenceParams,
66
} from '@blocksuite/affine/blocks';
77
import type {
88
AffineEditorContainer,
@@ -13,6 +13,7 @@ import { effect } from '@preact/signals-core';
1313
import type { DocService, WorkspaceService } from '@toeverything/infra';
1414
import { Entity, LiveData } from '@toeverything/infra';
1515
import { defaults, isEqual, omit } from 'lodash-es';
16+
import { skip } from 'rxjs';
1617

1718
import { paramsParseOptions, preprocessParams } from '../../navigation/utils';
1819
import type { WorkbenchView } from '../../workbench';
@@ -34,6 +35,34 @@ export class Editor extends Entity {
3435
readonly defaultOpenProperty$ = new LiveData<DefaultOpenProperty | undefined>(
3536
undefined
3637
);
38+
workbenchView: WorkbenchView | null = null;
39+
defaultScrollPosition:
40+
| number
41+
| {
42+
centerX: number;
43+
centerY: number;
44+
zoom: number;
45+
}
46+
| null = null;
47+
48+
private readonly focusAt$ = LiveData.computed(get => {
49+
const selector = get(this.selector$);
50+
const mode = get(this.mode$);
51+
let id = selector?.blockIds?.[0];
52+
let key = 'blockIds';
53+
54+
if (mode === 'edgeless') {
55+
const elementId = selector?.elementIds?.[0];
56+
if (elementId) {
57+
id = elementId;
58+
key = 'elementIds';
59+
}
60+
}
61+
62+
if (!id) return null;
63+
64+
return { id, key, mode, refreshKey: selector?.refreshKey };
65+
});
3766

3867
isPresenting$ = new LiveData<boolean>(false);
3968

@@ -61,10 +90,6 @@ export class Editor extends Entity {
6190
this.mode$.next(mode);
6291
}
6392

64-
setEditorContainer(editorContainer: AffineEditorContainer | null) {
65-
this.editorContainer$.next(editorContainer);
66-
}
67-
6893
setDefaultOpenProperty(defaultOpenProperty: DefaultOpenProperty | undefined) {
6994
this.defaultOpenProperty$.next(defaultOpenProperty);
7095
}
@@ -73,6 +98,12 @@ export class Editor extends Entity {
7398
* sync editor params with view query string
7499
*/
75100
bindWorkbenchView(view: WorkbenchView) {
101+
if (this.workbenchView) {
102+
throw new Error('already bound');
103+
}
104+
this.workbenchView = view;
105+
this.defaultScrollPosition = view.getScrollPosition() ?? null;
106+
76107
const stablePrimaryMode = this.doc.getPrimaryMode();
77108

78109
// eslint-disable-next-line rxjs/finnish
@@ -147,49 +178,103 @@ export class Editor extends Entity {
147178
});
148179

149180
return () => {
181+
this.workbenchView = null;
150182
unsubscribeEditorParams.unsubscribe();
151183
unsubscribeViewParams.unsubscribe();
152184
};
153185
}
154186

155187
bindEditorContainer(
156188
editorContainer: AffineEditorContainer,
157-
docTitle: DocTitle | null
189+
docTitle?: DocTitle | null,
190+
scrollViewport?: HTMLElement | null
158191
) {
192+
if (this.editorContainer$.value) {
193+
throw new Error('already bound');
194+
}
195+
this.editorContainer$.next(editorContainer);
159196
const unsubs: (() => void)[] = [];
160197

161-
const focusAt$ = LiveData.computed(get => {
162-
const selector = get(this.selector$);
163-
const mode = get(this.mode$);
164-
let id = selector?.blockIds?.[0];
165-
let key = 'blockIds';
166-
167-
if (mode === 'edgeless') {
168-
const elementId = selector?.elementIds?.[0];
169-
if (elementId) {
170-
id = elementId;
171-
key = 'elementIds';
172-
}
198+
const rootService = editorContainer.host?.std.getService('affine:page');
199+
200+
// ----- Scroll Position and Selection -----
201+
if (this.defaultScrollPosition) {
202+
// if we have default scroll position, we should restore it
203+
if (
204+
this.mode$.value === 'page' &&
205+
typeof this.defaultScrollPosition === 'number'
206+
) {
207+
scrollViewport?.scrollTo(0, this.defaultScrollPosition || 0);
208+
} else if (
209+
this.mode$.value === 'edgeless' &&
210+
typeof this.defaultScrollPosition === 'object' &&
211+
rootService instanceof EdgelessRootService
212+
) {
213+
rootService.viewport.setViewport(this.defaultScrollPosition.zoom, [
214+
this.defaultScrollPosition.centerX,
215+
this.defaultScrollPosition.centerY,
216+
]);
173217
}
174218

175-
if (!id) return null;
219+
this.defaultScrollPosition = null; // reset default scroll position
220+
} else {
221+
const initialFocusAt = this.focusAt$.value;
222+
223+
if (initialFocusAt === null) {
224+
const title = docTitle?.querySelector<
225+
HTMLElement & { inlineEditor: InlineEditor | null }
226+
>('rich-text');
227+
title?.inlineEditor?.focusEnd();
228+
} else {
229+
const selection = editorContainer.host?.std.selection;
230+
231+
const { id, key, mode } = initialFocusAt;
176232

177-
return { id, key, mode, refreshKey: selector?.refreshKey };
233+
if (mode === this.mode$.value) {
234+
selection?.setGroup('scene', [
235+
selection?.create('highlight', {
236+
mode,
237+
[key]: [id],
238+
}),
239+
]);
240+
}
241+
}
242+
}
243+
244+
// update scroll position when scrollViewport scroll
245+
const saveScrollPosition = () => {
246+
if (this.mode$.value === 'page' && scrollViewport) {
247+
this.workbenchView?.setScrollPosition(scrollViewport.scrollTop);
248+
} else if (
249+
this.mode$.value === 'edgeless' &&
250+
rootService instanceof EdgelessRootService
251+
) {
252+
this.workbenchView?.setScrollPosition({
253+
centerX: rootService.viewport.centerX,
254+
centerY: rootService.viewport.centerY,
255+
zoom: rootService.viewport.zoom,
256+
});
257+
}
258+
};
259+
scrollViewport?.addEventListener('scroll', saveScrollPosition);
260+
unsubs.push(() => {
261+
scrollViewport?.removeEventListener('scroll', saveScrollPosition);
178262
});
179-
if (focusAt$.value === null && docTitle) {
180-
const title = docTitle.querySelector<
181-
HTMLElement & { inlineEditor: InlineEditor | null }
182-
>('rich-text');
183-
title?.inlineEditor?.focusEnd();
263+
if (rootService instanceof EdgelessRootService) {
264+
unsubs.push(
265+
rootService.viewport.viewportUpdated.on(saveScrollPosition).dispose
266+
);
184267
}
185268

186-
const subscription = focusAt$
269+
// update selection when focusAt$ changed
270+
const subscription = this.focusAt$
187271
.distinctUntilChanged(
188272
(a, b) =>
189273
a?.id === b?.id &&
190274
a?.key === b?.key &&
191275
a?.refreshKey === b?.refreshKey
192276
)
277+
.pipe(skip(1))
193278
.subscribe(anchor => {
194279
if (!anchor) return;
195280

@@ -207,6 +292,7 @@ export class Editor extends Entity {
207292
});
208293
unsubs.push(subscription.unsubscribe.bind(subscription));
209294

295+
// ----- Presenting -----
210296
const edgelessPage = editorContainer.host?.querySelector(
211297
'affine-edgeless-root'
212298
);
@@ -226,6 +312,7 @@ export class Editor extends Entity {
226312
}
227313

228314
return () => {
315+
this.editorContainer$.next(null);
229316
for (const unsub of unsubs) {
230317
unsub();
231318
}

packages/frontend/core/src/modules/peek-view/view/doc-preview/doc-peek-view.tsx

-2
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,6 @@ function DocPeekPreviewEditor({
108108
})
109109
);
110110

111-
editor.setEditorContainer(editorContainer);
112111
const unbind = editor.bindEditorContainer(
113112
editorContainer,
114113
(editorContainer as any).title
@@ -120,7 +119,6 @@ function DocPeekPreviewEditor({
120119

121120
return () => {
122121
unbind();
123-
editor.setEditorContainer(null);
124122
disposableGroup.dispose();
125123
};
126124
},

0 commit comments

Comments
 (0)