1 |
|
2 |
|
3 | import { URLExt } from '@jupyterlab/coreutils';
|
4 | import { nullTranslator } from '@jupyterlab/translation';
|
5 | import { DisposableDelegate } from '@lumino/disposable';
|
6 | import { Signal } from '@lumino/signaling';
|
7 | import { Dialog, showDialog } from './dialog';
|
8 |
|
9 |
|
10 |
|
11 | const REQUEST_INTERVAL = 75;
|
12 |
|
13 |
|
14 |
|
15 | const REQUEST_THRESHOLD = 20;
|
16 |
|
17 |
|
18 |
|
19 | export class ThemeManager {
|
20 | |
21 |
|
22 |
|
23 | constructor(options) {
|
24 | this._current = null;
|
25 | this._links = [];
|
26 | this._overrides = {};
|
27 | this._overrideProps = {};
|
28 | this._outstanding = null;
|
29 | this._pending = 0;
|
30 | this._requests = {};
|
31 | this._themes = {};
|
32 | this._themeChanged = new Signal(this);
|
33 | const { host, key, splash, url } = options;
|
34 | this.translator = options.translator || nullTranslator;
|
35 | this._trans = this.translator.load('jupyterlab');
|
36 | const registry = options.settings;
|
37 | this._base = url;
|
38 | this._host = host;
|
39 | this._splash = splash || null;
|
40 | void registry.load(key).then(settings => {
|
41 | this._settings = settings;
|
42 |
|
43 | this._initOverrideProps();
|
44 | this._settings.changed.connect(this._loadSettings, this);
|
45 | this._loadSettings();
|
46 | });
|
47 | }
|
48 | |
49 |
|
50 |
|
51 | get theme() {
|
52 | return this._current;
|
53 | }
|
54 | |
55 |
|
56 |
|
57 | get preferredLightTheme() {
|
58 | return this._settings.composite['preferred-light-theme'];
|
59 | }
|
60 | |
61 |
|
62 |
|
63 | get preferredDarkTheme() {
|
64 | return this._settings.composite['preferred-dark-theme'];
|
65 | }
|
66 | |
67 |
|
68 |
|
69 |
|
70 |
|
71 | get preferredTheme() {
|
72 | if (!this.isToggledAdaptiveTheme()) {
|
73 | return this.theme;
|
74 | }
|
75 | if (this.isSystemColorSchemeDark()) {
|
76 | return this.preferredDarkTheme;
|
77 | }
|
78 | return this.preferredLightTheme;
|
79 | }
|
80 | |
81 |
|
82 |
|
83 | get themes() {
|
84 | return Object.keys(this._themes);
|
85 | }
|
86 | |
87 |
|
88 |
|
89 | get lightThemes() {
|
90 | return Object.entries(this._themes)
|
91 | .filter(([_, theme]) => theme.isLight)
|
92 | .map(([name, _]) => name);
|
93 | }
|
94 | |
95 |
|
96 |
|
97 | get darkThemes() {
|
98 | return Object.entries(this._themes)
|
99 | .filter(([_, theme]) => !theme.isLight)
|
100 | .map(([name, _]) => name);
|
101 | }
|
102 | |
103 |
|
104 |
|
105 | get themeChanged() {
|
106 | return this._themeChanged;
|
107 | }
|
108 | |
109 |
|
110 |
|
111 | isSystemColorSchemeDark() {
|
112 | return (window.matchMedia &&
|
113 | window.matchMedia('(prefers-color-scheme: dark)').matches);
|
114 | }
|
115 | |
116 |
|
117 |
|
118 |
|
119 |
|
120 |
|
121 |
|
122 | getCSS(key) {
|
123 | var _a;
|
124 | return ((_a = this._overrides[key]) !== null && _a !== void 0 ? _a : getComputedStyle(document.documentElement).getPropertyValue(`--jp-${key}`));
|
125 | }
|
126 | |
127 |
|
128 |
|
129 |
|
130 |
|
131 | loadCSS(path) {
|
132 | const base = this._base;
|
133 | const href = URLExt.isLocal(path) ? URLExt.join(base, path) : path;
|
134 | const links = this._links;
|
135 | return new Promise((resolve, reject) => {
|
136 | const link = document.createElement('link');
|
137 | link.setAttribute('rel', 'stylesheet');
|
138 | link.setAttribute('type', 'text/css');
|
139 | link.setAttribute('href', href);
|
140 | link.addEventListener('load', () => {
|
141 | resolve(undefined);
|
142 | });
|
143 | link.addEventListener('error', () => {
|
144 | reject(`Stylesheet failed to load: ${href}`);
|
145 | });
|
146 | document.body.appendChild(link);
|
147 | links.push(link);
|
148 |
|
149 | this.loadCSSOverrides();
|
150 | });
|
151 | }
|
152 | |
153 |
|
154 |
|
155 |
|
156 | loadCSSOverrides() {
|
157 | var _a;
|
158 | const newOverrides = (_a = this._settings.user['overrides']) !== null && _a !== void 0 ? _a : {};
|
159 |
|
160 | Object.keys({ ...this._overrides, ...newOverrides }).forEach(key => {
|
161 | const val = newOverrides[key];
|
162 | if (val && this.validateCSS(key, val)) {
|
163 |
|
164 | document.documentElement.style.setProperty(`--jp-${key}`, val);
|
165 | }
|
166 | else {
|
167 |
|
168 | delete newOverrides[key];
|
169 | document.documentElement.style.removeProperty(`--jp-${key}`);
|
170 | }
|
171 | });
|
172 |
|
173 | this._overrides = newOverrides;
|
174 | }
|
175 | |
176 |
|
177 |
|
178 |
|
179 |
|
180 |
|
181 |
|
182 | validateCSS(key, val) {
|
183 |
|
184 | const prop = this._overrideProps[key];
|
185 | if (!prop) {
|
186 | console.warn('CSS validation failed: could not find property corresponding to key.\n' +
|
187 | `key: '${key}', val: '${val}'`);
|
188 | return false;
|
189 | }
|
190 |
|
191 | if (CSS.supports(prop, val)) {
|
192 | return true;
|
193 | }
|
194 | else {
|
195 | console.warn('CSS validation failed: invalid value.\n' +
|
196 | `key: '${key}', val: '${val}', prop: '${prop}'`);
|
197 | return false;
|
198 | }
|
199 | }
|
200 | |
201 |
|
202 |
|
203 |
|
204 |
|
205 |
|
206 |
|
207 | register(theme) {
|
208 | const { name } = theme;
|
209 | const themes = this._themes;
|
210 | if (themes[name]) {
|
211 | throw new Error(`Theme already registered for ${name}`);
|
212 | }
|
213 | themes[name] = theme;
|
214 | return new DisposableDelegate(() => {
|
215 | delete themes[name];
|
216 | });
|
217 | }
|
218 | |
219 |
|
220 |
|
221 | setCSSOverride(key, value) {
|
222 | return this._settings.set('overrides', {
|
223 | ...this._overrides,
|
224 | [key]: value
|
225 | });
|
226 | }
|
227 | |
228 |
|
229 |
|
230 | setTheme(name) {
|
231 | return this._settings.set('theme', name);
|
232 | }
|
233 | |
234 |
|
235 |
|
236 | setPreferredLightTheme(name) {
|
237 | return this._settings.set('preferred-light-theme', name);
|
238 | }
|
239 | |
240 |
|
241 |
|
242 | setPreferredDarkTheme(name) {
|
243 | return this._settings.set('preferred-dark-theme', name);
|
244 | }
|
245 | |
246 |
|
247 |
|
248 | isLight(name) {
|
249 | return this._themes[name].isLight;
|
250 | }
|
251 | |
252 |
|
253 |
|
254 |
|
255 |
|
256 |
|
257 | incrFontSize(key) {
|
258 | return this._incrFontSize(key, true);
|
259 | }
|
260 | |
261 |
|
262 |
|
263 |
|
264 |
|
265 |
|
266 | decrFontSize(key) {
|
267 | return this._incrFontSize(key, false);
|
268 | }
|
269 | |
270 |
|
271 |
|
272 |
|
273 | themeScrollbars(name) {
|
274 | return (!!this._settings.composite['theme-scrollbars'] &&
|
275 | !!this._themes[name].themeScrollbars);
|
276 | }
|
277 | |
278 |
|
279 |
|
280 | isToggledThemeScrollbars() {
|
281 | return !!this._settings.composite['theme-scrollbars'];
|
282 | }
|
283 | |
284 |
|
285 |
|
286 | toggleThemeScrollbars() {
|
287 | return this._settings.set('theme-scrollbars', !this._settings.composite['theme-scrollbars']);
|
288 | }
|
289 | |
290 |
|
291 |
|
292 | isToggledAdaptiveTheme() {
|
293 | return !!this._settings.composite['adaptive-theme'];
|
294 | }
|
295 | |
296 |
|
297 |
|
298 | toggleAdaptiveTheme() {
|
299 | return this._settings.set('adaptive-theme', !this._settings.composite['adaptive-theme']);
|
300 | }
|
301 | |
302 |
|
303 |
|
304 | getDisplayName(name) {
|
305 | var _a, _b;
|
306 | return (_b = (_a = this._themes[name]) === null || _a === void 0 ? void 0 : _a.displayName) !== null && _b !== void 0 ? _b : name;
|
307 | }
|
308 | |
309 |
|
310 |
|
311 | _incrFontSize(key, add = true) {
|
312 | var _a;
|
313 |
|
314 | const parts = ((_a = this.getCSS(key)) !== null && _a !== void 0 ? _a : '13px').split(/([a-zA-Z]+)/);
|
315 |
|
316 | const incr = (add ? 1 : -1) * (parts[1] === 'em' ? 0.1 : 1);
|
317 |
|
318 | return this.setCSSOverride(key, `${Number(parts[0]) + incr}${parts[1]}`);
|
319 | }
|
320 | |
321 |
|
322 |
|
323 | _initOverrideProps() {
|
324 | const definitions = this._settings.schema.definitions;
|
325 | const overidesSchema = definitions.cssOverrides.properties;
|
326 | Object.keys(overidesSchema).forEach(key => {
|
327 |
|
328 |
|
329 | let description;
|
330 | switch (key) {
|
331 | case 'code-font-size':
|
332 | case 'content-font-size1':
|
333 | case 'ui-font-size1':
|
334 | description = 'font-size';
|
335 | break;
|
336 | default:
|
337 | description = overidesSchema[key].description;
|
338 | break;
|
339 | }
|
340 | this._overrideProps[key] = description;
|
341 | });
|
342 | }
|
343 | |
344 |
|
345 |
|
346 | _loadSettings() {
|
347 | const outstanding = this._outstanding;
|
348 | const pending = this._pending;
|
349 | const requests = this._requests;
|
350 |
|
351 | if (pending) {
|
352 | window.clearTimeout(pending);
|
353 | this._pending = 0;
|
354 | }
|
355 | const settings = this._settings;
|
356 | const themes = this._themes;
|
357 | let theme = settings.composite['theme'];
|
358 | if (this.isToggledAdaptiveTheme()) {
|
359 | if (this.isSystemColorSchemeDark()) {
|
360 | theme = this.preferredDarkTheme;
|
361 | }
|
362 | else {
|
363 | theme = this.preferredLightTheme;
|
364 | }
|
365 | }
|
366 |
|
367 |
|
368 |
|
369 | if (outstanding) {
|
370 | outstanding
|
371 | .then(() => {
|
372 | this._loadSettings();
|
373 | })
|
374 | .catch(() => {
|
375 | this._loadSettings();
|
376 | });
|
377 | this._outstanding = null;
|
378 | return;
|
379 | }
|
380 |
|
381 | requests[theme] = requests[theme] ? requests[theme] + 1 : 1;
|
382 |
|
383 | if (themes[theme]) {
|
384 | this._outstanding = this._loadTheme(theme);
|
385 | delete requests[theme];
|
386 | return;
|
387 | }
|
388 |
|
389 | if (requests[theme] > REQUEST_THRESHOLD) {
|
390 | const fallback = settings.default('theme');
|
391 |
|
392 | delete requests[theme];
|
393 | if (!themes[fallback]) {
|
394 | this._onError(this._trans.__('Neither theme %1 nor default %2 loaded.', theme, fallback));
|
395 | return;
|
396 | }
|
397 | console.warn(`Could not load theme ${theme}, using default ${fallback}.`);
|
398 | this._outstanding = this._loadTheme(fallback);
|
399 | return;
|
400 | }
|
401 |
|
402 | this._pending = window.setTimeout(() => {
|
403 | this._loadSettings();
|
404 | }, REQUEST_INTERVAL);
|
405 | }
|
406 | |
407 |
|
408 |
|
409 |
|
410 |
|
411 |
|
412 | _loadTheme(theme) {
|
413 | var _a;
|
414 | const current = this._current;
|
415 | const links = this._links;
|
416 | const themes = this._themes;
|
417 | const splash = this._splash
|
418 | ? this._splash.show(themes[theme].isLight)
|
419 | : new DisposableDelegate(() => undefined);
|
420 |
|
421 | links.forEach(link => {
|
422 | if (link.parentElement) {
|
423 | link.parentElement.removeChild(link);
|
424 | }
|
425 | });
|
426 | links.length = 0;
|
427 | const themeProps = (_a = this._settings.schema.properties) === null || _a === void 0 ? void 0 : _a.theme;
|
428 | if (themeProps) {
|
429 | themeProps.enum = Object.keys(themes).map(value => { var _a; return (_a = themes[value].displayName) !== null && _a !== void 0 ? _a : value; });
|
430 | }
|
431 |
|
432 | const old = current ? themes[current].unload() : Promise.resolve();
|
433 | return Promise.all([old, themes[theme].load()])
|
434 | .then(() => {
|
435 | this._current = theme;
|
436 | this._themeChanged.emit({
|
437 | name: 'theme',
|
438 | oldValue: current,
|
439 | newValue: theme
|
440 | });
|
441 |
|
442 |
|
443 | this._host.hide();
|
444 |
|
445 |
|
446 | requestAnimationFrame(() => {
|
447 | this._host.show();
|
448 | Private.fitAll(this._host);
|
449 | splash.dispose();
|
450 | });
|
451 | })
|
452 | .catch(reason => {
|
453 | this._onError(reason);
|
454 | splash.dispose();
|
455 | });
|
456 | }
|
457 | |
458 |
|
459 |
|
460 | _onError(reason) {
|
461 | void showDialog({
|
462 | title: this._trans.__('Error Loading Theme'),
|
463 | body: String(reason),
|
464 | buttons: [Dialog.okButton({ label: this._trans.__('OK') })]
|
465 | });
|
466 | }
|
467 | }
|
468 |
|
469 |
|
470 |
|
471 | var Private;
|
472 | (function (Private) {
|
473 | |
474 |
|
475 |
|
476 | function fitAll(widget) {
|
477 | for (const child of widget.children()) {
|
478 | fitAll(child);
|
479 | }
|
480 | widget.fit();
|
481 | }
|
482 | Private.fitAll = fitAll;
|
483 | })(Private || (Private = {}));
|
484 |
|
\ | No newline at end of file |