UNPKG

13.8 kBPlain TextView Raw
1import { PromiseDelegate } from '@lumino/coreutils';
2import { ISignal, Signal } from '@lumino/signaling';
3import { Menu } from '@lumino/widgets';
4import { CommandRegistry } from '@lumino/commands';
5import { ICommandPalette } from '@jupyterlab/apputils';
6import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
7
8import { ISettingRegistry } from '@jupyterlab/settingregistry';
9
10import { Stylist } from './stylist';
11
12import {
13 IFontManager,
14 PACKAGE_NAME,
15 CSS,
16 CMD,
17 TextProperty,
18 ITextStyleOptions,
19 TEXT_LABELS,
20 TextKind,
21 ROOT,
22 TEXT_OPTIONS,
23 FontFormat,
24 IFontFaceOptions
25} from '.';
26
27import { dataURISrc } from './util';
28
29import * as SCHEMA from './schema';
30
31const ALL_PALETTE = 'Fonts';
32
33const PALETTE = {
34 code: 'Fonts (Code)',
35 content: 'Fonts (Content)'
36};
37
38export class FontManager implements IFontManager {
39 protected _stylist: Stylist;
40 readonly licensePaneRequested: ISignal<any, any> = new Signal<any, any>(this);
41 private _fontFamilyMenus = new Map<TextKind, Menu>();
42 private _fontSizeMenus = new Map<TextKind, Menu>();
43 private _lineHeightMenus = new Map<TextKind, Menu>();
44 private _menu: Menu;
45 private _palette: ICommandPalette;
46 private _commands: CommandRegistry;
47 private _notebooks: INotebookTracker;
48 private _ready = new PromiseDelegate<void>();
49
50 private _settings: ISettingRegistry.ISettings;
51
52 constructor(
53 commands: CommandRegistry,
54 palette: ICommandPalette,
55 notebooks: INotebookTracker
56 ) {
57 this._stylist = new Stylist();
58 this._stylist.cacheUpdated.connect(this.settingsUpdate, this);
59 this._commands = commands;
60 this._palette = palette;
61 this._notebooks = notebooks;
62
63 this._notebooks.currentChanged.connect(this._onNotebooksChanged, this);
64
65 this.makeMenus(commands);
66 this.makeCommands();
67
68 this.hack();
69 }
70
71 get ready() {
72 return this._ready.promise;
73 }
74
75 get fonts() {
76 return this._stylist.fonts;
77 }
78
79 get enabled() {
80 if (!this.settings) {
81 return false;
82 }
83 const enabled = !!this._settings.get('enabled').composite;
84 return enabled;
85 }
86
87 set enabled(enabled) {
88 if (!this.settings) {
89 return;
90 }
91 this._settings
92 .set('enabled', enabled)
93 .then()
94 .catch(console.warn);
95 }
96
97 get settings() {
98 return this._settings;
99 }
100
101 get menu() {
102 return this._menu;
103 }
104
105 get stylesheets() {
106 return this._stylist.stylesheets;
107 }
108
109 set settings(settings) {
110 if (this._settings) {
111 this._settings.changed.disconnect(this.settingsUpdate, this);
112 }
113 this._settings = settings;
114 if (settings) {
115 settings.changed.connect(this.settingsUpdate, this);
116 }
117 this.settingsUpdate();
118 }
119
120 public async dataURISrc(
121 url: string,
122 format = FontFormat.woff2
123 ): Promise<string> {
124 return await dataURISrc(url, format);
125 }
126
127 registerFontFace(options: IFontFaceOptions): void {
128 this._stylist.fonts.set(options.name, options);
129 this.registerFontCommands(options);
130 }
131
132 getVarName(property: TextProperty, { kind }: Partial<ITextStyleOptions>) {
133 if (kind == null) {
134 return null;
135 }
136 return CSS[kind][property];
137 }
138
139 getTextStyle(property: TextProperty, { kind, notebook }: ITextStyleOptions) {
140 if (!notebook && !this.settings) {
141 return null;
142 }
143
144 try {
145 const styles: SCHEMA.IStyles = notebook?.model
146 ? (notebook.model.metadata.get(PACKAGE_NAME) as any).styles
147 : (this._settings.get('styles').composite as any);
148 let varName = this.getVarName(property, { kind });
149 if (styles != null) {
150 const rootStyle = styles[ROOT];
151 if (rootStyle == null) {
152 return null;
153 }
154 return rootStyle[varName as any];
155 }
156 } catch (err) {
157 //
158 }
159 return null;
160 }
161
162 async setTextStyle(
163 property: TextProperty,
164 value: SCHEMA.ICSSOM | null,
165 { kind, notebook }: ITextStyleOptions
166 ): Promise<void> {
167 if (!notebook && !this.settings) {
168 return;
169 }
170 let oldStyles: SCHEMA.IStyles = {};
171
172 if (notebook?.model) {
173 try {
174 oldStyles = notebook
175 ? (notebook.model.metadata.get(PACKAGE_NAME) as any).styles
176 : (this._settings.get('styles').composite as any);
177 } catch (err) {
178 //
179 }
180 }
181
182 let styles: SCHEMA.IStyles = JSON.parse(JSON.stringify(oldStyles || {}));
183 let root = (styles[ROOT] = styles[ROOT] ? styles[ROOT] : {});
184 let varName = this.getVarName(property, { kind });
185
186 if (root) {
187 if (value == null) {
188 delete root[varName as any];
189 } else {
190 root[varName as any] = value;
191 }
192 }
193
194 if (notebook) {
195 let metadata = (notebook.model?.metadata.get(PACKAGE_NAME) ||
196 {}) as SCHEMA.ISettings;
197 metadata = JSON.parse(JSON.stringify(metadata));
198 metadata.styles = styles;
199 switch (property) {
200 case 'font-family':
201 if (value != null) {
202 await this.embedFont(value, metadata);
203 }
204 break;
205 default:
206 break;
207 }
208 this.cleanMetadata(metadata);
209 notebook.model?.metadata.set(PACKAGE_NAME, metadata as any);
210 } else {
211 if (!Object.keys(styles[ROOT] || {}).length) {
212 delete styles[ROOT];
213 }
214 try {
215 await this._settings.set('styles', styles);
216 } catch (err) {
217 console.warn(err);
218 }
219 }
220 }
221
222 cleanMetadata(metadata: SCHEMA.ISettings) {
223 const rawStyle = JSON.stringify(metadata.styles, null, 2);
224 const oldFonts = Object.keys(metadata.fonts || {});
225 for (let fontFamily of oldFonts) {
226 let pattern = `'${fontFamily}'`;
227 if (rawStyle.indexOf(pattern) === -1) {
228 if (metadata.fonts) {
229 delete metadata.fonts[fontFamily];
230 }
231 if (metadata.fontLicenses) {
232 delete metadata.fontLicenses[fontFamily];
233 }
234 }
235 }
236 }
237
238 async embedFont(fontFamily: SCHEMA.ICSSOM, metadata: SCHEMA.ISettings) {
239 if (fontFamily == null) {
240 return;
241 }
242 const unquoted = (fontFamily as string).replace(/(['"]?)(.*)\1/, '$2');
243 const registered = this._stylist.fonts.get(unquoted);
244 if (!registered) {
245 return;
246 }
247 try {
248 const faces = await registered.faces();
249 const oldFaces = (metadata.fonts || {}) as SCHEMA.IFontFaceObject;
250 const oldLicenses = (metadata.fontLicenses ||
251 {}) as SCHEMA.IFontLicenseObject;
252 oldFaces[unquoted] = faces;
253 oldLicenses[unquoted] = {
254 spdx: registered.license.spdx,
255 name: registered.license.name,
256 text: await registered.license.text(),
257 holders: registered.license.holders
258 };
259 metadata.fonts = oldFaces;
260 metadata.fontLicenses = oldLicenses;
261 } catch (err) {
262 console.warn('error embedding font');
263 console.warn(err);
264 }
265 }
266
267 private _onNotebooksChanged() {
268 let styled = this._stylist.notebooks();
269
270 this._notebooks.forEach(notebook => {
271 if (styled.indexOf(notebook) === -1) {
272 this._registerNotebook(notebook);
273 }
274 });
275 }
276
277 private _registerNotebook(notebook: NotebookPanel) {
278 this._stylist.registerNotebook(notebook, true);
279 let watcher = this._notebookMetaWatcher(notebook);
280 if (notebook?.model) {
281 notebook.model.metadata.changed.connect(watcher);
282 }
283 notebook.disposed.connect(this._onNotebookDisposed);
284 watcher();
285 this.hack();
286 }
287
288 private _onNotebookDisposed(notebook: NotebookPanel) {
289 this._stylist.registerNotebook(notebook, false);
290 }
291
292 private _notebookMetaWatcher(_notebook: NotebookPanel) {
293 return () => {
294 this._notebooks.forEach(notebook => {
295 if (notebook.id !== notebook.id || !notebook.model) {
296 return;
297 }
298 const meta = notebook.model.metadata.get(
299 PACKAGE_NAME
300 ) as SCHEMA.ISettings;
301 if (meta) {
302 this._stylist.stylesheet(meta, notebook);
303 }
304 });
305 };
306 }
307
308 fontSizeOptions() {
309 return Array.from(Array(25).keys()).map(i => `${i + 8}px`);
310 }
311
312 fontSizeCommands(prefix: string) {
313 return this.fontSizeOptions().map(px => `${prefix}:${px}`);
314 }
315
316 makeCommands() {
317 [TextKind.code, TextKind.content].map(kind => {
318 ['Increase', 'Decrease'].map((label, i) => {
319 let command = `${CMD[kind].fontSize}:${label.toLowerCase()}`;
320 this._commands.addCommand(command, {
321 label: `${label} Code Font Size`,
322 execute: async () => {
323 let oldSize = this.getTextStyle('font-size', { kind }) as string;
324 let cfs = parseInt((oldSize || '0').replace(/px$/, ''), 10) || 13;
325 try {
326 await this.setTextStyle('font-size', `${cfs + (i ? -1 : 1)}px`, {
327 kind
328 });
329 } catch (err) {
330 console.warn(err);
331 }
332 },
333 isVisible: () => this.enabled
334 });
335 this._fontSizeMenus.get(kind)?.addItem({ command });
336 this._palette.addItem({ command, category: PALETTE[kind], rank: 0 });
337 });
338
339 ['line-height', 'font-size', 'font-family'].forEach(
340 (prop: TextProperty) => {
341 const command = `${kind}-${prop}:-reset`;
342 this._commands.addCommand(command, {
343 label: `Default ${kind[0].toUpperCase()}${kind.slice(1)} ${
344 TEXT_LABELS[prop]
345 }`,
346 execute: () => this.setTextStyle(prop, null, { kind }),
347 isVisible: () => this.enabled,
348 isToggled: () => this.getTextStyle(prop, { kind }) == null
349 });
350 }
351 );
352
353 TEXT_OPTIONS['line-height'](this).map(lineHeight => {
354 const command = `${CMD[kind].lineHeight}:${lineHeight}`;
355 this._commands.addCommand(command, {
356 label: `${lineHeight}`,
357 isToggled: () =>
358 this.getTextStyle('line-height', { kind }) === lineHeight,
359 isVisible: () => this.enabled,
360 execute: () => this.setTextStyle('line-height', lineHeight, { kind })
361 });
362 this._lineHeightMenus.get(kind)?.addItem({ command });
363 });
364
365 TEXT_OPTIONS['font-size'](this).map(px => {
366 const command = `${CMD[kind].fontSize}:${px}`;
367 this._commands.addCommand(command, {
368 label: `${px}`,
369 isToggled: () => this.getTextStyle('font-size', { kind }) === px,
370 isVisible: () => this.enabled,
371 execute: () => this.setTextStyle('font-size', px, { kind })
372 });
373 this._fontSizeMenus.get(kind)?.addItem({ command });
374 });
375 });
376
377 ['Enable', 'Disable'].map((label, i) => {
378 const command = `custom-fonts:${label.toLowerCase()}`;
379 this._commands.addCommand(command, {
380 label: `${label} Custom Fonts`,
381 isVisible: () => this.enabled === !!i,
382 execute: async () => {
383 if (!this._settings) {
384 return;
385 }
386 try {
387 await this._settings.set('enabled', !i);
388 } catch (err) {
389 console.warn(err);
390 }
391 }
392 });
393 this._palette.addItem({ command, category: ALL_PALETTE });
394 });
395 }
396
397 protected fontPropMenu(parent: Menu, kind: TextKind, property: TextProperty) {
398 let menu = new Menu({ commands: parent.commands });
399 menu.title.label = TEXT_LABELS[property];
400
401 menu.addItem({
402 command: `${kind}-${property}:-reset`
403 });
404
405 menu.addItem({
406 type: 'separator'
407 });
408
409 parent.addItem({ type: 'submenu', submenu: menu });
410
411 return menu;
412 }
413
414 protected makeMenus(commands: CommandRegistry) {
415 this._menu = new Menu({ commands });
416 this._menu.title.label = 'Fonts';
417
418 [TextKind.code, TextKind.content].map(kind => {
419 const submenu = new Menu({ commands });
420 submenu.title.label = kind[0].toUpperCase() + kind.slice(1);
421 this._menu.addItem({ type: 'submenu', submenu });
422
423 const family = this.fontPropMenu(submenu, kind, 'font-family');
424 const height = this.fontPropMenu(submenu, kind, 'line-height');
425 const size = this.fontPropMenu(submenu, kind, 'font-size');
426
427 this._fontFamilyMenus.set(kind, family);
428 this._lineHeightMenus.set(kind, height);
429 this._fontSizeMenus.set(kind, size);
430 });
431
432 this._menu.addItem({
433 command: CMD.editFonts,
434 args: { global: true }
435 });
436
437 this._menu.addItem({
438 command: CMD.customFonts.enable
439 });
440
441 this._menu.addItem({
442 command: CMD.customFonts.disable
443 });
444 }
445
446 settingsUpdate(): void {
447 let meta: SCHEMA.ISettings = {
448 styles: this._settings.get('styles').composite as SCHEMA.IStyles
449 };
450 if (this.enabled) {
451 this._stylist.stylesheet(meta, void 0, true);
452 } else {
453 this._stylist.hack(false);
454 }
455 }
456
457 private registerFontCommands(options: IFontFaceOptions) {
458 [TextKind.code, TextKind.content].forEach(kind => {
459 const slug = options.name.replace(/[^a-z\d]/gi, '-').toLowerCase();
460 let command = `${CMD[kind].fontFamily}:${slug}`;
461 this._commands.addCommand(command, {
462 label: options.name,
463 isToggled: () => {
464 let cff = this.getTextStyle('font-family', { kind });
465 return `${cff}`.indexOf(`'${options.name}'`) > -1;
466 },
467 isVisible: () => this.enabled,
468 execute: async () => {
469 try {
470 await this.setTextStyle('font-family', `'${options.name}'`, {
471 kind
472 });
473 } catch (err) {
474 console.warn(err);
475 }
476 }
477 });
478 this._fontFamilyMenus.get(kind)?.addItem({ command });
479 this._palette.addItem({ command, category: PALETTE[kind] });
480 });
481 }
482
483 requestLicensePane(font: any) {
484 (this.licensePaneRequested as Signal<any, void>).emit(font);
485 }
486
487 hack() {
488 this._stylist.hack();
489 this._ready.resolve(void 0);
490 }
491}