diff --git a/buildutils/src/utils.ts b/buildutils/src/utils.ts index a7805fd945e5..a29f7661f628 100644 --- a/buildutils/src/utils.ts +++ b/buildutils/src/utils.ts @@ -54,7 +54,11 @@ export function writePackageData(pkgJsonPath: string, data: any): boolean { * Read a json file. */ export function readJSONFile(filePath: string): any { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (e) { + throw `Cannot read JSON for path ${filePath}: ${e}`; + } } /** diff --git a/packages/apputils-extension/schema/print.json b/packages/apputils-extension/schema/print.json new file mode 100644 index 000000000000..8e6976f372d1 --- /dev/null +++ b/packages/apputils-extension/schema/print.json @@ -0,0 +1,14 @@ +{ + "title": "Print", + "description": "Print settings.", + "jupyter.lab.shortcuts": [ + { + "command": "apputils:print", + "keys": ["Accel P"], + "selector": "body" + } + ], + "additionalProperties": false, + "properties": {}, + "type": "object" +} diff --git a/packages/apputils-extension/src/index.ts b/packages/apputils-extension/src/index.ts index d9943021f3ba..86fae3d1680d 100644 --- a/packages/apputils-extension/src/index.ts +++ b/packages/apputils-extension/src/index.ts @@ -17,7 +17,8 @@ import { IThemeManager, IWindowResolver, ThemeManager, - WindowResolver + WindowResolver, + Printing } from '@jupyterlab/apputils'; import { @@ -69,6 +70,8 @@ namespace CommandIDs { export const resetOnLoad = 'apputils:reset-on-load'; export const saveState = 'apputils:save-statedb'; + + export const print = 'apputils:print'; } /** @@ -298,6 +301,27 @@ const splash: JupyterFrontEndPlugin = { } }; +const print: JupyterFrontEndPlugin = { + id: '@jupyterlab/apputils-extension:print', + autoStart: true, + activate: (app: JupyterFrontEnd) => { + app.commands.addCommand(CommandIDs.print, { + label: 'Print...', + isEnabled: () => { + const widget = app.shell.currentWidget; + return Printing.getPrintFunction(widget) !== null; + }, + execute: async () => { + const widget = app.shell.currentWidget; + const printFunction = Printing.getPrintFunction(widget); + if (printFunction) { + await printFunction(); + } + } + }); + } +}; + /** * The default state database for storing application state. */ @@ -567,7 +591,8 @@ const plugins: JupyterFrontEndPlugin[] = [ state, splash, themes, - themesPaletteMenu + themesPaletteMenu, + print ]; export default plugins; diff --git a/packages/apputils/src/index.ts b/packages/apputils/src/index.ts index 85b4eec37259..b8c5d22a02be 100644 --- a/packages/apputils/src/index.ts +++ b/packages/apputils/src/index.ts @@ -15,6 +15,7 @@ export * from './iframe'; export * from './inputdialog'; export * from './instancetracker'; export * from './mainareawidget'; +export * from './printing'; export * from './sanitizer'; export * from './spinner'; export * from './splash'; diff --git a/packages/apputils/src/mainareawidget.ts b/packages/apputils/src/mainareawidget.ts index 6523821de8e3..e0a875db7598 100644 --- a/packages/apputils/src/mainareawidget.ts +++ b/packages/apputils/src/mainareawidget.ts @@ -11,6 +11,8 @@ import { Toolbar } from './toolbar'; import { DOMUtils } from './domutils'; +import { Printing } from './printing'; + /** * A widget meant to be contained in the JupyterLab main area. * @@ -20,7 +22,8 @@ import { DOMUtils } from './domutils'; * This widget is automatically disposed when closed. * This widget ensures its own focus when activated. */ -export class MainAreaWidget extends Widget { +export class MainAreaWidget extends Widget + implements Printing.IPrintable { /** * Construct a new main area widget. * @@ -96,6 +99,13 @@ export class MainAreaWidget extends Widget { } } + /** + * Print method. Defered to content. + */ + [Printing.symbol](): Printing.OptionalAsyncThunk { + return Printing.getPrintFunction(this._content); + } + /** * The content hosted by the widget. */ diff --git a/packages/apputils/src/printing.ts b/packages/apputils/src/printing.ts new file mode 100644 index 000000000000..c3aede1c5582 --- /dev/null +++ b/packages/apputils/src/printing.ts @@ -0,0 +1,178 @@ +/** + * Any object is "printable" if it implements the `IPrintable` interface. + * + * To do this it, it must have a method called `Printing.symbol` which returns either a function + * to print the object or null if it cannot be printed. + * + * One way of printing is to use the `printWidget` function, which creates a hidden iframe + * and copies the DOM nodes from your widget to that iframe and printing just that iframe. + * + * Another way to print is to use the `printURL` function, which takes a URL and prints that page. + */ + +import { Widget } from '@phosphor/widgets'; +import { ServerConnection } from '@jupyterlab/services'; + +export namespace Printing { + /** + * Function that takes no arguments and when invoked prints out some object or null if printing is not defined. + */ + export type OptionalAsyncThunk = () => Promise | null; + + /** + * Symbol to use for a method that returns a function to print an object. + */ + export const symbol = Symbol('printable'); + + /** + * Objects who provide a custom way of printing themselves + * should implement this interface. + */ + export interface IPrintable { + /** + * Returns a function to print this object or null if it cannot be printed. + */ + [symbol]: () => OptionalAsyncThunk; + } + + /** + * Returns whether an object implements a print method. + */ + export function isPrintable(a: unknown): a is IPrintable { + if (typeof a !== 'object' || !a) { + return false; + } + return symbol in a; + } + + /** + * Returns the print function for an object, or null if it does not provide a handler. + */ + export function getPrintFunction(val: unknown): OptionalAsyncThunk { + if (isPrintable(val)) { + return val[symbol](); + } + return null; + } + + /** + * Prints a widget by copying it's DOM node + * to a hidden iframe and printing that iframe. + */ + export function printWidget(widget: Widget): Promise { + return printContent(widget.node); + } + + const settings = ServerConnection.makeSettings(); + /** + * Prints a URL by loading it into an iframe. + * + * @param url URL to load into an iframe. + */ + export async function printURL(url: string): Promise { + const text = await (await ServerConnection.makeRequest( + url, + {}, + settings + )).text(); + return printContent(text); + } + + /** + * Prints a URL or an element in an iframe and then removes the iframe after printing. + */ + async function printContent(textOrEl: string | HTMLElement): Promise { + const isText = typeof textOrEl === 'string'; + const iframe = createIFrame(); + + const parent = window.document.body; + parent.appendChild(iframe); + if (isText) { + iframe.srcdoc = textOrEl as string; + await resolveWhenLoaded(iframe); + } else { + iframe.src = 'about:blank'; + setIFrameNode(iframe, textOrEl as HTMLElement); + } + const printed = resolveAfterEvent(); + launchPrint(iframe.contentWindow); + // Once the print dialog has been dismissed, we regain event handling, + // and it should be safe to discard the hidden iframe. + await printed; + parent.removeChild(iframe); + } + + /** + * Creates a new hidden iframe and appends it to the document + * + * Modified from + * https://github.com/joseluisq/printd/blob/eb7948d602583c055ab6dee3ee294b6a421da4b6/src/index.ts#L24 + */ + function createIFrame(): HTMLIFrameElement { + const el = window.document.createElement('iframe'); + + // We need both allow-modals and allow-same-origin to be able to + // call print in the iframe. + // We intentionally do not allow scripts: + // https://github.com/jupyterlab/jupyterlab/pull/5850#pullrequestreview-230899790 + el.setAttribute('sandbox', 'allow-modals allow-same-origin'); + const css = + 'visibility:hidden;width:0;height:0;position:absolute;z-index:-9999;bottom:0;'; + el.setAttribute('style', css); + el.setAttribute('width', '0'); + el.setAttribute('height', '0'); + + return el; + } + + /** + * Copies a node from the base document to the iframe. + */ + function setIFrameNode(iframe: HTMLIFrameElement, node: HTMLElement) { + iframe.contentDocument.body.appendChild(node.cloneNode(true)); + iframe.contentDocument.close(); + } + + /** + * Promise that resolves when all resources are loaded in the window. + */ + function resolveWhenLoaded(iframe: HTMLIFrameElement): Promise { + return new Promise(resolve => { + iframe.onload = () => resolve(); + }); + } + + /** + * A promise that resolves after the next mousedown, mousemove, or + * keydown event. We use this as a proxy for determining when the + * main window has regained control after the print dialog is removed. + * + * We can't use the usual window.onafterprint handler because we + * disallow Javascript execution in the print iframe. + */ + function resolveAfterEvent(): Promise { + return new Promise(resolve => { + const onEvent = () => { + document.removeEventListener('mousemove', onEvent, true); + document.removeEventListener('mousedown', onEvent, true); + document.removeEventListener('keydown', onEvent, true); + resolve(); + }; + document.addEventListener('mousemove', onEvent, true); + document.addEventListener('mousedown', onEvent, true); + document.addEventListener('keydown', onEvent, true); + }); + } + + /** + * Prints a content window. + */ + function launchPrint(contentWindow: Window) { + const result = contentWindow.document.execCommand('print', false, null); + // execCommand won't work in firefox so we call the `print` method instead if it fails + // https://github.com/joseluisq/printd/blob/eb7948d602583c055ab6dee3ee294b6a421da4b6/src/index.ts#L148 + if (!result) { + contentWindow.print(); + } + } +} diff --git a/packages/coreutils/src/pageconfig.ts b/packages/coreutils/src/pageconfig.ts index dd1b44cc36c9..8036facc4375 100644 --- a/packages/coreutils/src/pageconfig.ts +++ b/packages/coreutils/src/pageconfig.ts @@ -154,6 +154,27 @@ export namespace PageConfig { return URLExt.normalize(wsUrl); } + /** + * Returns the URL converting this notebook to a certain + * format with nbconvert. + */ + export function getNBConvertURL({ + path, + format, + download + }: { + path: string; + format: string; + download: boolean; + }): string { + const notebookPath = URLExt.encodeParts(path); + const url = URLExt.join(getBaseUrl(), 'nbconvert', format, notebookPath); + if (download) { + return url + '?download=true'; + } + return url; + } + /** * Get the authorization token for a Jupyter application. */ diff --git a/packages/docregistry/src/mimedocument.ts b/packages/docregistry/src/mimedocument.ts index cdf75c6d4b24..65555f4a8ecb 100644 --- a/packages/docregistry/src/mimedocument.ts +++ b/packages/docregistry/src/mimedocument.ts @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { showErrorMessage } from '@jupyterlab/apputils'; +import { showErrorMessage, Printing } from '@jupyterlab/apputils'; import { ActivityMonitor } from '@jupyterlab/coreutils'; @@ -78,6 +78,13 @@ export class MimeContent extends Widget { */ readonly mimeType: string; + /** + * Print method. Defered to the renderer. + */ + [Printing.symbol]() { + return Printing.getPrintFunction(this.renderer); + } + /** * A promise that resolves when the widget is ready. */ diff --git a/packages/imageviewer/src/widget.ts b/packages/imageviewer/src/widget.ts index 0f9f03c7282c..d54080dbf257 100644 --- a/packages/imageviewer/src/widget.ts +++ b/packages/imageviewer/src/widget.ts @@ -3,6 +3,8 @@ import { PathExt } from '@jupyterlab/coreutils'; +import { Printing } from '@jupyterlab/apputils'; + import { ABCWidgetFactory, DocumentRegistry, @@ -24,7 +26,7 @@ const IMAGE_CLASS = 'jp-ImageViewer'; /** * A widget for images. */ -export class ImageViewer extends Widget { +export class ImageViewer extends Widget implements Printing.IPrintable { /** * Construct a new image widget. */ @@ -54,6 +56,13 @@ export class ImageViewer extends Widget { }); } + /** + * Print in iframe. + */ + [Printing.symbol]() { + return () => Printing.printWidget(this); + } + /** * The image widget's context. */ diff --git a/packages/inspector/src/inspector.ts b/packages/inspector/src/inspector.ts index 64ca4fffee6c..70a7ebffd6ac 100644 --- a/packages/inspector/src/inspector.ts +++ b/packages/inspector/src/inspector.ts @@ -1,6 +1,8 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. +import { Printing } from '@jupyterlab/apputils'; + import { Token } from '@phosphor/coreutils'; import { ISignal } from '@phosphor/signaling'; @@ -88,7 +90,8 @@ export namespace IInspector { /** * A panel which contains a set of inspectors. */ -export class InspectorPanel extends Panel implements IInspector { +export class InspectorPanel extends Panel + implements IInspector, Printing.IPrintable { /** * Construct an inspector. */ @@ -97,6 +100,13 @@ export class InspectorPanel extends Panel implements IInspector { this.addClass(PANEL_CLASS); } + /** + * Print in iframe + */ + [Printing.symbol]() { + return () => Printing.printWidget(this); + } + /** * The source of events the inspector panel listens for. */ diff --git a/packages/json-extension/package.json b/packages/json-extension/package.json index 884ff3c209b0..74a2f5968b64 100644 --- a/packages/json-extension/package.json +++ b/packages/json-extension/package.json @@ -30,6 +30,7 @@ "watch": "tsc -b --watch" }, "dependencies": { + "@jupyterlab/apputils": "^1.0.0-alpha.6", "@jupyterlab/rendermime-interfaces": "^1.3.0-alpha.6", "@jupyterlab/ui-components": "^1.0.0-alpha.6", "@phosphor/coreutils": "^1.3.0", diff --git a/packages/json-extension/src/index.tsx b/packages/json-extension/src/index.tsx index d9e6412394bc..fd5b7f2514a7 100644 --- a/packages/json-extension/src/index.tsx +++ b/packages/json-extension/src/index.tsx @@ -3,6 +3,8 @@ import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; +import { Printing } from '@jupyterlab/apputils'; + import { Message } from '@phosphor/messaging'; import { Widget } from '@phosphor/widgets'; @@ -28,7 +30,8 @@ export const MIME_TYPE = 'application/json'; /** * A renderer for JSON data. */ -export class RenderedJSON extends Widget implements IRenderMime.IRenderer { +export class RenderedJSON extends Widget + implements IRenderMime.IRenderer, Printing.IPrintable { /** * Create a new widget for rendering JSON. */ @@ -40,6 +43,10 @@ export class RenderedJSON extends Widget implements IRenderMime.IRenderer { this._mimeType = options.mimeType; } + [Printing.symbol]() { + return () => Printing.printWidget(this); + } + /** * Render JSON into this widget's node. */ diff --git a/packages/json-extension/tsconfig.json b/packages/json-extension/tsconfig.json index 6c6160cda714..ac4b7ac16078 100644 --- a/packages/json-extension/tsconfig.json +++ b/packages/json-extension/tsconfig.json @@ -6,6 +6,9 @@ }, "include": ["src/*"], "references": [ + { + "path": "../apputils" + }, { "path": "../rendermime-interfaces" }, diff --git a/packages/mainmenu-extension/src/index.ts b/packages/mainmenu-extension/src/index.ts index f8dc44a9924a..5ec7a2842199 100644 --- a/packages/mainmenu-extension/src/index.ts +++ b/packages/mainmenu-extension/src/index.ts @@ -448,12 +448,14 @@ export function createFileMenu( { command: 'filemenu:logout' }, { command: 'filemenu:shutdown' } ]; + const printGroup = [{ command: 'apputils:print' }]; menu.addGroup(newGroup, 0); menu.addGroup(newViewGroup, 1); menu.addGroup(closeGroup, 2); menu.addGroup(saveGroup, 3); menu.addGroup(reGroup, 4); + menu.addGroup(printGroup, 98); if (menu.quitEntry) { menu.addGroup(quitGroup, 99); } diff --git a/packages/notebook-extension/src/index.ts b/packages/notebook-extension/src/index.ts index a73c5f86ed43..700a839ddb0e 100644 --- a/packages/notebook-extension/src/index.ts +++ b/packages/notebook-extension/src/index.ts @@ -24,8 +24,7 @@ import { ISettingRegistry, IStateDB, nbformat, - PageConfig, - URLExt + PageConfig } from '@jupyterlab/coreutils'; import { IDocumentManager } from '@jupyterlab/docmanager'; @@ -1139,14 +1138,11 @@ function addCommands( return; } - const notebookPath = URLExt.encodeParts(current.context.path); - const url = - URLExt.join( - services.serverSettings.baseUrl, - 'nbconvert', - args['format'] as string, - notebookPath - ) + '?download=true'; + const url = PageConfig.getNBConvertURL({ + format: args['format'] as string, + download: true, + path: current.context.path + }); const child = window.open('', '_blank'); const { context } = current; diff --git a/packages/notebook/src/panel.ts b/packages/notebook/src/panel.ts index 105735a76c36..32182ae5cc18 100644 --- a/packages/notebook/src/panel.ts +++ b/packages/notebook/src/panel.ts @@ -9,7 +9,7 @@ import { Message } from '@phosphor/messaging'; import { ISignal, Signal } from '@phosphor/signaling'; -import { IClientSession } from '@jupyterlab/apputils'; +import { IClientSession, Printing } from '@jupyterlab/apputils'; import { DocumentWidget } from '@jupyterlab/docregistry'; @@ -18,6 +18,7 @@ import { RenderMimeRegistry } from '@jupyterlab/rendermime'; import { INotebookModel } from './model'; import { Notebook, StaticNotebook } from './widget'; +import { PageConfig } from '@jupyterlab/coreutils'; /** * The class name added to notebook panels. @@ -151,6 +152,26 @@ export class NotebookPanel extends DocumentWidget { this._activated.emit(void 0); } + /** + * Prints the notebook by converting to HTML with nbconvert. + */ + [Printing.symbol]() { + return async () => { + // Save before generating HTML + if (this.context.model.dirty && !this.context.model.readOnly) { + await this.context.save(); + } + + await Printing.printURL( + PageConfig.getNBConvertURL({ + format: 'html', + download: false, + path: this.context.path + }) + ); + }; + } + /** * Handle a change in the kernel by updating the document metadata. */ diff --git a/yarn.lock b/yarn.lock index 28356d3bac00..d8fd1b9d3768 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4829,8 +4829,8 @@ file-type@^10.10.0: file-uri-to-path@1: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" - fileset@^2.0.2, fileset@^2.0.3: + version "2.0.3" resolved "https://registry.yarnpkg.com/fileset/-/fileset-2.0.3.tgz#8e7548a96d3cc2327ee5e674168723a333bba2a0" dependencies: @@ -6530,8 +6530,8 @@ jest-jasmine2@^24.7.1: jest-util "^24.7.1" pretty-format "^24.7.0" throat "^4.0.0" - jest-junit@^6.3.0: + version "6.3.0" resolved "https://registry.yarnpkg.com/jest-junit/-/jest-junit-6.3.0.tgz#99e64ebc54eddcb21238f0cc49f5820c89a8c785" integrity sha512-3PH9UkpaomX6CUzqjlnk0m4yBCW/eroxV6v61OM6LkCQFO848P3YUhfIzu8ypZSBKB3vvCbB4WaLTKT0BrIf8A== @@ -8833,8 +8833,8 @@ popper.js@^1.14.1: posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" - postcss-modules-extract-imports@^2.0.0: + version "2.0.0" resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz#818719a1ae1da325f9832446b01136eeb493cd7e" integrity sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ== @@ -10712,8 +10712,8 @@ to-arraybuffer@^1.0.0: to-fast-properties@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" - to-fast-properties@^2.0.0: + version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= @@ -10962,8 +10962,8 @@ typestyle@^2.0.1: dependencies: csstype "^2.4.0" free-style "2.5.1" - ua-parser-js@^0.7.18, ua-parser-js@^0.7.9: + version "0.7.19" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b"