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
18 changed files with 525 additions and 208 deletions

View File

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

View File

@@ -3,6 +3,9 @@ import * as React from "react";
import { GalleryCardComponent } from "../Controls/NotebookGallery/Cards/GalleryCardComponent";
import { FileSystemUtil } from "../Notebook/FileSystemUtil";
import "./PublishNotebookPaneComponent.less";
import Html2Canvas from "html2canvas";
import { ImmutableNotebook } from "@nteract/commutable/src";
import { NotebookUtil } from "../Notebook/NotebookUtil";
export interface PublishNotebookPaneProps {
notebookName: string;
@@ -10,6 +13,8 @@ export interface PublishNotebookPaneProps {
notebookTags: string;
notebookAuthor: string;
notebookCreatedDate: string;
notebookObject: ImmutableNotebook;
notebookParentDomElement: HTMLElement;
onChangeDescription: (newValue: string) => void;
onChangeTags: (newValue: string) => void;
onChangeImageSrc: (newValue: string) => void;
@@ -24,9 +29,15 @@ interface PublishNotebookPaneState {
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> {
private static readonly maxImageSizeInMib = 1.5;
private static readonly ImageTypes = ["URL", "Custom Image"];
private descriptionPara1: string;
private descriptionPara2: string;
private descriptionProps: ITextFieldProps;
@@ -34,12 +45,13 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
private thumbnailUrlProps: ITextFieldProps;
private thumbnailSelectorProps: IDropdownProps;
private imageToBase64: (file: File, updateImageSrc: (result: string) => void) => void;
private takeScreenshot: (target: HTMLElement, onError: (error: Error) => void) => void;
constructor(props: PublishNotebookPaneProps) {
super(props);
this.state = {
type: PublishNotebookPaneComponent.ImageTypes[0],
type: ImageTypes.Url,
notebookDescription: "",
notebookTags: "",
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 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 = {
label: "Cover image",
defaultSelectedKey: PublishNotebookPaneComponent.ImageTypes[0],
defaultSelectedKey: ImageTypes.Url,
ariaLabel: "Cover image",
options: PublishNotebookPaneComponent.ImageTypes.map((value: string) => ({ text: value, key: value })),
onChange: (event, options) => {
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 });
}
};
@@ -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 {
return (
<div className="publishNotebookPanelContent">
@@ -135,39 +257,8 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
<Dropdown {...this.thumbnailSelectorProps} />
</Stack.Item>
{this.state.type === PublishNotebookPaneComponent.ImageTypes[0] ? (
<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";
<Stack.Item>{this.renderThumbnailSelectors(this.state.type)}</Stack.Item>
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>
<Text>Preview</Text>
</Stack.Item>

View File

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