Added support for taking screenshot during Notebook publish to Gallery (#108)

* Added support for taking screenshot

- Screenshot is taken using html2canvas package
- Converted to base 64 and uploaded to metadata
- For Using first display output
  - Notebok object is passed instead of string, to publish pane
  - The first cell with output present is parsed out
  - The dom is also parsed to get corresponding div element to take screenshot of the first output

* fixed bug

* Addressed PR comments

- FIxed bug that didn't capture screenshot when mutiple notebook tabs are opened

* removed unnecessary dependencies

* fixed compile issues

* more edits
This commit is contained in:
Srinath Narayanan 2020-07-23 00:43:53 -07:00 committed by GitHub
parent acc65c9588
commit dc67c5f40b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 525 additions and 208 deletions

21
package-lock.json generated
View File

@ -9519,6 +9519,11 @@
"resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz",
"integrity": "sha1-4pf2DX7BAUp6lxo568ipjAtoHnA=" "integrity": "sha1-4pf2DX7BAUp6lxo568ipjAtoHnA="
}, },
"base64-arraybuffer": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz",
"integrity": "sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ=="
},
"base64-js": { "base64-js": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
@ -10874,6 +10879,14 @@
"resolved": "https://registry.npmjs.org/css-element-queries/-/css-element-queries-1.1.1.tgz", "resolved": "https://registry.npmjs.org/css-element-queries/-/css-element-queries-1.1.1.tgz",
"integrity": "sha512-/PX6Bkk77ShgbOx/mpawHdEvS3PGgy1mmMktcztDPndWdMJxcorcQiivrs+nEljqtBpvNEhAmQky9tQR6FSm8Q==" "integrity": "sha512-/PX6Bkk77ShgbOx/mpawHdEvS3PGgy1mmMktcztDPndWdMJxcorcQiivrs+nEljqtBpvNEhAmQky9tQR6FSm8Q=="
}, },
"css-line-break": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-1.1.1.tgz",
"integrity": "sha512-1feNVaM4Fyzdj4mKPIQNL2n70MmuYzAXZ1aytlROFX1JsOo070OsugwGjj7nl6jnDJWHDM8zRZswkmeYVWZJQA==",
"requires": {
"base64-arraybuffer": "^0.2.0"
}
},
"css-loader": { "css-loader": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-loader/-/css-loader-1.0.0.tgz", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-1.0.0.tgz",
@ -15557,6 +15570,14 @@
} }
} }
}, },
"html2canvas": {
"version": "1.0.0-rc.5",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.0.0-rc.5.tgz",
"integrity": "sha512-DtNqPxJNXPoTajs+lVQzGS1SULRI4GQaROeU5R41xH8acffHukxRh/NBVcTBsfCkJSkLq91rih5TpbEwUP9yWA==",
"requires": {
"css-line-break": "1.1.1"
}
},
"htmlparser2": { "htmlparser2": {
"version": "3.10.1", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",

View File

@ -54,6 +54,7 @@
"es6-symbol": "3.1.3", "es6-symbol": "3.1.3",
"eslint-plugin-jest": "23.13.2", "eslint-plugin-jest": "23.13.2",
"hasher": "1.2.0", "hasher": "1.2.0",
"html2canvas": "1.0.0-rc.5",
"immutable": "4.0.0-rc.12", "immutable": "4.0.0-rc.12",
"is-ci": "2.0.0", "is-ci": "2.0.0",
"jquery": "3.5.1", "jquery": "3.5.1",

View File

@ -36,6 +36,8 @@ export interface GalleryCardComponentProps {
export class GalleryCardComponent extends React.Component<GalleryCardComponentProps> { export class GalleryCardComponent extends React.Component<GalleryCardComponentProps> {
public static readonly CARD_WIDTH = 256; public static readonly CARD_WIDTH = 256;
private static readonly cardImageHeight = 144; private static readonly cardImageHeight = 144;
public static readonly cardHeightToWidthRatio =
GalleryCardComponent.cardImageHeight / GalleryCardComponent.CARD_WIDTH;
private static readonly cardDescriptionMaxChars = 88; private static readonly cardDescriptionMaxChars = 88;
private static readonly cardItemGapBig = 10; private static readonly cardItemGapBig = 10;
private static readonly cardItemGapSmall = 8; private static readonly cardItemGapSmall = 8;

View File

@ -2347,9 +2347,9 @@ export default class Explorer {
return Promise.resolve(false); return Promise.resolve(false);
} }
public publishNotebook(name: string, content: string): void { public publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): void {
if (this.notebookManager) { if (this.notebookManager) {
this.notebookManager.openPublishNotebookPane(name, content); this.notebookManager.openPublishNotebookPane(name, content, parentDomElement);
this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter; this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter;
this.isPublishNotebookPaneEnabled(true); this.isPublishNotebookPaneEnabled(true);
} }

View File

@ -8,6 +8,7 @@ import { actions, createContentRef, createKernelRef, selectors } from "@nteract/
import VirtualCommandBarComponent from "./VirtualCommandBarComponent"; import VirtualCommandBarComponent from "./VirtualCommandBarComponent";
import { NotebookContentItem } from "../NotebookContentItem"; import { NotebookContentItem } from "../NotebookContentItem";
import { NotebookComponentBootstrapper } from "./NotebookComponentBootstrapper"; import { NotebookComponentBootstrapper } from "./NotebookComponentBootstrapper";
import { CdbAppState } from "./types";
export interface NotebookComponentAdapterOptions { export interface NotebookComponentAdapterOptions {
contentItem: NotebookContentItem; contentItem: NotebookContentItem;
@ -18,6 +19,7 @@ export interface NotebookComponentAdapterOptions {
export class NotebookComponentAdapter extends NotebookComponentBootstrapper implements ReactAdapter { export class NotebookComponentAdapter extends NotebookComponentBootstrapper implements ReactAdapter {
private onUpdateKernelInfo: () => void; private onUpdateKernelInfo: () => void;
public getNotebookParentElement: () => HTMLElement;
public parameters: any; public parameters: any;
constructor(options: NotebookComponentAdapterOptions) { constructor(options: NotebookComponentAdapterOptions) {
@ -44,6 +46,11 @@ export class NotebookComponentAdapter extends NotebookComponentBootstrapper impl
}) })
); );
} }
this.getNotebookParentElement = () => {
const cdbAppState = this.getStore().getState() as CdbAppState;
return cdbAppState.cdb.currentNotebookParentElements.get(this.contentRef);
};
} }
protected renderExtraComponent = (): JSX.Element => { protected renderExtraComponent = (): JSX.Element => {

View File

@ -17,7 +17,7 @@ import {
} from "@nteract/core"; } from "@nteract/core";
import * as Immutable from "immutable"; import * as Immutable from "immutable";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { CellType, CellId, toJS } from "@nteract/commutable"; import { CellType, CellId, ImmutableNotebook } from "@nteract/commutable";
import { Store, AnyAction } from "redux"; import { Store, AnyAction } from "redux";
import "./NotebookComponent.less"; import "./NotebookComponent.less";
@ -71,14 +71,14 @@ export class NotebookComponentBootstrapper {
); );
} }
public getContent(): { name: string; content: string } { public getContent(): { name: string; content: string | ImmutableNotebook } {
const record = this.getStore() const record = this.getStore()
.getState() .getState()
.core.entities.contents.byRef.get(this.contentRef); .core.entities.contents.byRef.get(this.contentRef);
let content: string; let content: string | ImmutableNotebook;
switch (record.model.type) { switch (record.model.type) {
case "notebook": case "notebook":
content = JSON.stringify(toJS(record.model.notebook)); content = record.model.notebook;
break; break;
case "file": case "file":
content = record.model.text; content = record.model.text;

View File

@ -84,3 +84,22 @@ export const traceNotebookTelemetry = (payload: {
payload payload
}; };
}; };
export const UPDATE_NOTEBOOK_PARENT_DOM_ELTS = "UPDATE_NOTEBOOK_PARENT_DOM_ELTS";
export interface UpdateNotebookParentDomEltAction {
type: "UPDATE_NOTEBOOK_PARENT_DOM_ELTS";
payload: {
contentRef: ContentRef;
parentElt: HTMLElement;
};
}
export const UpdateNotebookParentDomElt = (payload: {
contentRef: ContentRef;
parentElt: HTMLElement;
}): UpdateNotebookParentDomEltAction => {
return {
type: UPDATE_NOTEBOOK_PARENT_DOM_ELTS,
payload
};
};

View File

@ -82,6 +82,19 @@ export const cdbReducer = (state: CdbRecord, action: Action) => {
}); });
return state; return state;
} }
case cdbActions.UPDATE_NOTEBOOK_PARENT_DOM_ELTS: {
const typedAction = action as cdbActions.UpdateNotebookParentDomEltAction;
var parentEltsMap = state.get("currentNotebookParentElements");
const contentRef = typedAction.payload.contentRef;
const parentElt = typedAction.payload.parentElt;
if (parentElt) {
parentEltsMap.set(contentRef, parentElt);
} else {
parentEltsMap.delete(contentRef);
}
return state.set("currentNotebookParentElements", parentEltsMap);
}
} }
return state; return state;
}; };

View File

@ -1,5 +1,5 @@
import * as Immutable from "immutable"; import * as Immutable from "immutable";
import { AppState } from "@nteract/core"; import { AppState, ContentRef } from "@nteract/core";
import { Notebook } from "../../../Common/Constants"; import { Notebook } from "../../../Common/Constants";
import { CellId } from "@nteract/commutable"; import { CellId } from "@nteract/commutable";
@ -9,6 +9,7 @@ export interface CdbRecordProps {
defaultExperience: string | undefined; defaultExperience: string | undefined;
kernelRestartDelayMs: number; kernelRestartDelayMs: number;
hoveredCellId: CellId | undefined; hoveredCellId: CellId | undefined;
currentNotebookParentElements: Map<ContentRef, HTMLElement>;
} }
export type CdbRecord = Immutable.RecordOf<CdbRecordProps>; export type CdbRecord = Immutable.RecordOf<CdbRecordProps>;
@ -21,5 +22,6 @@ export const makeCdbRecord = Immutable.Record<CdbRecordProps>({
databaseAccountName: undefined, databaseAccountName: undefined,
defaultExperience: undefined, defaultExperience: undefined,
kernelRestartDelayMs: Notebook.kernelRestartInitialDelayMs, kernelRestartDelayMs: Notebook.kernelRestartInitialDelayMs,
hoveredCellId: undefined hoveredCellId: undefined,
currentNotebookParentElements: new Map<ContentRef, HTMLElement>()
}); });

View File

@ -23,6 +23,7 @@ import { DialogProps } from "../Controls/DialogReactComponent/DialogComponent";
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
import { PublishNotebookPaneAdapter } from "../Panes/PublishNotebookPaneAdapter"; import { PublishNotebookPaneAdapter } from "../Panes/PublishNotebookPaneAdapter";
import { getFullName } from "../../Utils/UserUtils"; import { getFullName } from "../../Utils/UserUtils";
import { ImmutableNotebook } from "@nteract/commutable";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
export interface NotebookManagerOptions { export interface NotebookManagerOptions {
@ -108,8 +109,12 @@ export default class NotebookManager {
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
} }
public openPublishNotebookPane(name: string, content: string): void { public openPublishNotebookPane(
this.publishNotebookPaneAdapter.open(name, getFullName(), content); name: string,
content: string | ImmutableNotebook,
parentDomElement: HTMLElement
): void {
this.publishNotebookPaneAdapter.open(name, getFullName(), content, parentDomElement);
} }
// Octokit's error handler uses any // Octokit's error handler uses any

View File

@ -30,11 +30,18 @@ import { CellType } from "@nteract/commutable/src";
import "./NotebookRenderer.less"; import "./NotebookRenderer.less";
import HoverableCell from "./decorators/HoverableCell"; import HoverableCell from "./decorators/HoverableCell";
import CellLabeler from "./decorators/CellLabeler"; import CellLabeler from "./decorators/CellLabeler";
import * as cdbActions from "../NotebookComponent/actions";
export interface NotebookRendererProps { export interface NotebookRendererBaseProps {
contentRef: any; contentRef: any;
} }
interface NotebookRendererDispatchProps {
updateNotebookParentDomElt: (contentRef: ContentRef, parentElt: HTMLElement) => void;
}
type NotebookRendererProps = NotebookRendererBaseProps & NotebookRendererDispatchProps;
interface PassedEditorProps { interface PassedEditorProps {
id: string; id: string;
contentRef: ContentRef; contentRef: ContentRef;
@ -68,6 +75,8 @@ const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, child
}; };
class BaseNotebookRenderer extends React.Component<NotebookRendererProps> { class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
private notebookRendererRef = React.createRef<HTMLDivElement>();
constructor(props: NotebookRendererProps) { constructor(props: NotebookRendererProps) {
super(props); super(props);
@ -78,13 +87,22 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
componentDidMount() { componentDidMount() {
loadTransform(this.props as any); loadTransform(this.props as any);
this.props.updateNotebookParentDomElt(this.props.contentRef, this.notebookRendererRef.current);
}
componentDidUpdate() {
this.props.updateNotebookParentDomElt(this.props.contentRef, this.notebookRendererRef.current);
}
componentWillUnmount() {
this.props.updateNotebookParentDomElt(this.props.contentRef, undefined);
} }
render(): JSX.Element { render(): JSX.Element {
return ( return (
<> <>
<div className="NotebookRendererContainer"> <div className="NotebookRendererContainer">
<div className="NotebookRenderer"> <div className="NotebookRenderer" ref={this.notebookRendererRef}>
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<KeyboardShortcuts contentRef={this.props.contentRef}> <KeyboardShortcuts contentRef={this.props.contentRef}>
<Cells contentRef={this.props.contentRef}> <Cells contentRef={this.props.contentRef}>
@ -146,7 +164,7 @@ class BaseNotebookRenderer extends React.Component<NotebookRendererProps> {
} }
} }
const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: NotebookRendererProps) => { const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: NotebookRendererBaseProps) => {
const mapDispatchToProps = (dispatch: Dispatch) => { const mapDispatchToProps = (dispatch: Dispatch) => {
return { return {
addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => { addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => {
@ -156,6 +174,14 @@ const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: Noteboo
component: transform component: transform
}) })
); );
},
updateNotebookParentDomElt: (contentRef: ContentRef, parentElt: HTMLElement) => {
return dispatch(
cdbActions.UpdateNotebookParentDomElt({
contentRef,
parentElt
})
);
} }
}; };
}; };

View File

@ -1,5 +1,15 @@
import { NotebookUtil } from "./NotebookUtil"; import { NotebookUtil } from "./NotebookUtil";
import * as GitHubUtils from "../../Utils/GitHubUtils"; import * as GitHubUtils from "../../Utils/GitHubUtils";
import {
ImmutableNotebook,
MediaBundle,
CodeCellParams,
MarkdownCellParams,
makeCodeCell,
makeMarkdownCell,
makeNotebookRecord
} from "@nteract/commutable";
import { List, Map } from "immutable";
const fileName = "file"; const fileName = "file";
const notebookName = "file.ipynb"; const notebookName = "file.ipynb";
@ -7,6 +17,57 @@ const filePath = `folder/${fileName}`;
const notebookPath = `folder/${notebookName}`; const notebookPath = `folder/${notebookName}`;
const gitHubFileUri = GitHubUtils.toContentUri("owner", "repo", "branch", filePath); const gitHubFileUri = GitHubUtils.toContentUri("owner", "repo", "branch", filePath);
const gitHubNotebookUri = GitHubUtils.toContentUri("owner", "repo", "branch", notebookPath); const gitHubNotebookUri = GitHubUtils.toContentUri("owner", "repo", "branch", notebookPath);
const notebookRecord = makeNotebookRecord({
cellOrder: List.of("0", "1", "2", "3"),
cellMap: Map({
"0": makeMarkdownCell({
cell_type: "markdown",
source: "abc",
metadata: undefined
} as MarkdownCellParams),
"1": makeCodeCell({
cell_type: "code",
execution_count: undefined,
metadata: undefined,
source: "print(5)",
outputs: List.of({
name: "stdout",
output_type: "stream",
text: "5"
})
} as CodeCellParams),
"2": makeCodeCell({
cell_type: "code",
execution_count: undefined,
metadata: undefined,
source: 'display(HTML("<h1>Sample html</h1>"))',
outputs: List.of({
data: Object.freeze({
data: {
"text/html": "<h1>Sample output</h1>",
"text/plain": "<IPython.core.display.HTML object>"
}
} as MediaBundle),
output_type: "display_data",
metadata: undefined
})
} as CodeCellParams),
"3": makeCodeCell({
cell_type: "code",
execution_count: undefined,
metadata: undefined,
source: 'print("hello world")',
outputs: List.of({
name: "stdout",
output_type: "stream",
text: "hello world"
})
} as CodeCellParams)
}),
nbformat_minor: 2,
nbformat: 2,
metadata: undefined
});
describe("NotebookUtil", () => { describe("NotebookUtil", () => {
describe("isNotebookFile", () => { describe("isNotebookFile", () => {
@ -46,4 +107,11 @@ describe("NotebookUtil", () => {
); );
}); });
}); });
describe("findFirstCodeCellWithDisplay", () => {
it("works for Notebook file", () => {
const notebookObject = notebookRecord as ImmutableNotebook;
expect(NotebookUtil.findFirstCodeCellWithDisplay(notebookObject)).toEqual(1);
});
});
}); });

View File

@ -1,5 +1,5 @@
import path from "path"; import path from "path";
import { ImmutableNotebook } from "@nteract/commutable"; import { ImmutableNotebook, ImmutableCodeCell, ImmutableOutput } from "@nteract/commutable";
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
import { StringUtils } from "../../Utils/StringUtils"; import { StringUtils } from "../../Utils/StringUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils"; import * as GitHubUtils from "../../Utils/GitHubUtils";
@ -100,4 +100,30 @@ export class NotebookUtil {
const basePath = path.split(contentName).shift(); const basePath = path.split(contentName).shift();
return `${basePath}${newName}`; return `${basePath}${newName}`;
} }
public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number {
let codeCellCount = -1;
for (let i = 0; i < notebookObject.cellOrder.size; i++) {
const cellId = notebookObject.cellOrder.get(i);
if (cellId) {
const cell = notebookObject.cellMap.get(cellId);
if (cell && cell.cell_type === "code") {
codeCellCount++;
const codeCell = cell as ImmutableCodeCell;
if (codeCell.outputs) {
const displayOutput = codeCell.outputs.find((output: ImmutableOutput) => {
if (output.output_type === "display_data" || output.output_type === "execute_result") {
return true;
}
return false;
});
if (displayOutput) {
return codeCellCount;
}
}
}
}
}
throw new Error("Output does not exist for any of the cells.");
}
} }

View File

@ -1,156 +1,178 @@
import ko from "knockout"; import ko from "knockout";
import * as React from "react"; import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import * as Logger from "../../Common/Logger"; import * as Logger from "../../Common/Logger";
import { JunoClient } from "../../Juno/JunoClient"; import Explorer from "../Explorer";
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils"; import { JunoClient } from "../../Juno/JunoClient";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent"; import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import Explorer from "../Explorer"; import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent";
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent"; import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
import { ImmutableNotebook } from "@nteract/commutable/src";
export class PublishNotebookPaneAdapter implements ReactAdapter { import { toJS } from "@nteract/commutable";
parameters: ko.Observable<number>;
private isOpened: boolean; export class PublishNotebookPaneAdapter implements ReactAdapter {
private isExecuting: boolean; parameters: ko.Observable<number>;
private formError: string; private isOpened: boolean;
private formErrorDetail: string; private isExecuting: boolean;
private formError: string;
private name: string; private formErrorDetail: string;
private author: string;
private content: string; private name: string;
private description: string; private author: string;
private tags: string; private content: string;
private imageSrc: string; private description: string;
private tags: string;
constructor(private container: Explorer, private junoClient: JunoClient) { private imageSrc: string;
this.parameters = ko.observable(Date.now()); private notebookObject: ImmutableNotebook;
this.reset(); private parentDomElement: HTMLElement;
this.triggerRender();
} constructor(private container: Explorer, private junoClient: JunoClient) {
this.parameters = ko.observable(Date.now());
public renderComponent(): JSX.Element { this.reset();
if (!this.isOpened) { this.triggerRender();
return undefined; }
}
public renderComponent(): JSX.Element {
const props: GenericRightPaneProps = { if (!this.isOpened) {
container: this.container, return undefined;
content: this.createContent(), }
formError: this.formError,
formErrorDetail: this.formErrorDetail, const props: GenericRightPaneProps = {
id: "publishnotebookpane", container: this.container,
isExecuting: this.isExecuting, content: this.createContent(),
title: "Publish to gallery", formError: this.formError,
submitButtonText: "Publish", formErrorDetail: this.formErrorDetail,
onClose: () => this.close(), id: "publishnotebookpane",
onSubmit: () => this.submit() isExecuting: this.isExecuting,
}; title: "Publish to gallery",
submitButtonText: "Publish",
return <GenericRightPaneComponent {...props} />; onClose: () => this.close(),
} onSubmit: () => this.submit()
};
public triggerRender(): void {
window.requestAnimationFrame(() => this.parameters(Date.now())); return <GenericRightPaneComponent {...props} />;
} }
public open(name: string, author: string, content: string): void { public triggerRender(): void {
this.name = name; window.requestAnimationFrame(() => this.parameters(Date.now()));
this.author = author; }
this.content = content;
public open(
this.isOpened = true; name: string,
this.triggerRender(); author: string,
} notebookContent: string | ImmutableNotebook,
parentDomElement: HTMLElement
public close(): void { ): void {
this.reset(); this.name = name;
this.triggerRender(); this.author = author;
} if (typeof notebookContent === "string") {
this.content = notebookContent as string;
public async submit(): Promise<void> { } else {
const notificationId = NotificationConsoleUtils.logConsoleMessage( this.content = JSON.stringify(toJS(notebookContent as ImmutableNotebook));
ConsoleDataType.InProgress, this.notebookObject = notebookContent;
`Publishing ${this.name} to gallery` }
); this.parentDomElement = parentDomElement;
this.isExecuting = true;
this.triggerRender(); this.isOpened = true;
this.triggerRender();
try { }
if (!this.name || !this.description || !this.author) {
throw new Error("Name, description, and author are required"); public close(): void {
} this.reset();
this.triggerRender();
const response = await this.junoClient.publishNotebook( }
this.name,
this.description, public async submit(): Promise<void> {
this.tags?.split(","), const notificationId = NotificationConsoleUtils.logConsoleMessage(
this.author, ConsoleDataType.InProgress,
this.imageSrc, `Publishing ${this.name} to gallery`
this.content );
); this.isExecuting = true;
if (!response.data) { this.triggerRender();
throw new Error(`Received HTTP ${response.status} when publishing ${name} to gallery`);
} try {
if (!this.name || !this.description || !this.author) {
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`); throw new Error("Name, description, and author are required");
} catch (error) { }
this.formError = `Failed to publish ${this.name} to gallery`;
this.formErrorDetail = `${error}`; const response = await this.junoClient.publishNotebook(
this.name,
const message = `${this.formError}: ${this.formErrorDetail}`; this.description,
Logger.logError(message, "PublishNotebookPaneAdapter/submit"); this.tags?.split(","),
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); this.author,
return; this.imageSrc,
} finally { this.content
NotificationConsoleUtils.clearInProgressMessageWithId(notificationId); );
this.isExecuting = false; if (!response.data) {
this.triggerRender(); throw new Error(`Received HTTP ${response.status} when publishing ${name} to gallery`);
} }
this.close(); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`);
} } catch (error) {
this.formError = `Failed to publish ${this.name} to gallery`;
private createFormErrorForLargeImageSelection = (formError: string, formErrorDetail: string, area: string): void => { this.formErrorDetail = `${error}`;
this.formError = formError;
this.formErrorDetail = formErrorDetail; const message = `${this.formError}: ${this.formErrorDetail}`;
Logger.logError(message, "PublishNotebookPaneAdapter/submit");
const message = `${this.formError}: ${this.formErrorDetail}`; NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
Logger.logError(message, area); return;
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message); } finally {
this.triggerRender(); NotificationConsoleUtils.clearInProgressMessageWithId(notificationId);
}; this.isExecuting = false;
this.triggerRender();
private clearFormError = (): void => { }
this.formError = undefined;
this.formErrorDetail = undefined; this.close();
this.triggerRender(); }
};
private createFormErrorForLargeImageSelection = (formError: string, formErrorDetail: string, area: string): void => {
private createContent = (): JSX.Element => { this.formError = formError;
const publishNotebookPaneProps: PublishNotebookPaneProps = { this.formErrorDetail = formErrorDetail;
notebookName: this.name,
notebookDescription: "", const message = `${this.formError}: ${this.formErrorDetail}`;
notebookTags: "", Logger.logError(message, area);
notebookAuthor: this.author, NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
notebookCreatedDate: new Date().toISOString(), this.triggerRender();
onChangeDescription: (newValue: string) => (this.description = newValue), };
onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue), private clearFormError = (): void => {
onError: this.createFormErrorForLargeImageSelection, this.formError = undefined;
clearFormError: this.clearFormError this.formErrorDetail = undefined;
}; this.triggerRender();
};
return <PublishNotebookPaneComponent {...publishNotebookPaneProps} />;
}; private createContent = (): JSX.Element => {
const publishNotebookPaneProps: PublishNotebookPaneProps = {
private reset = (): void => { notebookName: this.name,
this.isOpened = false; notebookDescription: "",
this.isExecuting = false; notebookTags: "",
this.formError = undefined; notebookAuthor: this.author,
this.formErrorDetail = undefined; notebookCreatedDate: new Date().toISOString(),
this.name = undefined; notebookObject: this.notebookObject,
this.author = undefined; notebookParentDomElement: this.parentDomElement,
this.content = undefined; onChangeDescription: (newValue: string) => (this.description = newValue),
}; onChangeTags: (newValue: string) => (this.tags = newValue),
} onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormErrorForLargeImageSelection,
clearFormError: this.clearFormError
};
return <PublishNotebookPaneComponent {...publishNotebookPaneProps} />;
};
private reset = (): void => {
this.isOpened = false;
this.isExecuting = false;
this.formError = undefined;
this.formErrorDetail = undefined;
this.name = undefined;
this.author = undefined;
this.content = undefined;
this.description = undefined;
this.tags = undefined;
this.imageSrc = undefined;
this.notebookObject = undefined;
this.parentDomElement = undefined;
};
}

View File

@ -10,6 +10,8 @@ describe("PublishNotebookPaneComponent", () => {
notebookTags: "tag1, tag2", notebookTags: "tag1, tag2",
notebookAuthor: "CosmosDB", notebookAuthor: "CosmosDB",
notebookCreatedDate: "2020-07-17T00:00:00Z", notebookCreatedDate: "2020-07-17T00:00:00Z",
notebookObject: undefined,
notebookParentDomElement: undefined,
onChangeDescription: undefined, onChangeDescription: undefined,
onChangeTags: undefined, onChangeTags: undefined,
onChangeImageSrc: undefined, onChangeImageSrc: undefined,

View File

@ -3,6 +3,9 @@ import * as React from "react";
import { GalleryCardComponent } from "../Controls/NotebookGallery/Cards/GalleryCardComponent"; import { GalleryCardComponent } from "../Controls/NotebookGallery/Cards/GalleryCardComponent";
import { FileSystemUtil } from "../Notebook/FileSystemUtil"; import { FileSystemUtil } from "../Notebook/FileSystemUtil";
import "./PublishNotebookPaneComponent.less"; import "./PublishNotebookPaneComponent.less";
import Html2Canvas from "html2canvas";
import { ImmutableNotebook } from "@nteract/commutable/src";
import { NotebookUtil } from "../Notebook/NotebookUtil";
export interface PublishNotebookPaneProps { export interface PublishNotebookPaneProps {
notebookName: string; notebookName: string;
@ -10,6 +13,8 @@ export interface PublishNotebookPaneProps {
notebookTags: string; notebookTags: string;
notebookAuthor: string; notebookAuthor: string;
notebookCreatedDate: string; notebookCreatedDate: string;
notebookObject: ImmutableNotebook;
notebookParentDomElement: HTMLElement;
onChangeDescription: (newValue: string) => void; onChangeDescription: (newValue: string) => void;
onChangeTags: (newValue: string) => void; onChangeTags: (newValue: string) => void;
onChangeImageSrc: (newValue: string) => void; onChangeImageSrc: (newValue: string) => void;
@ -24,9 +29,15 @@ interface PublishNotebookPaneState {
imageSrc: string; imageSrc: string;
} }
enum ImageTypes {
Url = "URL",
CustomImage = "Custom Image",
TakeScreenshot = "Take Screenshot",
UseFirstDisplayOutput = "Use First Display Output"
}
export class PublishNotebookPaneComponent extends React.Component<PublishNotebookPaneProps, PublishNotebookPaneState> { export class PublishNotebookPaneComponent extends React.Component<PublishNotebookPaneProps, PublishNotebookPaneState> {
private static readonly maxImageSizeInMib = 1.5; private static readonly maxImageSizeInMib = 1.5;
private static readonly ImageTypes = ["URL", "Custom Image"];
private descriptionPara1: string; private descriptionPara1: string;
private descriptionPara2: string; private descriptionPara2: string;
private descriptionProps: ITextFieldProps; private descriptionProps: ITextFieldProps;
@ -34,12 +45,13 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
private thumbnailUrlProps: ITextFieldProps; private thumbnailUrlProps: ITextFieldProps;
private thumbnailSelectorProps: IDropdownProps; private thumbnailSelectorProps: IDropdownProps;
private imageToBase64: (file: File, updateImageSrc: (result: string) => void) => void; private imageToBase64: (file: File, updateImageSrc: (result: string) => void) => void;
private takeScreenshot: (target: HTMLElement, onError: (error: Error) => void) => void;
constructor(props: PublishNotebookPaneProps) { constructor(props: PublishNotebookPaneProps) {
super(props); super(props);
this.state = { this.state = {
type: PublishNotebookPaneComponent.ImageTypes[0], type: ImageTypes.Url,
notebookDescription: "", notebookDescription: "",
notebookTags: "", notebookTags: "",
imageSrc: undefined imageSrc: undefined
@ -61,6 +73,38 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
}; };
}; };
this.takeScreenshot = (target: HTMLElement, onError: (error: Error) => void): void => {
const updateImageSrcWithScreenshot = (canvasUrl: string): void => {
this.props.onChangeImageSrc(canvasUrl);
this.setState({ imageSrc: canvasUrl });
};
target.scrollIntoView();
Html2Canvas(target, {
useCORS: true,
allowTaint: true,
scale: 1,
logging: true
})
.then(canvas => {
//redraw canvas to fit Card Cover Image dimensions
const originalImageData = canvas.toDataURL();
const requiredHeight =
parseInt(canvas.style.width.split("px")[0]) * GalleryCardComponent.cardHeightToWidthRatio;
canvas.height = requiredHeight;
const context = canvas.getContext("2d");
const image = new Image();
image.src = originalImageData;
image.onload = function() {
context.drawImage(image, 0, 0);
updateImageSrcWithScreenshot(canvas.toDataURL());
};
})
.catch(error => {
onError(error);
});
};
this.descriptionPara1 = this.descriptionPara1 =
"This notebook has your data. Please make sure you delete any sensitive data/output before publishing."; "This notebook has your data. Please make sure you delete any sensitive data/output before publishing.";
@ -78,12 +122,45 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
} }
}; };
const screenshotErrorHandler = (error: Error) => {
const formError = "Failed to take screen shot";
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/takeScreenshot";
this.props.onError(formError, formErrorDetail, area);
};
const firstOutputErrorHandler = (error: Error) => {
const formError = "Failed to capture first output";
const formErrorDetail = `${error}`;
const area = "PublishNotebookPaneComponent/UseFirstOutput";
this.props.onError(formError, formErrorDetail, area);
};
this.thumbnailSelectorProps = { this.thumbnailSelectorProps = {
label: "Cover image", label: "Cover image",
defaultSelectedKey: PublishNotebookPaneComponent.ImageTypes[0], defaultSelectedKey: ImageTypes.Url,
ariaLabel: "Cover image", ariaLabel: "Cover image",
options: PublishNotebookPaneComponent.ImageTypes.map((value: string) => ({ text: value, key: value })), options: [
onChange: (event, options) => { ImageTypes.Url,
ImageTypes.CustomImage,
ImageTypes.TakeScreenshot,
ImageTypes.UseFirstDisplayOutput
].map((value: string) => ({ text: value, key: value })),
onChange: async (event, options) => {
this.props.clearFormError();
if (options.text === ImageTypes.TakeScreenshot) {
try {
await this.takeScreenshot(this.props.notebookParentDomElement, screenshotErrorHandler);
} catch (error) {
screenshotErrorHandler(error);
}
} else if (options.text === ImageTypes.UseFirstDisplayOutput) {
try {
await this.takeScreenshot(this.findFirstOutput(), firstOutputErrorHandler);
} catch (error) {
firstOutputErrorHandler(error);
}
}
this.setState({ type: options.text }); this.setState({ type: options.text });
} }
}; };
@ -111,6 +188,51 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
}; };
} }
private renderThumbnailSelectors(type: string) {
switch (type) {
case ImageTypes.Url:
return <TextField {...this.thumbnailUrlProps} />;
case ImageTypes.CustomImage:
return (
<input
id="selectImageFile"
type="file"
accept="image/*"
onChange={event => {
const file = event.target.files[0];
if (file.size / 1024 ** 2 > PublishNotebookPaneComponent.maxImageSizeInMib) {
event.target.value = "";
const formError = `Failed to upload ${file.name}`;
const formErrorDetail = `Image is larger than ${PublishNotebookPaneComponent.maxImageSizeInMib} MiB. Please Choose a different image.`;
const area = "PublishNotebookPaneComponent/selectImageFile";
this.props.onError(formError, formErrorDetail, area);
this.props.onChangeImageSrc(undefined);
this.setState({ imageSrc: undefined });
return;
} else {
this.props.clearFormError();
}
this.imageToBase64(file, (result: string) => {
this.props.onChangeImageSrc(result);
this.setState({ imageSrc: result });
});
}}
/>
);
default:
return <></>;
}
}
private findFirstOutput(): HTMLElement {
const indexOfFirstCodeCellWithDisplay = NotebookUtil.findFirstCodeCellWithDisplay(this.props.notebookObject);
const cellOutputDomElements = this.props.notebookParentDomElement.querySelectorAll<HTMLElement>(
".nteract-cell-outputs"
);
return cellOutputDomElements[indexOfFirstCodeCellWithDisplay];
}
public render(): JSX.Element { public render(): JSX.Element {
return ( return (
<div className="publishNotebookPanelContent"> <div className="publishNotebookPanelContent">
@ -135,39 +257,8 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
<Dropdown {...this.thumbnailSelectorProps} /> <Dropdown {...this.thumbnailSelectorProps} />
</Stack.Item> </Stack.Item>
{this.state.type === PublishNotebookPaneComponent.ImageTypes[0] ? ( <Stack.Item>{this.renderThumbnailSelectors(this.state.type)}</Stack.Item>
<Stack.Item>
<TextField {...this.thumbnailUrlProps} />
</Stack.Item>
) : (
<Stack.Item>
<input
id="selectImageFile"
type="file"
accept="image/*"
onChange={event => {
const file = event.target.files[0];
if (file.size / 1024 ** 2 > PublishNotebookPaneComponent.maxImageSizeInMib) {
event.target.value = "";
const formError = `Failed to upload ${file.name}`;
const formErrorDetail = `Image is larger than ${PublishNotebookPaneComponent.maxImageSizeInMib} MiB. Please Choose a different image.`;
const area = "PublishNotebookPaneComponent/selectImageFile";
this.props.onError(formError, formErrorDetail, area);
this.props.onChangeImageSrc(undefined);
this.setState({ imageSrc: undefined });
return;
} else {
this.props.clearFormError();
}
this.imageToBase64(file, (result: string) => {
this.props.onChangeImageSrc(result);
this.setState({ imageSrc: result });
});
}}
/>
</Stack.Item>
)}
<Stack.Item> <Stack.Item>
<Text>Preview</Text> <Text>Preview</Text>
</Stack.Item> </Stack.Item>

View File

@ -56,6 +56,14 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
"key": "Custom Image", "key": "Custom Image",
"text": "Custom Image", "text": "Custom Image",
}, },
Object {
"key": "Take Screenshot",
"text": "Take Screenshot",
},
Object {
"key": "Use First Display Output",
"text": "Use First Display Output",
},
] ]
} }
/> />

View File

@ -449,7 +449,11 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab {
private publishToGallery = () => { private publishToGallery = () => {
const notebookContent = this.notebookComponentAdapter.getContent(); const notebookContent = this.notebookComponentAdapter.getContent();
this.container.publishNotebook(notebookContent.name, notebookContent.content); this.container.publishNotebook(
notebookContent.name,
notebookContent.content,
this.notebookComponentAdapter.getNotebookParentElement()
);
}; };
private traceTelemetry(actionType: number) { private traceTelemetry(actionType: number) {