Skip to content

Commit c26b5a5

Browse files
missmesswanglei75github-actions[bot]
authored
ft: notebook add toc extension (#375)
* ft: notebook add toc extension * Automatic application of license header --------- Co-authored-by: wanglei75 <[email protected]> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 73be102 commit c26b5a5

File tree

8 files changed

+456
-1
lines changed

8 files changed

+456
-1
lines changed

packages/react/src/components/notebook/Notebook.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type IDatalayerNotebookExtensionProps = {
5656
notebookId: string;
5757
commands: CommandRegistry;
5858
panel: NotebookPanel;
59+
adapter: NotebookAdapter;
5960
};
6061

6162
export type DatalayerNotebookExtension = DocumentRegistry.IWidgetExtension<
@@ -168,6 +169,7 @@ export const Notebook = (props: INotebookProps) => {
168169
notebookId: id,
169170
commands: adapter.commands,
170171
panel: adapter.notebookPanel!,
172+
adapter,
171173
});
172174
extension.createNew(adapter.notebookPanel!, adapter.context!);
173175
setExtensionComponents(

packages/react/src/components/notebook/NotebookState.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useStore } from 'zustand';
99
import { ReactPortal } from 'react';
1010
import { INotebookModel } from '@jupyterlab/notebook';
1111
import * as nbformat from '@jupyterlab/nbformat';
12+
import { TableOfContents } from '@jupyterlab/toc';
1213
import { Cell, ICellModel } from '@jupyterlab/cells';
1314
import { NotebookChange } from '@jupyter/ydoc';
1415
import { Kernel as JupyterKernel } from '@jupyterlab/services';
@@ -23,6 +24,7 @@ export type PortalDisplay = {
2324

2425
export type INotebookState = {
2526
model?: INotebookModel;
27+
tocModel?: TableOfContents.Model;
2628
adapter?: NotebookAdapter;
2729
saveRequest?: Date;
2830
activeCell?: Cell<ICellModel>;
@@ -48,6 +50,10 @@ type NotebookModelId = {
4850
id: string;
4951
notebookModel: INotebookModel;
5052
};
53+
type TocModelId = {
54+
id: string;
55+
tocModel: TableOfContents.Model;
56+
};
5157
type CellModelId = {
5258
id: string;
5359
cellModel?: Cell<ICellModel>;
@@ -83,6 +89,7 @@ export type NotebookState = INotebooksState & {
8389
selectNotebook: (id: string) => INotebookState | undefined;
8490
selectNotebookAdapter: (id: string) => NotebookAdapter | undefined;
8591
selectNotebookModel: (id: string) => { model: INotebookModel | undefined; changed: any } | undefined;
92+
selectTocModel: (id: string) => TableOfContents.Model | undefined;
8693
selectKernelStatus: (id: string) => string | undefined;
8794
selectActiveCell: (id: string) => Cell<ICellModel> | undefined;
8895
selectNotebookPortals: (id: string) => React.ReactPortal[] | undefined;
@@ -100,6 +107,7 @@ export type NotebookState = INotebooksState & {
100107
update: (update: NotebookUpdate) => void;
101108
activeCellChange: (cellModelId: CellModelId) => void;
102109
changeModel: (notebookModelId: NotebookModelId) => void;
110+
changeTocModel: (tocModelId: TocModelId) => void;
103111
changeNotebook: (notebookChangeId: NotebookChangeId) => void;
104112
changeKernelStatus: (kernelStatusId: KernelStatusMutation) => void;
105113
changeKernel: (kernelChange: KernelChangeMutation) => void;
@@ -131,6 +139,9 @@ export const notebookStore = createStore<NotebookState>((set, get) => ({
131139
}
132140
return undefined;
133141
},
142+
selectTocModel: (id: string): TableOfContents.Model | undefined => {
143+
return get().notebooks.get(id)?.tocModel;
144+
},
134145
selectKernelStatus: (id: string): string | undefined => {
135146
return get().notebooks.get(id)?.kernelStatus;
136147
},
@@ -207,9 +218,16 @@ export const notebookStore = createStore<NotebookState>((set, get) => ({
207218
set((state: NotebookState) => ({ notebooks }));
208219
}
209220
},
221+
changeTocModel: (tocModelId: TocModelId) => {
222+
const notebooks = get().notebooks;
223+
const notebook = notebooks.get(tocModelId.id);
224+
if (notebook) {
225+
notebook.tocModel = tocModelId.tocModel;
226+
set((state: NotebookState) => ({ notebooks }));
227+
}
228+
},
210229
changeNotebook: (notebookChangeId: NotebookChangeId) => {
211230
const notebooks = get().notebooks;
212-
const notebook = notebooks.get(notebookChangeId.id);
213231
if (notebook) {
214232
notebook.notebookChange = notebookChangeId.notebookChange;
215233
set((state: NotebookState) => ({ notebooks }));
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright (c) 2021-2023 Datalayer, Inc.
3+
*
4+
* MIT License
5+
*/
6+
7+
import { INotebookContent } from '@jupyterlab/nbformat';
8+
import { useMemo, useState } from 'react';
9+
import { createRoot } from 'react-dom/client';
10+
import { Notebook } from '../components/notebook/Notebook';
11+
import { JupyterReactTheme } from '../theme/JupyterReactTheme';
12+
import { NotebookToolbar } from '../components/notebook/toolbar/NotebookToolbar';
13+
import { TocExtension } from './extensions/toc/TocExtension';
14+
import { ReactLayoutFactory } from './extensions/toc/ReactLayoutFactory';
15+
import nbformat from './notebooks/NotebookToCExample.ipynb.json';
16+
import { Box, Button } from '@primer/react';
17+
import { JupyterLayoutFactory } from './extensions/toc/JupyterLayoutFactory';
18+
19+
const NotebookToc = () => {
20+
const [layout, setLayout] = useState<'react' | 'jupyter'>('jupyter');
21+
22+
const extensions = useMemo(
23+
() => [
24+
new TocExtension({
25+
factory:
26+
layout === 'react'
27+
? new ReactLayoutFactory()
28+
: new JupyterLayoutFactory(),
29+
}),
30+
],
31+
[layout]
32+
);
33+
return (
34+
<JupyterReactTheme>
35+
<Box>
36+
<Button
37+
onClick={() => {
38+
setLayout(layout === 'react' ? 'jupyter' : 'react');
39+
}}
40+
>
41+
Use {layout === 'react' ? 'Jupyter' : 'React'} Layout
42+
</Button>
43+
</Box>
44+
<Notebook
45+
key={layout}
46+
nbformat={nbformat as INotebookContent}
47+
extensions={extensions}
48+
id="notebook-toc-id"
49+
height="calc(100vh - 2.6rem)" // (Height - Toolbar Height).
50+
Toolbar={NotebookToolbar}
51+
serverless
52+
/>
53+
</JupyterReactTheme>
54+
);
55+
};
56+
57+
const div = document.createElement('div');
58+
document.body.appendChild(div);
59+
const root = createRoot(div);
60+
61+
root.render(<NotebookToc />);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright (c) 2021-2023 Datalayer, Inc.
3+
*
4+
* MIT License
5+
*/
6+
7+
import { NotebookPanel } from '@jupyterlab/notebook';
8+
import { TocLayoutFactory } from './TocExtension';
9+
import { TableOfContents, TableOfContentsPanel } from '@jupyterlab/toc';
10+
import { BoxPanel } from '@lumino/widgets';
11+
12+
/**
13+
* Jupyter ToC Layout Factory.
14+
* Insert ToC Panel into (Lumino) notebook BoxPanel. (Default: left side and 50% stretch)
15+
*/
16+
export class JupyterLayoutFactory implements TocLayoutFactory {
17+
private _tocPanel: TableOfContentsPanel;
18+
private _config: Record<string, any>;
19+
20+
constructor(config?: Record<string, any>) {
21+
this._config = config ?? {};
22+
this._tocPanel = new TableOfContentsPanel();
23+
}
24+
25+
layout(panel: BoxPanel, notebookPanel: NotebookPanel, notebookId: string) {
26+
panel.direction = this._config?.direction ?? 'left-to-right';
27+
panel.insertWidget(this._config?.index ?? 1, this._tocPanel);
28+
BoxPanel.setStretch(this._tocPanel, this._config?.stretch ?? 0);
29+
BoxPanel.setSizeBasis(this._tocPanel, this._config?.sizeBasis ?? 0);
30+
return null;
31+
}
32+
33+
setModel(model: TableOfContents.Model) {
34+
this._tocPanel.model = model;
35+
}
36+
37+
dispose() {
38+
this._tocPanel.dispose();
39+
}
40+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) 2021-2023 Datalayer, Inc.
3+
*
4+
* MIT License
5+
*/
6+
7+
import { NotebookPanel } from '@jupyterlab/notebook';
8+
import { TocLayoutFactory } from './TocExtension';
9+
import { TableOfContents } from '@jupyterlab/toc';
10+
import { BoxPanel } from '@lumino/widgets';
11+
import TocComponent from './TocComponent';
12+
import { Box } from '@primer/react';
13+
14+
/**
15+
* React ToC Layout Factory.
16+
*/
17+
export class ReactLayoutFactory implements TocLayoutFactory {
18+
constructor() {}
19+
20+
layout(panel: BoxPanel, notebookPanel: NotebookPanel, notebookId: string) {
21+
return (
22+
<Box
23+
position="fixed"
24+
top="2.6rem"
25+
right={0}
26+
width="200px"
27+
height="100%"
28+
style={{
29+
float: 'right',
30+
zIndex: 1000,
31+
}}
32+
>
33+
<TocComponent notebookId={notebookId} />
34+
</Box>
35+
);
36+
}
37+
38+
setModel(model: TableOfContents.Model) {
39+
// React will get model from notebookStore
40+
}
41+
42+
dispose() {
43+
// React will dispose automatically
44+
}
45+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright (c) 2021-2023 Datalayer, Inc.
3+
*
4+
* MIT License
5+
*/
6+
7+
import { TableOfContents, TableOfContentsTree } from '@jupyterlab/toc';
8+
import { useEffect, useState } from 'react';
9+
import { useNotebookStore } from '../../../components';
10+
11+
export interface TocTreeProps {
12+
notebookId: string;
13+
}
14+
15+
/** Custom CSS Variables */
16+
const CustomCssVarStyles = {
17+
'--base-height-multiplier': '8', // Size scaling ratio
18+
'--jp-inverse-layout-color3': '#a8a8a8', // Icon color
19+
'--type-ramp-base-font-size': '14px', // Font size
20+
} as React.CSSProperties;
21+
22+
/** Table of Contents Tree Component */
23+
const TocTree = ({ notebookId }: TocTreeProps) => {
24+
const model = useNotebookStore(state => state.selectTocModel(notebookId));
25+
const [, setCount] = useState(0);
26+
const update = () => setCount(c => c + 1);
27+
28+
useEffect(() => {
29+
if (model) {
30+
model.isActive = true;
31+
// model change not trigger react update, so we need to manually trigger
32+
model.stateChanged.connect(update);
33+
}
34+
return () => {
35+
if (model) {
36+
model.isActive = false;
37+
model.stateChanged.disconnect(update);
38+
}
39+
};
40+
}, [model, update]);
41+
42+
return model && model.headings.length > 0 ? (
43+
<section style={CustomCssVarStyles}>
44+
<TableOfContentsTree
45+
activeHeading={model.activeHeading}
46+
documentType={model.documentType}
47+
headings={model.headings}
48+
onCollapseChange={(heading: TableOfContents.IHeading) => {
49+
model!.toggleCollapse({ heading });
50+
}}
51+
setActiveHeading={(heading: TableOfContents.IHeading) => {
52+
model!.setActiveHeading(heading);
53+
}}
54+
/>
55+
</section>
56+
) : (
57+
<>Empty</>
58+
);
59+
};
60+
61+
export default TocTree;

0 commit comments

Comments
 (0)