Skip to content

Commit cf5911c

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

File tree

4 files changed

+200
-5
lines changed

4 files changed

+200
-5
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: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { DocumentCommands, LibroView } from '@difizen/libro-jupyter/noeditor';
2-
import { CommandRegistry, Container, Disposable, ViewRender } from '@difizen/mana-app';
2+
import { CommandRegistry, Container, 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';
8+
import { Disposable } from '@opensumi/ide-core-common';
79
import { ReactEditorComponent } from '@opensumi/ide-editor/lib/browser/types';
810

11+
import { LibroStateManager } from './libro-state-manager';
912
import styles from './libro.module.less';
1013
import { ILibroOpensumiService } from './libro.service';
1114
import { ManaContainer } from './mana';
@@ -16,16 +19,47 @@ export const OpensumiLibroView: ReactEditorComponent = (...params) => {
1619
const libroOpensumiService = useInjectable<ILibroOpensumiService>(ILibroOpensumiService);
1720
const manaContainer = useInjectable<Container>(ManaContainer);
1821
const commandRegistry = manaContainer.get(CommandRegistry);
22+
const stateManager = useInjectable<LibroStateManager>(LibroStateManager);
1923

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

2245
React.useEffect(() => {
2346
let autoSaveHandle: undefined | number;
2447
let modelChangeDisposer: undefined | Disposable;
25-
libroOpensumiService.getOrCreateLibroView(params[0].resource.uri).then((libro) => {
48+
49+
// 监听滚动变化(防抖)
50+
const handleScroll = debounce(() => {
51+
saveNotebookState();
52+
}, 500);
53+
54+
libroOpensumiService.getOrCreateLibroView(uri).then((libro) => {
2655
setLibroView(libro);
56+
57+
// 恢复状态
58+
restoreNotebookState();
59+
60+
// 监听模型变化
2761
modelChangeDisposer = libro.model.onChanged(() => {
28-
libroOpensumiService.updateDirtyStatus(params[0].resource.uri, true);
62+
libroOpensumiService.updateDirtyStatus(uri, true);
2963
if (autoSaveHandle) {
3064
window.clearTimeout(autoSaveHandle);
3165
}
@@ -42,15 +76,37 @@ export const OpensumiLibroView: ReactEditorComponent = (...params) => {
4276
});
4377
}, AUTO_SAVE_DELAY);
4478
});
79+
4580
libro.onSave(() => {
46-
libroOpensumiService.updateDirtyStatus(params[0].resource.uri, false);
81+
libroOpensumiService.updateDirtyStatus(uri, false);
4782
});
83+
84+
const libroViewContainer = libro.container?.current;
85+
86+
if (libroViewContainer) {
87+
const libroViewContent = libroViewContainer.querySelector(LibroStateManager.LIBRO_SCROLL_ELEMENT);
88+
libroViewContent?.addEventListener('scroll', handleScroll);
89+
}
4890
});
91+
4992
return () => {
5093
modelChangeDisposer?.dispose();
5194
window.clearTimeout(autoSaveHandle);
95+
libroView?.container?.current
96+
?.querySelector(LibroStateManager.LIBRO_SCROLL_ELEMENT)
97+
?.removeEventListener('scroll', handleScroll);
5298
};
53-
}, []);
99+
}, [libroOpensumiService, uri, commandRegistry, stateManager, saveNotebookState, restoreNotebookState]);
100+
101+
// 当 notebook 完全加载后恢复状态
102+
React.useEffect(() => {
103+
if (libroView && !isStateRestored) {
104+
const timer = setTimeout(() => {
105+
restoreNotebookState();
106+
}, 100);
107+
return () => clearTimeout(timer);
108+
}
109+
}, [libroView, isStateRestored, restoreNotebookState]);
54110

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

0 commit comments

Comments
 (0)