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