10.5 kBPlain TextView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { ITranslator, nullTranslator } from '@jupyterlab/translation';
5import { ReactiveToolbar, Spinner, Toolbar } from '@jupyterlab/ui-components';
6import { Message, MessageLoop } from '@lumino/messaging';
7import { BoxLayout, BoxPanel, Widget } from '@lumino/widgets';
8import { DOMUtils } from './domutils';
9import { Printing } from './printing';
10
11/**
12 * A flag to indicate that event handlers are caught in the capture phase.
13 */
14const USE_CAPTURE = true;
15
16/**
17 * A widget meant to be contained in the JupyterLab main area.
18 *
19 * #### Notes
20 * Mirrors all of the `title` attributes of the content.
21 * This widget is `closable` by default.
22 * This widget is automatically disposed when closed.
23 * This widget ensures its own focus when activated.
24 */
25export class MainAreaWidget<T extends Widget = Widget>
26 extends Widget
27 implements Printing.IPrintable
28{
29 /**
30 * Construct a new main area widget.
31 *
32 * @param options - The options for initializing the widget.
33 */
34 constructor(options: MainAreaWidget.IOptions<T>) {
35 super(options);
36 this.addClass('jp-MainAreaWidget');
37 // Set contain=strict to avoid many forced layout rendering while adding cells.
38 // Don't forget to remove the CSS class when your remove the spinner to allow
39 // the content to be rendered.
40 // @see https://github.com/jupyterlab/jupyterlab/issues/9381
41 this.addClass('jp-MainAreaWidget-ContainStrict');
42 this.id = DOMUtils.createDomID();
43
44 const trans = (options.translator || nullTranslator).load('jupyterlab');
45 const content = (this._content = options.content);
46 content.node.setAttribute('role', 'region');
47 content.node.setAttribute('aria-label', trans.__('notebook content'));
48 const toolbar = (this._toolbar = options.toolbar || new ReactiveToolbar());
49 toolbar.node.setAttribute('role', 'toolbar');
50 toolbar.node.setAttribute('aria-label', trans.__('notebook actions'));
51 const contentHeader = (this._contentHeader =
52 options.contentHeader ||
53 new BoxPanel({
54 direction: 'top-to-bottom',
55 spacing: 0
56 }));
57
58 const layout = (this.layout = new BoxLayout({ spacing: 0 }));
59 layout.direction = 'top-to-bottom';
60 BoxLayout.setStretch(toolbar, 0);
61 BoxLayout.setStretch(contentHeader, 0);
62 BoxLayout.setStretch(content, 1);
63 layout.addWidget(toolbar);
64 layout.addWidget(contentHeader);
65 layout.addWidget(content);
66
67 if (!content.id) {
68 content.id = DOMUtils.createDomID();
69 }
70 content.node.tabIndex = -1;
71
72 this._updateTitle();
73 content.title.changed.connect(this._updateTitle, this);
74 this.title.closable = true;
75 this.title.changed.connect(this._updateContentTitle, this);
76
77 if (options.reveal) {
78 this.node.appendChild(this._spinner.node);
79 this._revealed = options.reveal
80 .then(() => {
81 if (content.isDisposed) {
82 this.dispose();
83 return;
84 }
85 content.disposed.connect(() => this.dispose());
86 const active = document.activeElement === this._spinner.node;
87 this._disposeSpinner();
88 this._isRevealed = true;
89 if (active) {
90 this._focusContent();
91 }
92 })
93 .catch(e => {
94 // Show a revealed promise error.
95 const error = new Widget();
96 error.addClass('jp-MainAreaWidget-error');
97 // Show the error to the user.
98 const pre = document.createElement('pre');
99 pre.textContent = String(e);
100 error.node.appendChild(pre);
101 BoxLayout.setStretch(error, 1);
102 this._disposeSpinner();
103 content.dispose();
104 this._content = null!;
105 toolbar.dispose();
106 this._toolbar = null!;
107 layout.addWidget(error);
108 this._isRevealed = true;
109 throw error;
110 });
111 } else {
112 // Handle no reveal promise.
113 this._spinner.dispose();
114 this.removeClass('jp-MainAreaWidget-ContainStrict');
115 content.disposed.connect(() => this.dispose());
116 this._isRevealed = true;
117 this._revealed = Promise.resolve(undefined);
118 }
119 }
120
121 /**
122 * Print method. Deferred to content.
123 */
124 [Printing.symbol](): Printing.OptionalAsyncThunk {
125 if (!this._content) {
126 return null;
127 }
128 return Printing.getPrintFunction(this._content);
129 }
130
131 /**
132 * The content hosted by the widget.
133 */
134 get content(): T {
135 return this._content;
136 }
137
138 /**
139 * The toolbar hosted by the widget.
140 */
141 get toolbar(): Toolbar {
142 return this._toolbar;
143 }
144
145 /**
146 * A panel for widgets that sit between the toolbar and the content.
147 * Imagine a formatting toolbar, notification headers, etc.
148 */
149 get contentHeader(): BoxPanel {
150 return this._contentHeader;
151 }
152
153 /**
154 * Whether the content widget or an error is revealed.
155 */
156 get isRevealed(): boolean {
157 return this._isRevealed;
158 }
159
160 /**
161 * A promise that resolves when the widget is revealed.
162 */
163 get revealed(): Promise<void> {
164 return this._revealed;
165 }
166
167 /**
168 * Handle `'activate-request'` messages.
169 */
170 protected onActivateRequest(msg: Message): void {
171 if (this._isRevealed) {
172 this._focusContent();
173 } else {
174 this._spinner.node.focus();
175 }
176 }
177
178 /**
179 * Handle `after-attach` messages for the widget.
180 */
181 protected onAfterAttach(msg: Message): void {
182 super.onAfterAttach(msg);
183 // Focus content in capture phase to ensure relevant commands operate on the
184 // current main area widget.
185 // Add the event listener directly instead of using `handleEvent` in order
186 // to save sub-classes from needing to reason about calling it as well.
187 this.node.addEventListener('mousedown', this._evtMouseDown, USE_CAPTURE);
188 }
189
190 /**
191 * Handle `before-detach` messages for the widget.
192 */
193 protected onBeforeDetach(msg: Message): void {
194 this.node.removeEventListener('mousedown', this._evtMouseDown, USE_CAPTURE);
195 super.onBeforeDetach(msg);
196 }
197
198 /**
199 * Handle `'close-request'` messages.
200 */
201 protected onCloseRequest(msg: Message): void {
202 this.dispose();
203 }
204
205 /**
206 * Handle `'update-request'` messages by forwarding them to the content.
207 */
208 protected onUpdateRequest(msg: Message): void {
209 if (this._content) {
210 MessageLoop.sendMessage(this._content, msg);
211 }
212 }
213
214 private _disposeSpinner() {
215 this.node.removeChild(this._spinner.node);
216 this._spinner.dispose();
217 this.removeClass('jp-MainAreaWidget-ContainStrict');
218 }
219
220 /**
221 * Update the title based on the attributes of the child widget.
222 */
223 private _updateTitle(): void {
224 if (this._changeGuard || !this.content) {
225 return;
226 }
227 this._changeGuard = true;
228 const content = this.content;
229 this.title.label = content.title.label;
230 this.title.mnemonic = content.title.mnemonic;
231 this.title.icon = content.title.icon;
232 this.title.iconClass = content.title.iconClass;
233 this.title.iconLabel = content.title.iconLabel;
234 this.title.caption = content.title.caption;
235 this.title.className = content.title.className;
236 this.title.dataset = content.title.dataset;
237 this._changeGuard = false;
238 }
239
240 /**
241 * Update the content title based on attributes of the main widget.
242 */
243 private _updateContentTitle(): void {
244 if (this._changeGuard || !this.content) {
245 return;
246 }
247 this._changeGuard = true;
248 const content = this.content;
249 content.title.label = this.title.label;
250 content.title.mnemonic = this.title.mnemonic;
251 content.title.icon = this.title.icon;
252 content.title.iconClass = this.title.iconClass;
253 content.title.iconLabel = this.title.iconLabel;
254 content.title.caption = this.title.caption;
255 content.title.className = this.title.className;
256 content.title.dataset = this.title.dataset;
257 this._changeGuard = false;
258 }
259
260 /**
261 * Give focus to the content.
262 */
263 private _focusContent(): void {
264 if (!this.content) {
265 return;
266 }
267 // Focus the content node if we aren't already focused on it or a
268 // descendent.
269 if (!this.content.node.contains(document.activeElement)) {
270 this.content.node.focus();
271 }
272
273 // Activate the content asynchronously (which may change the focus).
274 this.content.activate();
275 }
276
277 /*
278 MainAreaWidget's layout:
279 - this.layout, a BoxLayout, from parent
280 - this._toolbar, a Toolbar
281 - this._contentHeader, a BoxPanel, empty by default
282 - this._content
283 */
284 private _content: T;
285 private _toolbar: Toolbar;
286 private _contentHeader: BoxPanel;
287
288 private _changeGuard = false;
289 private _spinner = new Spinner();
290
291 private _isRevealed = false;
292 private _revealed: Promise<void>;
293 private _evtMouseDown = () => {
294 if (!this.node.contains(document.activeElement)) {
295 this._focusContent();
296 }
297 };
298}
299
300/**
301 * The namespace for the `MainAreaWidget` class statics.
302 */
303export namespace MainAreaWidget {
304 /**
305 * An options object for creating a main area widget.
306 */
307 export interface IOptions<T extends Widget = Widget> extends Widget.IOptions {
308 /**
309 * The child widget to wrap.
310 */
311 content: T;
312
313 /**
314 * The toolbar to use for the widget. Defaults to an empty toolbar.
315 */
316 toolbar?: Toolbar;
317
318 /**
319 * The layout to sit underneath the toolbar and above the content,
320 * and that extensions can populate. Defaults to an empty BoxPanel.
321 */
322 contentHeader?: BoxPanel;
323
324 /**
325 * An optional promise for when the content is ready to be revealed.
326 */
327 reveal?: Promise<any>;
328
329 /**
330 * The application language translator.
331 */
332 translator?: ITranslator;
333 }
334
335 /**
336 * An options object for main area widget subclasses providing their own
337 * default content.
338 *
339 * #### Notes
340 * This makes it easier to have a subclass that provides its own default
341 * content. This can go away once we upgrade to TypeScript 2.8 and have an
342 * easy way to make a single property optional, ala
343 * https://stackoverflow.com/a/46941824
344 */
345 export interface IOptionsOptionalContent<T extends Widget = Widget>
346 extends Widget.IOptions {
347 /**
348 * The child widget to wrap.
349 */
350 content?: T;
351
352 /**
353 * The toolbar to use for the widget. Defaults to an empty toolbar.
354 */
355 toolbar?: Toolbar;
356
357 /**
358 * An optional promise for when the content is ready to be revealed.
359 */
360 reveal?: Promise<any>;
361 }
362}