1 | import { PromiseDelegate } from '@lumino/coreutils';
|
2 | import { ISignal, Signal } from '@lumino/signaling';
|
3 | import { Menu } from '@lumino/widgets';
|
4 | import { CommandRegistry } from '@lumino/commands';
|
5 | import { ICommandPalette } from '@jupyterlab/apputils';
|
6 | import { INotebookTracker, NotebookPanel } from '@jupyterlab/notebook';
|
7 |
|
8 | import { ISettingRegistry } from '@jupyterlab/settingregistry';
|
9 |
|
10 | import { Stylist } from './stylist';
|
11 |
|
12 | import {
|
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 |
|
27 | import { dataURISrc } from './util';
|
28 |
|
29 | import * as SCHEMA from './schema';
|
30 |
|
31 | const ALL_PALETTE = 'Fonts';
|
32 |
|
33 | const PALETTE = {
|
34 | code: 'Fonts (Code)',
|
35 | content: 'Fonts (Content)'
|
36 | };
|
37 |
|
38 | export 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 | }
|