From dc67c5f40b86922a7bced68d23a2a4f9c234bcc9 Mon Sep 17 00:00:00 2001 From: Srinath Narayanan Date: Thu, 23 Jul 2020 00:43:53 -0700 Subject: [PATCH] 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 --- package-lock.json | 21 ++ package.json | 1 + .../Cards/GalleryCardComponent.tsx | 2 + src/Explorer/Explorer.ts | 4 +- .../NotebookComponentAdapter.tsx | 7 + .../NotebookComponentBootstrapper.tsx | 8 +- .../Notebook/NotebookComponent/actions.ts | 19 + .../Notebook/NotebookComponent/reducers.ts | 13 + .../Notebook/NotebookComponent/types.ts | 6 +- src/Explorer/Notebook/NotebookManager.ts | 9 +- .../NotebookRenderer/NotebookRenderer.tsx | 32 +- src/Explorer/Notebook/NotebookUtil.test.ts | 68 ++++ src/Explorer/Notebook/NotebookUtil.ts | 28 +- .../Panes/PublishNotebookPaneAdapter.tsx | 334 ++++++++++-------- .../PublishNotebookPaneComponent.test.tsx | 2 + .../Panes/PublishNotebookPaneComponent.tsx | 165 +++++++-- ...PublishNotebookPaneComponent.test.tsx.snap | 8 + src/Explorer/Tabs/NotebookV2Tab.ts | 6 +- 18 files changed, 525 insertions(+), 208 deletions(-) diff --git a/package-lock.json b/package-lock.json index 52e878d37..d8811867c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9519,6 +9519,11 @@ "resolved": "https://registry.npmjs.org/base16/-/base16-1.0.0.tgz", "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": { "version": "1.3.1", "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", "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": { "version": "1.0.0", "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": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz", diff --git a/package.json b/package.json index 2ffbc3936..614f8e8b7 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "es6-symbol": "3.1.3", "eslint-plugin-jest": "23.13.2", "hasher": "1.2.0", + "html2canvas": "1.0.0-rc.5", "immutable": "4.0.0-rc.12", "is-ci": "2.0.0", "jquery": "3.5.1", diff --git a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx index 1223552fc..00ff0775a 100644 --- a/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx +++ b/src/Explorer/Controls/NotebookGallery/Cards/GalleryCardComponent.tsx @@ -36,6 +36,8 @@ export interface GalleryCardComponentProps { export class GalleryCardComponent extends React.Component { public static readonly CARD_WIDTH = 256; private static readonly cardImageHeight = 144; + public static readonly cardHeightToWidthRatio = + GalleryCardComponent.cardImageHeight / GalleryCardComponent.CARD_WIDTH; private static readonly cardDescriptionMaxChars = 88; private static readonly cardItemGapBig = 10; private static readonly cardItemGapSmall = 8; diff --git a/src/Explorer/Explorer.ts b/src/Explorer/Explorer.ts index 740531dda..0f146c71d 100644 --- a/src/Explorer/Explorer.ts +++ b/src/Explorer/Explorer.ts @@ -2347,9 +2347,9 @@ export default class Explorer { return Promise.resolve(false); } - public publishNotebook(name: string, content: string): void { + public publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): void { if (this.notebookManager) { - this.notebookManager.openPublishNotebookPane(name, content); + this.notebookManager.openPublishNotebookPane(name, content, parentDomElement); this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter; this.isPublishNotebookPaneEnabled(true); } diff --git a/src/Explorer/Notebook/NotebookComponent/NotebookComponentAdapter.tsx b/src/Explorer/Notebook/NotebookComponent/NotebookComponentAdapter.tsx index 385e8d383..4c36df365 100644 --- a/src/Explorer/Notebook/NotebookComponent/NotebookComponentAdapter.tsx +++ b/src/Explorer/Notebook/NotebookComponent/NotebookComponentAdapter.tsx @@ -8,6 +8,7 @@ import { actions, createContentRef, createKernelRef, selectors } from "@nteract/ import VirtualCommandBarComponent from "./VirtualCommandBarComponent"; import { NotebookContentItem } from "../NotebookContentItem"; import { NotebookComponentBootstrapper } from "./NotebookComponentBootstrapper"; +import { CdbAppState } from "./types"; export interface NotebookComponentAdapterOptions { contentItem: NotebookContentItem; @@ -18,6 +19,7 @@ export interface NotebookComponentAdapterOptions { export class NotebookComponentAdapter extends NotebookComponentBootstrapper implements ReactAdapter { private onUpdateKernelInfo: () => void; + public getNotebookParentElement: () => HTMLElement; public parameters: any; 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 => { diff --git a/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx b/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx index 5933509cf..dfc64b161 100644 --- a/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx +++ b/src/Explorer/Notebook/NotebookComponent/NotebookComponentBootstrapper.tsx @@ -17,7 +17,7 @@ import { } from "@nteract/core"; import * as Immutable from "immutable"; 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 "./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() .getState() .core.entities.contents.byRef.get(this.contentRef); - let content: string; + let content: string | ImmutableNotebook; switch (record.model.type) { case "notebook": - content = JSON.stringify(toJS(record.model.notebook)); + content = record.model.notebook; break; case "file": content = record.model.text; diff --git a/src/Explorer/Notebook/NotebookComponent/actions.ts b/src/Explorer/Notebook/NotebookComponent/actions.ts index c081f3003..e516f0ba5 100644 --- a/src/Explorer/Notebook/NotebookComponent/actions.ts +++ b/src/Explorer/Notebook/NotebookComponent/actions.ts @@ -84,3 +84,22 @@ export const traceNotebookTelemetry = (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 + }; +}; diff --git a/src/Explorer/Notebook/NotebookComponent/reducers.ts b/src/Explorer/Notebook/NotebookComponent/reducers.ts index dec2fab1e..0cdd4cc14 100644 --- a/src/Explorer/Notebook/NotebookComponent/reducers.ts +++ b/src/Explorer/Notebook/NotebookComponent/reducers.ts @@ -82,6 +82,19 @@ export const cdbReducer = (state: CdbRecord, action: Action) => { }); 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; }; diff --git a/src/Explorer/Notebook/NotebookComponent/types.ts b/src/Explorer/Notebook/NotebookComponent/types.ts index 54c627d80..a0d1e4814 100644 --- a/src/Explorer/Notebook/NotebookComponent/types.ts +++ b/src/Explorer/Notebook/NotebookComponent/types.ts @@ -1,5 +1,5 @@ import * as Immutable from "immutable"; -import { AppState } from "@nteract/core"; +import { AppState, ContentRef } from "@nteract/core"; import { Notebook } from "../../../Common/Constants"; import { CellId } from "@nteract/commutable"; @@ -9,6 +9,7 @@ export interface CdbRecordProps { defaultExperience: string | undefined; kernelRestartDelayMs: number; hoveredCellId: CellId | undefined; + currentNotebookParentElements: Map; } export type CdbRecord = Immutable.RecordOf; @@ -21,5 +22,6 @@ export const makeCdbRecord = Immutable.Record({ databaseAccountName: undefined, defaultExperience: undefined, kernelRestartDelayMs: Notebook.kernelRestartInitialDelayMs, - hoveredCellId: undefined + hoveredCellId: undefined, + currentNotebookParentElements: new Map() }); diff --git a/src/Explorer/Notebook/NotebookManager.ts b/src/Explorer/Notebook/NotebookManager.ts index 2e4d5fe87..36ad4fc98 100644 --- a/src/Explorer/Notebook/NotebookManager.ts +++ b/src/Explorer/Notebook/NotebookManager.ts @@ -23,6 +23,7 @@ import { DialogProps } from "../Controls/DialogReactComponent/DialogComponent"; import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter"; import { PublishNotebookPaneAdapter } from "../Panes/PublishNotebookPaneAdapter"; import { getFullName } from "../../Utils/UserUtils"; +import { ImmutableNotebook } from "@nteract/commutable"; import Explorer from "../Explorer"; export interface NotebookManagerOptions { @@ -108,8 +109,12 @@ export default class NotebookManager { this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); } - public openPublishNotebookPane(name: string, content: string): void { - this.publishNotebookPaneAdapter.open(name, getFullName(), content); + public openPublishNotebookPane( + name: string, + content: string | ImmutableNotebook, + parentDomElement: HTMLElement + ): void { + this.publishNotebookPaneAdapter.open(name, getFullName(), content, parentDomElement); } // Octokit's error handler uses any diff --git a/src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx b/src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx index 8388e0098..eed929586 100644 --- a/src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx +++ b/src/Explorer/Notebook/NotebookRenderer/NotebookRenderer.tsx @@ -30,11 +30,18 @@ import { CellType } from "@nteract/commutable/src"; import "./NotebookRenderer.less"; import HoverableCell from "./decorators/HoverableCell"; import CellLabeler from "./decorators/CellLabeler"; +import * as cdbActions from "../NotebookComponent/actions"; -export interface NotebookRendererProps { +export interface NotebookRendererBaseProps { contentRef: any; } +interface NotebookRendererDispatchProps { + updateNotebookParentDomElt: (contentRef: ContentRef, parentElt: HTMLElement) => void; +} + +type NotebookRendererProps = NotebookRendererBaseProps & NotebookRendererDispatchProps; + interface PassedEditorProps { id: string; contentRef: ContentRef; @@ -68,6 +75,8 @@ const decorate = (id: string, contentRef: ContentRef, cell_type: CellType, child }; class BaseNotebookRenderer extends React.Component { + private notebookRendererRef = React.createRef(); + constructor(props: NotebookRendererProps) { super(props); @@ -78,13 +87,22 @@ class BaseNotebookRenderer extends React.Component { componentDidMount() { 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 { return ( <>
-
+
@@ -146,7 +164,7 @@ class BaseNotebookRenderer extends React.Component { } } -const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: NotebookRendererProps) => { +const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: NotebookRendererBaseProps) => { const mapDispatchToProps = (dispatch: Dispatch) => { return { addTransform: (transform: React.ComponentType & { MIMETYPE: string }) => { @@ -156,6 +174,14 @@ const makeMapDispatchToProps = (initialDispatch: Dispatch, initialProps: Noteboo component: transform }) ); + }, + updateNotebookParentDomElt: (contentRef: ContentRef, parentElt: HTMLElement) => { + return dispatch( + cdbActions.UpdateNotebookParentDomElt({ + contentRef, + parentElt + }) + ); } }; }; diff --git a/src/Explorer/Notebook/NotebookUtil.test.ts b/src/Explorer/Notebook/NotebookUtil.test.ts index 0c3902843..e43aa2c9c 100644 --- a/src/Explorer/Notebook/NotebookUtil.test.ts +++ b/src/Explorer/Notebook/NotebookUtil.test.ts @@ -1,5 +1,15 @@ import { NotebookUtil } from "./NotebookUtil"; 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 notebookName = "file.ipynb"; @@ -7,6 +17,57 @@ const filePath = `folder/${fileName}`; const notebookPath = `folder/${notebookName}`; const gitHubFileUri = GitHubUtils.toContentUri("owner", "repo", "branch", filePath); 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("

Sample html

"))', + outputs: List.of({ + data: Object.freeze({ + data: { + "text/html": "

Sample output

", + "text/plain": "" + } + } 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("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); + }); + }); }); diff --git a/src/Explorer/Notebook/NotebookUtil.ts b/src/Explorer/Notebook/NotebookUtil.ts index bc9c42017..be4e38480 100644 --- a/src/Explorer/Notebook/NotebookUtil.ts +++ b/src/Explorer/Notebook/NotebookUtil.ts @@ -1,5 +1,5 @@ import path from "path"; -import { ImmutableNotebook } from "@nteract/commutable"; +import { ImmutableNotebook, ImmutableCodeCell, ImmutableOutput } from "@nteract/commutable"; import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; import { StringUtils } from "../../Utils/StringUtils"; import * as GitHubUtils from "../../Utils/GitHubUtils"; @@ -100,4 +100,30 @@ export class NotebookUtil { const basePath = path.split(contentName).shift(); 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."); + } } diff --git a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx index 2dac1fe9a..6c20dcb48 100644 --- a/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneAdapter.tsx @@ -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; - 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 ; - } - - 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 { - 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 ; - }; - - 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; + 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 ; + } + + 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 { + 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 ; + }; + + 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; + }; +} diff --git a/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx b/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx index 6be43800c..8f2aae7f7 100644 --- a/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneComponent.test.tsx @@ -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, diff --git a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx index b87c822d0..83ca8b5aa 100644 --- a/src/Explorer/Panes/PublishNotebookPaneComponent.tsx +++ b/src/Explorer/Panes/PublishNotebookPaneComponent.tsx @@ -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 { 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 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 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 { + 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; + case ImageTypes.CustomImage: + return ( + { + 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( + ".nteract-cell-outputs" + ); + return cellOutputDomElements[indexOfFirstCodeCellWithDisplay]; + } + public render(): JSX.Element { return (
@@ -135,39 +257,8 @@ export class PublishNotebookPaneComponent extends React.Component - {this.state.type === PublishNotebookPaneComponent.ImageTypes[0] ? ( - - - - ) : ( - - { - 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.renderThumbnailSelectors(this.state.type)} - 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 }); - }); - }} - /> - - )} Preview diff --git a/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap b/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap index bba1e3351..535927881 100644 --- a/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap +++ b/src/Explorer/Panes/__snapshots__/PublishNotebookPaneComponent.test.tsx.snap @@ -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", + }, ] } /> diff --git a/src/Explorer/Tabs/NotebookV2Tab.ts b/src/Explorer/Tabs/NotebookV2Tab.ts index 4e96ef453..75e775da0 100644 --- a/src/Explorer/Tabs/NotebookV2Tab.ts +++ b/src/Explorer/Tabs/NotebookV2Tab.ts @@ -449,7 +449,11 @@ export default class NotebookTabV2 extends TabsBase implements ViewModels.Tab { private publishToGallery = () => { 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) {