Skip to content

Commit bdfd315

Browse files
committed
feat: notebook support keep scroll position when tab switched
1 parent 8323ec3 commit bdfd315

File tree

4 files changed

+198
-4
lines changed

4 files changed

+198
-4
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { LibroView } from '@difizen/libro-jupyter/noeditor';
2+
3+
import { Autowired, Injectable } from '@opensumi/di';
4+
import { StorageProvider, URI } from '@opensumi/ide-core-common';
5+
6+
export interface INotebookViewState {
7+
uri: string;
8+
scrollTop: number;
9+
lastActiveTime: number;
10+
}
11+
12+
export interface INotebookStateManager {
13+
saveState(uri: URI, libroView: LibroView): void;
14+
restoreState(uri: URI, libroView: LibroView): Promise<boolean>;
15+
clearState(uri: URI): void;
16+
getAllStates(): Record<string, INotebookViewState>;
17+
}
18+
19+
@Injectable()
20+
export class LibroStateManager implements INotebookStateManager {
21+
private static readonly STORAGE_KEY = 'libro-notebook-states';
22+
public static readonly LIBRO_SCROLL_ELEMENT = '.libro-view-content';
23+
24+
@Autowired(StorageProvider)
25+
private readonly storageProvider: StorageProvider;
26+
27+
private stateCache = new Map<string, INotebookViewState>();
28+
29+
constructor() {
30+
this.loadFromStorage();
31+
}
32+
33+
private async loadFromStorage() {
34+
try {
35+
const storage = await this.storageProvider(new URI('libro-notebook-storage'));
36+
const stored = await storage.get(LibroStateManager.STORAGE_KEY);
37+
if (stored && typeof stored === 'object') {
38+
Object.entries(stored).forEach(([uri, state]) => {
39+
if (this.isValidState(state)) {
40+
this.stateCache.set(uri, state as INotebookViewState);
41+
}
42+
});
43+
}
44+
} catch (error) {
45+
// eslint-disable-next-line no-console
46+
console.warn('Failed to load notebook states from storage:', error);
47+
}
48+
}
49+
50+
private async saveToStorage() {
51+
try {
52+
const storage = await this.storageProvider(new URI('libro-notebook-storage'));
53+
const states: Record<string, INotebookViewState> = {};
54+
this.stateCache.forEach((state, uri) => {
55+
states[uri] = state;
56+
});
57+
await storage.set(LibroStateManager.STORAGE_KEY, states);
58+
} catch (error) {
59+
// eslint-disable-next-line no-console
60+
console.warn('Failed to save notebook states to storage:', error);
61+
}
62+
}
63+
64+
private isValidState(state: any): state is INotebookViewState {
65+
return state && typeof state === 'object' && typeof state.uri === 'string' && typeof state.scrollTop === 'number';
66+
}
67+
68+
saveState(uri: URI, libroView: LibroView): void {
69+
if (!libroView || !libroView.model || !libroView.container) {
70+
return;
71+
}
72+
73+
try {
74+
const libroViewContent = libroView.container.current?.querySelector(LibroStateManager.LIBRO_SCROLL_ELEMENT);
75+
const state: INotebookViewState = {
76+
uri: uri.toString(),
77+
scrollTop: libroViewContent?.scrollTop || 0,
78+
lastActiveTime: Date.now(),
79+
};
80+
81+
this.stateCache.set(uri.toString(), state);
82+
this.saveToStorage();
83+
} catch (error) {
84+
// eslint-disable-next-line no-console
85+
console.warn('Failed to save notebook state:', error);
86+
}
87+
}
88+
89+
async restoreState(uri: URI, libroView: LibroView): Promise<boolean> {
90+
const state = this.stateCache.get(uri.toString());
91+
if (!state || !libroView || !libroView.model) {
92+
return false;
93+
}
94+
95+
try {
96+
const libroViewContent = libroView.container?.current?.querySelector(LibroStateManager.LIBRO_SCROLL_ELEMENT);
97+
// 恢复 notebook 的滚动位置
98+
if (libroViewContent) {
99+
libroViewContent.scrollTop = state.scrollTop;
100+
}
101+
102+
return true;
103+
} catch (error) {
104+
// eslint-disable-next-line no-console
105+
console.warn('Failed to restore notebook state:', error);
106+
return false;
107+
}
108+
}
109+
110+
clearState(uri: URI): void {
111+
this.stateCache.delete(uri.toString());
112+
this.saveToStorage();
113+
}
114+
115+
getAllStates(): Record<string, INotebookViewState> {
116+
const states: Record<string, INotebookViewState> = {};
117+
this.stateCache.forEach((state, uri) => {
118+
states[uri] = state;
119+
});
120+
return states;
121+
}
122+
123+
getState(uri: URI): INotebookViewState | undefined {
124+
return this.stateCache.get(uri.toString());
125+
}
126+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Provider } from '@opensumi/di';
2+
3+
import { LibroStateManager } from './libro-state-manager';
4+
5+
export const LibroStateModule: Provider[] = [
6+
{
7+
token: LibroStateManager,
8+
useClass: LibroStateManager,
9+
},
10+
];

packages/notebook/src/browser/libro.contribution.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { LibroOpensumiModule } from './libro';
4444
import { LibroDiffModule } from './libro/diff-view';
4545
import { LibroOpener } from './libro-opener';
4646
import { LibroVersionPreview } from './libro-preview.view';
47+
import { LibroStateModule } from './libro-state';
4748
import { initLibroColorToken } from './libro.color.tokens';
4849
import { LIBRO_COMPONENTS_SCHEME_ID, LIBRO_COMPONENT_ID, LIBRO_PREVIEW_COMPONENT_ID } from './libro.protocol';
4950
import { OpensumiLibroView } from './libro.view';
@@ -132,6 +133,8 @@ export class LibroContribution
132133

133134
initialize(app: IClientApp) {
134135
initLibroOpensumi(app.injector, manaContainer);
136+
// 注册状态管理模块
137+
app.injector.addProviders(...LibroStateModule);
135138
}
136139

137140
registerComponent(registry: ComponentRegistry) {

packages/notebook/src/browser/libro.view.tsx

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { DocumentCommands, LibroView } from '@difizen/libro-jupyter/noeditor';
22
import { CommandRegistry, Container, Disposable, ViewRender } from '@difizen/mana-app';
3+
import debounce from 'lodash/debounce';
34
import * as React from 'react';
45

56
import { message } from '@opensumi/ide-components';
67
import { localize, useInjectable } from '@opensumi/ide-core-browser';
78
import { ReactEditorComponent } from '@opensumi/ide-editor/lib/browser/types';
89

10+
import { LibroStateManager } from './libro-state-manager';
911
import styles from './libro.module.less';
1012
import { ILibroOpensumiService } from './libro.service';
1113
import { ManaContainer } from './mana';
@@ -16,16 +18,47 @@ export const OpensumiLibroView: ReactEditorComponent = (...params) => {
1618
const libroOpensumiService = useInjectable<ILibroOpensumiService>(ILibroOpensumiService);
1719
const manaContainer = useInjectable<Container>(ManaContainer);
1820
const commandRegistry = manaContainer.get(CommandRegistry);
21+
const stateManager = useInjectable<LibroStateManager>(LibroStateManager);
1922

2023
const [libroView, setLibroView] = React.useState<LibroView | undefined>(undefined);
24+
const [isStateRestored, setIsStateRestored] = React.useState(false);
25+
const uri = params[0].resource.uri;
26+
27+
// 保存状态的函数
28+
const saveNotebookState = React.useCallback(() => {
29+
if (libroView && uri) {
30+
stateManager.saveState(uri, libroView);
31+
}
32+
}, [libroView, uri, stateManager]);
33+
34+
// 恢复状态的函数
35+
const restoreNotebookState = React.useCallback(async () => {
36+
if (libroView && uri && !isStateRestored) {
37+
const restored = await stateManager.restoreState(uri, libroView);
38+
if (restored) {
39+
setIsStateRestored(true);
40+
}
41+
}
42+
}, [libroView, uri, stateManager, isStateRestored]);
2143

2244
React.useEffect(() => {
2345
let autoSaveHandle: undefined | number;
2446
let modelChangeDisposer: undefined | Disposable;
25-
libroOpensumiService.getOrCreateLibroView(params[0].resource.uri).then((libro) => {
47+
48+
// 监听滚动变化(防抖)
49+
const handleScroll = debounce(() => {
50+
saveNotebookState();
51+
}, 500);
52+
53+
libroOpensumiService.getOrCreateLibroView(uri).then((libro) => {
2654
setLibroView(libro);
55+
56+
// 恢复状态
57+
restoreNotebookState();
58+
59+
// 监听模型变化
2760
modelChangeDisposer = libro.model.onChanged(() => {
28-
libroOpensumiService.updateDirtyStatus(params[0].resource.uri, true);
61+
libroOpensumiService.updateDirtyStatus(uri, true);
2962
if (autoSaveHandle) {
3063
window.clearTimeout(autoSaveHandle);
3164
}
@@ -42,15 +75,37 @@ export const OpensumiLibroView: ReactEditorComponent = (...params) => {
4275
});
4376
}, AUTO_SAVE_DELAY);
4477
});
78+
4579
libro.onSave(() => {
46-
libroOpensumiService.updateDirtyStatus(params[0].resource.uri, false);
80+
libroOpensumiService.updateDirtyStatus(uri, false);
4781
});
82+
83+
const libroViewContainer = libro.container?.current;
84+
85+
if (libroViewContainer) {
86+
const libroViewContent = libroViewContainer.querySelector(LibroStateManager.LIBRO_SCROLL_ELEMENT);
87+
libroViewContent?.addEventListener('scroll', handleScroll);
88+
}
4889
});
90+
4991
return () => {
5092
modelChangeDisposer?.dispose();
5193
window.clearTimeout(autoSaveHandle);
94+
libroView?.container?.current
95+
?.querySelector(LibroStateManager.LIBRO_SCROLL_ELEMENT)
96+
?.removeEventListener('scroll', handleScroll);
5297
};
53-
}, []);
98+
}, [libroOpensumiService, uri, commandRegistry, stateManager, saveNotebookState, restoreNotebookState]);
99+
100+
// 当 notebook 完全加载后恢复状态
101+
React.useEffect(() => {
102+
if (libroView && !isStateRestored) {
103+
const timer = setTimeout(() => {
104+
restoreNotebookState();
105+
}, 100);
106+
return () => clearTimeout(timer);
107+
}
108+
}, [libroView, isStateRestored, restoreNotebookState]);
54109

55110
return <div className={styles.libroView}>{libroView && <ViewRender view={libroView}></ViewRender>}</div>;
56111
};

0 commit comments

Comments
 (0)