diff --git a/html/httpgd/index.ejs b/html/httpgd/index.ejs index d9afdd079..99036c41c 100644 --- a/html/httpgd/index.ejs +++ b/html/httpgd/index.ejs @@ -8,7 +8,7 @@
> - <%- largePlot?.svg %> + <%- largePlot?.data %>
diff --git a/html/httpgd/smallPlot.ejs b/html/httpgd/smallPlot.ejs index 1c24490cc..7d8337661 100644 --- a/html/httpgd/smallPlot.ejs +++ b/html/httpgd/smallPlot.ejs @@ -4,7 +4,7 @@ class="focusPlot <%- plot.id === activePlot ? 'active' : '' %> " plotId="<%- plot.id %>" > - <%- plot.svg %> + <%- plot.data %> { - const res = await fetch(this.remove_index(index).href, { - headers: this.httpHeaders - }); - return res; - } - - private remove_id(id: PlotId): URL { - const url = new URL(this.httpRemove); - url.searchParams.append('id', id); - return url; - } - - public async get_remove_id(id: PlotId): Promise { - const res = await fetch(this.remove_id(id).href, { - headers: this.httpHeaders - }); - return res; - } - - public async get_plots(): Promise { - const res = await fetch(this.httpPlots, { - headers: this.httpHeaders - }); - return await (res.json() as Promise); - } - - public async get_plot_contents_all(): Promise { - const plotIds = await this.get_plots(); - const plots = plotIds.plots.map(async idRes => { - return await this.get_plot_contents(idRes.id); - }); - return await Promise.all(plots); - } - - public async get_plot_contents(id: PlotId, width?: number, height?: number, c?: string): Promise { - const url = this.svg_id(id, width, height, c).toString(); - const plot = fetch(url).then(res => res.text()).then(res => { - return { - url: url, - host: this.host, - id: id, - svg: res, - height: height, - width: width, - }; - }); - return plot; - } - - - public async get_clear(): Promise { - const res = await fetch(this.httpClear, { - headers: this.httpHeaders - }); - return res; - } - - public async get_state(): Promise { - const res = await fetch(this.httpState, { - headers: this.httpHeaders - }); - return await (res.json() as Promise); - } - - public new_websocket(): WebSocket { - return new WebSocket(this.ws); - } -} - -const enum HttpgdConnectionMode { - NONE, - POLL, - SLOWPOLL, - WEBSOCKET -} - -/** - * Handles HTTP polling / WebSocket connection. - * This handles falling back to HTTP polling when WebSockets are not available, - * and automatically reconnects if the server is temporarily unavailable. - */ -class HttpgdConnection { - private static readonly INTERVAL_POLL: number = 500; - private static readonly INTERVAL_POLL_SLOW: number = 5000; - - public api: HttpgdApi; - - private mode: HttpgdConnectionMode = HttpgdConnectionMode.NONE; - private allowWebsockets: boolean; - - private socket?: WebSocket; - private pollHandle?: ReturnType; - - private pausePoll: boolean = false; - private disconnected: boolean = true; - - private lastState?: HttpgdState; - - public remoteStateChanged?: (newState: HttpgdState) => void; - public connectionChanged?: (disconnected: boolean) => void; - - public constructor(host: string, token?: string, allowWebsockets?: boolean) { - this.api = new HttpgdApi(host, token); - this.allowWebsockets = allowWebsockets ? allowWebsockets : false; - } - - public open(): void { - if (this.mode !== HttpgdConnectionMode.NONE) {return;} - this.start(HttpgdConnectionMode.WEBSOCKET); - } - - public close(): void { - if (this.mode === HttpgdConnectionMode.NONE) {return;} - this.start(HttpgdConnectionMode.NONE); - } - - private start(targetMode: HttpgdConnectionMode): void { - if (this.mode === targetMode) {return;} - - switch (targetMode) { - case HttpgdConnectionMode.POLL: - console.log('Start POLL'); - this.clearWebsocket(); - this.clearPoll(); - this.pollHandle = setInterval(() => this.poll(), HttpgdConnection.INTERVAL_POLL); - this.mode = targetMode; - break; - case HttpgdConnectionMode.SLOWPOLL: - console.log('Start SLOWPOLL'); - this.clearWebsocket(); - this.clearPoll(); - this.pollHandle = setInterval(() => this.poll(), HttpgdConnection.INTERVAL_POLL_SLOW); - this.mode = targetMode; - break; - case HttpgdConnectionMode.WEBSOCKET: - if (!this.allowWebsockets) { - this.start(HttpgdConnectionMode.POLL); - break; - } - console.log('Start WEBSOCKET'); - this.clearPoll(); - this.clearWebsocket(); - - this.socket = this.api.new_websocket(); - this.socket.onmessage = (ev) => this.onWsMessage(ev.data.toString()); - this.socket.onopen = () => this.onWsOpen(); - this.socket.onclose = () => this.onWsClose(); - this.socket.onerror = () => console.log('Websocket error'); - this.mode = targetMode; - this.poll(); // get initial state - break; - case HttpgdConnectionMode.NONE: - this.clearWebsocket(); - this.clearPoll(); - this.mode = targetMode; - break; - default: - break; - } - - } - - private clearPoll() { - if (this.pollHandle) { - clearInterval(this.pollHandle); - } - } - - private clearWebsocket() { - if (this.socket) { - this.socket.onclose = () => { /* ignore? */ }; - this.socket.close(); - } - } - - private poll(): void { - if (this.pausePoll) {return;} - this.api.get_state().then((remoteState: HttpgdState) => { - this.setDisconnected(false); - if (this.mode === HttpgdConnectionMode.SLOWPOLL) {this.start(HttpgdConnectionMode.WEBSOCKET);} // reconnect - if (this.pausePoll) {return;} - this.checkState(remoteState); - }).catch((e) => { - console.warn(e); - this.setDisconnected(true); - }); - } - - private onWsMessage(message: string): void { - if (message.startsWith('{')) { - const remoteState = JSON.parse(message) as HttpgdState; - this.checkState(remoteState); - } else { - console.log('Unknown WS message: ' + message); - } - } - private onWsClose(): void { - console.log('Websocket closed'); - this.setDisconnected(true); - } - private onWsOpen(): void { - console.log('Websocket opened'); - this.setDisconnected(false); - } - - private setDisconnected(disconnected: boolean): void { - if (this.disconnected !== disconnected) { - this.disconnected = disconnected; - if (this.disconnected) { - this.start(HttpgdConnectionMode.SLOWPOLL); - } else { - this.start(HttpgdConnectionMode.WEBSOCKET); - } - this.connectionChanged?.(disconnected); - } - } - - private checkState(remoteState: HttpgdState): void { - if ( - (!this.lastState) || - (this.lastState.active !== remoteState.active) || - (this.lastState.hsize !== remoteState.hsize) || - (this.lastState.upid !== remoteState.upid) - ) { - this.lastState = remoteState; - this.remoteStateChanged?.(remoteState); - } - } -} - -/** - * Public API for communicating with a httpgd server. - */ -export class Httpgd implements IHttpgdViewerApi { - - private connection: HttpgdConnection; - - // Constructor is called by the viewer: - public constructor(host: string, token?: string) - { - this.connection = new HttpgdConnection(host, token, true); - } - - // Opens the connection to the server - public start(): void { - this.connection.open(); - } - - // api calls: - // general state info: - public getState(): Promise { - return this.connection.api.get_state(); - } - // get list of plot Ids: - public getPlotIds(): Promise { - return this.connection.api.get_plots().then(res => res.plots.map(r => r.id)); - } - // get content of a single plot. Use sensible defaults if no height/width given: - public getPlotContent(id: PlotId, height?: number, width?: number, c?: string): Promise { - return this.connection.api.get_plot_contents(id, width, height, c); - } - // get content of multiple plots: - // Use sensible defaults if no height/width given. - // Return all plots if no ids given. - public getPlotContents(ids?: PlotId[], height?: number, width?: number): Promise { - if (!ids) { - return this.connection.api.get_plot_contents_all(); - } - const plots = ids.map(async id => { - return await this.connection.api.get_plot_contents(id, width, height); - }); - return Promise.all(plots); - } - - // close/remove plot - public async closePlot(id: PlotId): Promise { - await this.connection.api.get_remove_id(id); - } - - // Listen to connection changes of the httpgd server - // Todo: Expand to fill observer pattern with multiple listeners (?) - public onConnectionChange(listener: (disconnected: boolean) => void): void { - this.connection.connectionChanged = listener; - } - - // Listen to plot changes of the httpgd server - // Todo: Expand to fill observer pattern with multiple listeners (?) - public onPlotsChange(listener: () => void): void { - this.connection.remoteStateChanged = listener; - } - - // Dispose-function to clean up when vscode closes - public dispose(): void { - this.connection.close(); - } -} diff --git a/src/plotViewer/httpgdTypes.d.ts b/src/plotViewer/httpgdTypes.d.ts index 98820865e..23dc7c8ea 100644 --- a/src/plotViewer/httpgdTypes.d.ts +++ b/src/plotViewer/httpgdTypes.d.ts @@ -1,81 +1,25 @@ +import { Httpgd } from 'httpgd'; +import { HttpgdPlotId } from 'httpgd/lib/types'; import * as vscode from 'vscode'; import { HttpgdManager } from '.'; import { PreviewPlotLayout } from './webviewMessages'; export type MaybePromise = T | Promise; -// type to indicate where a plotId is required -export type PlotId = string; - -// supported file types for image export -export type ExportFormat = 'png' | 'jpg' | 'bmp' | 'svg'; - - -export interface HttpgdPlot { - // url of the connection this plot was retrieved from - url: string; - host: string; +export interface HttpgdPlot { // unique ID for this plot (w.r.t. this connection/device) - id: PlotId; + id: HttpgdPlotId; - // svg of the plot - svg: string; + // data of the plot + data: T; - // Size when computed: - // (displayed size might vary, if % values are used) + // Size height: number; width: number; -} - -export interface HttpgdState { - // What do these mean? - upid: number; - hsize: number; - active: boolean; - // /? - - // Include which plots have changed? - changedPlots?: PlotId[]; - - // Indicate that R wants to focus a specific plot? - focusPlot?: PlotId; -} - -// Roughly combines the functionality of HttpgdApi and HttpgdConnection -export declare class IHttpgdViewerApi { - // Constructor is called by the viewer: - public constructor(host: string, token?: string); - - // api calls: - // general state info: - public getState(): MaybePromise; - // get list of plot Ids: - public getPlotIds(): MaybePromise; - // get content of a single plot. Use sensible defaults if no height/width given: - public getPlotContent(id: PlotId, height?: number, width?: number): MaybePromise; - // get content of multiple plots: - // Use sensible defaults if no height/width given. - // Return all plots if no ids given. - public getPlotContents(ids?: PlotId[], height?: number, width?: number): MaybePromise; - - // Export functionality could maybe also be implemented inside vscode-R? - // Not sure which libraries produce better results... - // User querying for format and filename is done by vscode - public exportPlot?(id: PlotId, format: ExportFormat, outFile: string): MaybePromise; - - // Method to supply listeners - // The listener should be called when there is a change to the device - // Further info (new state, plots etc.) can then be queried by the viewer - public onConnectionChange(listener: (disconnected: boolean) => void): void; - public onPlotsChange(listener: () => void): void; - - // Dispose-function to clean up when vscode closes - // E.g. to close connections etc., notify R, ... - // Not sure if sensible here - public dispose?(): MaybePromise; + zoom: number; } // Example for possible viewer creation options: @@ -99,13 +43,13 @@ export class IHttpgdViewer { webviewPanel?: vscode.WebviewPanel; // Api that provides plot contents etc. - api?: IHttpgdViewerApi; + api: Httpgd; // active plots - plots: HttpgdPlot[]; + plots: HttpgdPlot[]; // Id of the currently viewed plot - activePlot?: PlotId; + activePlot?: HttpgdPlotId; // Size of the view area: viewHeight: number; @@ -123,7 +67,7 @@ export class IHttpgdViewer { show(preserveFocus?: boolean): void; // focus a specific plot id - focusPlot(id: PlotId): void; + focusPlot(id: HttpgdPlotId): void; // navigate through plots (supply `true` to go to end/beginning of list) nextPlot(last?: boolean): void; @@ -135,7 +79,7 @@ export class IHttpgdViewer { // export plot // if no format supplied, show a quickpick menu etc. // if no filename supplied, show selector window - exportPlot(id: PlotId, format?: ExportFormat, outFile?: string): void; + exportPlot(id: HttpgdPlotId, format?: string, outFile?: string): void; // Dispose-function to clean up when vscode closes // E.g. to close connections etc., notify R, ... diff --git a/src/plotViewer/index.ts b/src/plotViewer/index.ts index d9d5e7146..aa68f0c26 100644 --- a/src/plotViewer/index.ts +++ b/src/plotViewer/index.ts @@ -3,8 +3,8 @@ import * as vscode from 'vscode'; -import { Httpgd } from './httpgd'; -import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions, PlotId, ExportFormat, HttpgdState } from './httpgdTypes'; +import { Httpgd } from 'httpgd'; +import { HttpgdPlot, IHttpgdViewer, HttpgdViewerOptions } from './httpgdTypes'; import * as path from 'path'; import * as fs from 'fs'; import * as ejs from 'ejs'; @@ -14,8 +14,8 @@ import { config, setContext, UriIcon } from '../util'; import { extensionContext } from '../extension'; import { FocusPlotMessage, InMessage, OutMessage, ToggleStyleMessage, UpdatePlotMessage, HidePlotMessage, AddPlotMessage, PreviewPlotLayout, PreviewPlotLayoutMessage, ToggleFullWindowMessage } from './webviewMessages'; - -import { isHost, rHostService, shareBrowser } from '../liveShare'; +import { HttpgdIdResponse, HttpgdPlotId, HttpgdRendererId } from 'httpgd/lib/types'; +import { Response } from 'node-fetch'; const commands = [ 'showViewers', @@ -41,7 +41,7 @@ type CommandName = typeof commands[number]; export function initializeHttpgd(): HttpgdManager { const httpgdManager = new HttpgdManager(); - for(const cmd of commands){ + for (const cmd of commands) { const fullCommand = `r.plot.${cmd}`; const cb = httpgdManager.getCommandHandler(cmd); vscode.commands.registerCommand(fullCommand, cb); @@ -56,7 +56,7 @@ export class HttpgdManager { recentlyActiveViewers: HttpgdViewer[] = []; - constructor(){ + constructor() { const htmlRoot = extensionContext.asAbsolutePath('html/httpgd'); this.viewerOptions = { parent: this, @@ -67,17 +67,16 @@ export class HttpgdManager { public showViewer(urlString: string): void { const url = new URL(urlString); - const host = url.host; const token = url.searchParams.get('token') || undefined; const ind = this.viewers.findIndex( (viewer) => viewer.host === host ); - if(ind >= 0){ + if (ind >= 0) { const viewer = this.viewers.splice(ind, 1)[0]; this.viewers.unshift(viewer); viewer.show(); - } else{ + } else { const conf = config(); const colorTheme = conf.get('plot.defaults.colorTheme', 'vscode'); this.viewerOptions.stripStyles = (colorTheme === 'vscode'); @@ -93,7 +92,7 @@ export class HttpgdManager { public registerActiveViewer(viewer: HttpgdViewer): void { const ind = this.recentlyActiveViewers.indexOf(viewer); - if(ind){ + if (ind) { this.recentlyActiveViewers.splice(ind, 1); } this.recentlyActiveViewers.unshift(viewer); @@ -121,7 +120,7 @@ export class HttpgdManager { prompt: 'Please enter the httpgd url' }; const urlString = await vscode.window.showInputBox(options); - if(urlString){ + if (urlString) { this.showViewer(urlString); } } @@ -135,22 +134,22 @@ export class HttpgdManager { // below is an attempt to handle these different combinations efficiently and (somewhat) robustly // - if(command === 'showViewers'){ + if (command === 'showViewers') { this.viewers.forEach(viewer => { viewer.show(true); }); return; - } else if(command === 'openUrl'){ + } else if (command === 'openUrl') { void this.openUrl(); return; } // Identify the correct viewer let viewer: HttpgdViewer | undefined; - if(typeof hostOrWebviewUri === 'string'){ + if (typeof hostOrWebviewUri === 'string') { const host = hostOrWebviewUri; viewer = this.viewers.find((viewer) => viewer.host === host); - } else if(hostOrWebviewUri instanceof vscode.Uri){ + } else if (hostOrWebviewUri instanceof vscode.Uri) { const uri = hostOrWebviewUri; viewer = this.viewers.find((viewer) => viewer.getPanelPath() === uri.path); } @@ -159,7 +158,7 @@ export class HttpgdManager { viewer ||= this.getRecentViewer(); // Abort if no viewer identified - if(!viewer){ + if (!viewer) { return; } @@ -168,7 +167,7 @@ export class HttpgdManager { const boolArg = findItemOfType(args, 'boolean'); // Call corresponding method, possibly with an argument: - switch(command) { + switch (command) { case 'showIndex': { void viewer.focusPlot(stringArg); break; @@ -225,9 +224,9 @@ export class HttpgdManager { interface EjsData { overwriteStyles: boolean; previewPlotLayout: PreviewPlotLayout; - activePlot?: PlotId; - plots: HttpgdPlot[]; - largePlot: HttpgdPlot; + activePlot?: HttpgdPlotId; + plots: HttpgdPlot[]; + largePlot: HttpgdPlot; host: string; asLocalPath: (relPath: string) => string; asWebViewPath: (localPath: string) => string; @@ -235,7 +234,7 @@ interface EjsData { overwriteCssPath: string; // only used to render an individual smallPlot div: - plot?: HttpgdPlot; + plot?: HttpgdPlot; } interface ShowOptions { @@ -255,17 +254,16 @@ export class HttpgdViewer implements IHttpgdViewer { webviewPanel?: vscode.WebviewPanel; // Api that provides plot contents etc. - api?: Httpgd; + readonly api: Httpgd; // active plots - plots: HttpgdPlot[] = []; - state?: HttpgdState; + plots: HttpgdPlot[] = []; // Id of the currently viewed plot - activePlot?: PlotId; + activePlot?: HttpgdPlotId; // Ids of plots that are not shown, but not closed inside httpgd - hiddenPlots: PlotId[] = []; + hiddenPlots: HttpgdPlotId[] = []; readonly defaultStripStyles: boolean = true; stripStyles: boolean; @@ -287,14 +285,16 @@ export class HttpgdViewer implements IHttpgdViewer { plotHeight: number; plotWidth: number; - readonly scale0: number = 1; - scale: number = this.scale0; + readonly zoom0: number = 1; + zoom: number = this.zoom0; resizeTimeout?: NodeJS.Timeout; readonly resizeTimeoutLength: number = 1300; refreshTimeout?: NodeJS.Timeout; readonly refreshTimeoutLength: number = 10; + + private lastExportUri?: vscode.Uri; readonly htmlTemplate: string; readonly smallPlotTemplate: string; @@ -307,50 +307,44 @@ export class HttpgdViewer implements IHttpgdViewer { // Get/set active plot by index instead of id: protected get activeIndex(): number { + if(!this.activePlot){ + return -1; + } return this.getIndex(this.activePlot); } protected set activeIndex(ind: number) { - if(this.plots.length === 0){ + if (this.plots.length === 0) { this.activePlot = undefined; - } else{ + } else { ind = Math.max(ind, 0); ind = Math.min(ind, this.plots.length - 1); this.activePlot = this.plots[ind].id; } } - // Get scaled view size: - protected get scaledViewHeight(): number { - return this.viewHeight * this.scale; - } - protected get scaledViewWidth(): number { - return this.viewWidth * this.scale; - } - // constructor called by the session watcher if a corresponding function was called in R // creates a new api instance itself constructor(host: string, options: HttpgdViewerOptions) { this.host = host; this.token = options.token; this.parent = options.parent; - this.api = new Httpgd(this.host, this.token); - this.api.onPlotsChange(() => { - this.checkStateDelayed(); + + this.api = new Httpgd(this.host, this.token, true); + this.api.onPlotsChanged((newState) => { + void this.refreshPlots(newState.plots); }); - this.api.onConnectionChange((disconnected: boolean) => { - if(disconnected){ - this.api?.dispose(); - this.api = undefined; - } else{ - this.checkStateDelayed(); - } + this.api.onConnectionChanged(() => { + // todo + }); + this.api.onDeviceActiveChanged(() => { + // todo }); const conf = config(); this.customOverwriteCssPath = conf.get('plot.customStyleOverwrites', ''); const localResourceRoots = ( this.customOverwriteCssPath ? - [extensionContext.extensionUri, vscode.Uri.file(path.dirname(this.customOverwriteCssPath))] : - undefined + [extensionContext.extensionUri, vscode.Uri.file(path.dirname(this.customOverwriteCssPath))] : + undefined ); this.htmlRoot = options.htmlRoot; this.htmlTemplate = fs.readFileSync(path.join(this.htmlRoot, 'index.ejs'), 'utf-8'); @@ -373,8 +367,8 @@ export class HttpgdViewer implements IHttpgdViewer { this.fullWindow = this.defaultFullWindow; this.resizeTimeoutLength = options.refreshTimeoutLength ?? this.resizeTimeoutLength; this.refreshTimeoutLength = options.refreshTimeoutLength ?? this.refreshTimeoutLength; - this.api.start(); - void this.checkState(); + void this.api.connect(); + //void this.checkState(); } @@ -384,14 +378,14 @@ export class HttpgdViewer implements IHttpgdViewer { // Called to create a new webview if the user closed the old one: public show(preserveFocus?: boolean): void { preserveFocus ??= this.showOptions.preserveFocus; - if(!this.webviewPanel){ + if (!this.webviewPanel) { const showOptions = { ...this.showOptions, preserveFocus: preserveFocus }; this.webviewPanel = this.makeNewWebview(showOptions); this.refreshHtml(); - } else{ + } else { this.webviewPanel.reveal(undefined, preserveFocus); } this.parent.registerActiveViewer(this); @@ -399,7 +393,7 @@ export class HttpgdViewer implements IHttpgdViewer { public openExternal(): void { let urlString = `http://${this.host}/live`; - if(this.token){ + if (this.token) { urlString += `?token=${this.token}`; } const uri = vscode.Uri.parse(urlString); @@ -407,19 +401,20 @@ export class HttpgdViewer implements IHttpgdViewer { } // focus a specific plot id - public async focusPlot(id?: PlotId): Promise { - if(id){ - this.activePlot = id; - } + public async focusPlot(id?: HttpgdPlotId): Promise { + this.activePlot = id || this.activePlot; const plt = this.plots[this.activeIndex]; - if(plt.height !== this.viewHeight * this.scale || plt.width !== this.viewHeight * this.scale){ - await this.refreshPlots(); - } else{ + if (plt.height !== this.viewHeight || plt.width !== this.viewHeight || plt.zoom !== this.zoom) { + await this.refreshPlots(this.api.getPlots()); + } else { this._focusPlot(); } } - protected _focusPlot(plotId?: PlotId): void { + protected _focusPlot(plotId?: HttpgdPlotId): void { plotId ??= this.activePlot; + if(!plotId){ + return; + } const msg: FocusPlotMessage = { message: 'focusPlot', plotId: plotId @@ -430,34 +425,34 @@ export class HttpgdViewer implements IHttpgdViewer { // navigate through plots (supply `true` to go to end/beginning of list) public async nextPlot(last?: boolean): Promise { - this.activeIndex = last ? this.plots.length - 1 : this.activeIndex+1; + this.activeIndex = last ? this.plots.length - 1 : this.activeIndex + 1; await this.focusPlot(); } public async prevPlot(first?: boolean): Promise { - this.activeIndex = first ? 0 : this.activeIndex-1; + this.activeIndex = first ? 0 : this.activeIndex - 1; await this.focusPlot(); } // restore closed plots, reset zoom, redraw html public resetPlots(): void { this.hiddenPlots = []; - this.scale = this.scale0; - void this.refreshPlots(true, true); + this.zoom = this.zoom0; + void this.refreshPlots(this.api.getPlots(), true, true); } - public hidePlot(id?: PlotId): void { + public hidePlot(id?: HttpgdPlotId): void { id ??= this.activePlot; - if(!id){ return; } + if (!id) { return; } const tmpIndex = this.activeIndex; this.hiddenPlots.push(id); this.plots = this.plots.filter((plt) => !this.hiddenPlots.includes(plt.id)); - if(id === this.activePlot){ + if (id === this.activePlot) { this.activeIndex = tmpIndex; this._focusPlot(); } this._hidePlot(id); } - protected _hidePlot(id: PlotId): void { + protected _hidePlot(id: HttpgdPlotId): void { const msg: HidePlotMessage = { message: 'hidePlot', plotId: id @@ -465,15 +460,15 @@ export class HttpgdViewer implements IHttpgdViewer { this.postWebviewMessage(msg); } - public async closePlot(id?: PlotId): Promise { + public async closePlot(id?: HttpgdPlotId): Promise { id ??= this.activePlot; - if(id){ + if (id) { this.hidePlot(id); - await this.api?.closePlot(id); + await this.api.removePlot({ id: id }); } } - public toggleStyle(force?: boolean): void{ + public toggleStyle(force?: boolean): void { this.stripStyles = force ?? !this.stripStyles; const msg: ToggleStyleMessage = { message: 'toggleStyle', @@ -491,14 +486,14 @@ export class HttpgdViewer implements IHttpgdViewer { this.postWebviewMessage(msg); } - public togglePreviewPlots(force?: PreviewPlotLayout): void{ - if(force){ + public togglePreviewPlots(force?: PreviewPlotLayout): void { + if (force) { this.previewPlotLayout = force; - } else if(this.previewPlotLayout === 'multirow'){ + } else if (this.previewPlotLayout === 'multirow') { this.previewPlotLayout = 'scroll'; - } else if(this.previewPlotLayout === 'scroll'){ + } else if (this.previewPlotLayout === 'scroll') { this.previewPlotLayout = 'hidden'; - } else if(this.previewPlotLayout === 'hidden'){ + } else if (this.previewPlotLayout === 'hidden') { this.previewPlotLayout = 'multirow'; } const msg: PreviewPlotLayoutMessage = { @@ -508,87 +503,54 @@ export class HttpgdViewer implements IHttpgdViewer { this.postWebviewMessage(msg); } - public zoomIn(): void { - if(this.scale > 0){ - this.scale -= 0.1; + public zoomOut(): void { + if (this.zoom > 0) { + this.zoom -= 0.1; void this.resizePlot(); } } - public zoomOut(): void { - this.scale += 0.1; + public zoomIn(): void { + this.zoom += 0.1; void this.resizePlot(); } - public async setContextValues(mightBeInBackground: boolean = false): Promise { - if(this.webviewPanel?.active){ + public async setContextValues(mightBeInBackground: boolean = false): Promise { + if (this.webviewPanel?.active) { this.parent.registerActiveViewer(this); await setContext('r.plot.active', true); await setContext('r.plot.canGoBack', this.activeIndex > 0); await setContext('r.plot.canGoForward', this.activeIndex < this.plots.length - 1); - } else if (!mightBeInBackground){ + } else if (!mightBeInBackground) { await setContext('r.plot.active', false); } - } + } public getPanelPath(): string | undefined { - if(!this.webviewPanel) { + if (!this.webviewPanel) { return undefined; } const dummyUri = this.webviewPanel.webview.asWebviewUri(vscode.Uri.file('')); const m = /^[^.]*/.exec(dummyUri.authority); - const webviewId = m[0] || ''; + const webviewId = m?.[0] || ''; return `webview-panel/webview-${webviewId}`; } - // internal functions - // - - // use a delay to avoid refreshing while a plot is incrementally drawn - protected checkStateDelayed(): void { - clearTimeout(this.refreshTimeout); - if(this.refreshTimeoutLength <= 0){ - void this.checkState(); - this.refreshTimeout = undefined; - } else { - this.refreshTimeout = setTimeout(() => { - void this.checkState(); - }, this.refreshTimeoutLength); - } - } - protected async checkState(): Promise { - if(!this.api){ - return; - } - const oldUpid = this.state?.upid; - this.state = await this.api.getState(); - if(this.state.upid !== oldUpid){ - await this.refreshPlots(); - if (isHost()) { - const urlString = `http://${this.host}/live?token=${this.token}`; - void shareBrowser( - urlString, - '[VSC-R] R Plot', - true - ); - void rHostService.notifyGuestPlotManager(urlString); - } - } - } - - protected getIndex(id: PlotId): number { - return this.plots.findIndex((plt: HttpgdPlot) => plt.id === id); + protected getIndex(id: HttpgdPlotId): number { + return this.plots.findIndex((plt: HttpgdPlot) => plt.id === id); } protected handleResize(height: number, width: number, userTriggered: boolean = false): void { this.viewHeight = height; this.viewWidth = width; - if(userTriggered || this.resizeTimeoutLength === 0){ - clearTimeout(this.resizeTimeout); + if (userTriggered || this.resizeTimeoutLength === 0) { + if(this.resizeTimeout){ + clearTimeout(this.resizeTimeout); + } this.resizeTimeout = undefined; void this.resizePlot(); - } else if(!this.resizeTimeout){ + } else if (!this.resizeTimeout) { this.resizeTimeout = setTimeout(() => { void this.resizePlot().then(() => this.resizeTimeout = undefined @@ -597,46 +559,40 @@ export class HttpgdViewer implements IHttpgdViewer { } } - protected async resizePlot(id?: PlotId): Promise { + protected async resizePlot(id?: HttpgdPlotId): Promise { id ??= this.activePlot; - if(!id){ return; } - const height = this.scaledViewHeight; - const width = this.scaledViewWidth; - const plt = await this.getPlotContent(id, height, width); - if(plt){ - this.plotWidth = plt.width; - this.plotHeight = plt.height; - this.updatePlot(plt); - } + if (!id) { return; } + const plt = await this.getPlotContent(id, this.viewWidth, this.viewHeight, this.zoom); + this.plotWidth = plt.width; + this.plotHeight = plt.height; + this.updatePlot(plt); } - protected async refreshPlots(redraw: boolean = false, force: boolean = false): Promise { - if(!this.api){ - return; - } + protected async refreshPlots(plotsIdResponse: HttpgdIdResponse[], redraw: boolean = false, force: boolean = false): Promise { const nPlots = this.plots.length; - const oldPlotIds = this.plots.map(plt => plt.id); - let plotIds = await this.api.getPlotIds(); + let plotIds = plotsIdResponse.map((x) => x.id); plotIds = plotIds.filter((id) => !this.hiddenPlots.includes(id)); - const newPlots = plotIds.map(async (id) => { + const newPlotPromises = plotIds.map(async (id) => { const plot = this.plots.find((plt) => plt.id === id); - if(force || !plot || id === this.activePlot){ - return await this.getPlotContent(id, this.scaledViewHeight, this.scaledViewWidth); - } else{ + if (force || !plot || id === this.activePlot) { + return await this.getPlotContent(id, this.viewWidth, this.viewHeight, this.zoom); + } else { return plot; } }); - this.plots = await Promise.all(newPlots); - if(this.plots.length !== nPlots){ + const newPlots = await Promise.all(newPlotPromises); + const oldPlotIds = this.plots.map(plt => plt.id); + this.plots = newPlots; + if (this.plots.length !== nPlots) { this.activePlot = this.plots[this.plots.length - 1]?.id; } - if(redraw || !this.webviewPanel){ + if (redraw || !this.webviewPanel) { this.refreshHtml(); - } else{ - for(const plt of this.plots){ - if(oldPlotIds.includes(plt.id)){ + } else { + for (const plt of this.plots) { + if (oldPlotIds.includes(plt.id)) { this.updatePlot(plt); - } else{ + } else { this.addPlot(plt); } } @@ -644,16 +600,16 @@ export class HttpgdViewer implements IHttpgdViewer { } } - protected updatePlot(plt: HttpgdPlot): void { + protected updatePlot(plt: HttpgdPlot): void { const msg: UpdatePlotMessage = { message: 'updatePlot', plotId: plt.id, - svg: plt.svg + svg: plt.data }; this.postWebviewMessage(msg); } - protected addPlot(plt: HttpgdPlot): void { + protected addPlot(plt: HttpgdPlot): void { const ejsData = this.makeEjsData(); ejsData.plot = plt; const html = ejs.render(this.smallPlotTemplate, ejsData); @@ -662,18 +618,32 @@ export class HttpgdViewer implements IHttpgdViewer { html: html }; this.postWebviewMessage(msg); + void this.focusPlot(plt.id); void this.setContextValues(); } - protected async getPlotContent(id: PlotId, height?: number, width?: number): Promise { - if(!this.api){ - return undefined; - } - height ||= this.scaledViewHeight; - width ||= this.scaledViewWidth; - const plt = await this.api.getPlotContent(id, height, width); - stripSize(plt); - makeIdsUnique(plt, this.state?.upid || 0); + // get content of a single plot + protected async getPlotContent(id: HttpgdPlotId, width: number, height: number, zoom: number): Promise> { + + const args = { + id: id, + height: height, + width: width, + zoom: zoom, + renderer: 'svgp' + }; + + const plotContent = await this.api.getPlot(args); + const svg = await plotContent?.text() || ''; + + const plt: HttpgdPlot = { + id: id, + data: svg, + height: height, + width: width, + zoom: zoom, + }; + this.viewHeight ??= plt.height; this.viewWidth ??= plt.width; return plt; @@ -699,14 +669,14 @@ export class HttpgdViewer implements IHttpgdViewer { protected makeEjsData(): EjsData { const asLocalPath = (relPath: string) => { - if(!this.webviewPanel){ + if (!this.webviewPanel) { return relPath; } const localUri = vscode.Uri.file(path.join(this.htmlRoot, relPath)); return localUri.fsPath; }; const asWebViewPath = (localPath: string) => { - if(!this.webviewPanel){ + if (!this.webviewPanel) { return localPath; } const localUri = vscode.Uri.file(path.join(this.htmlRoot, localPath)); @@ -718,10 +688,10 @@ export class HttpgdViewer implements IHttpgdViewer { return `command:${command}?${argString}`; }; let overwriteCssPath = ''; - if(this.customOverwriteCssPath){ + if (this.customOverwriteCssPath) { const uri = vscode.Uri.file(this.customOverwriteCssPath); - overwriteCssPath = this.webviewPanel.webview.asWebviewUri(uri).toString(); - } else{ + overwriteCssPath = this.webviewPanel?.webview.asWebviewUri(uri).toString() || ''; + } else { overwriteCssPath = asWebViewPath('styleOverwrites.css'); } const ejsData: EjsData = { @@ -758,9 +728,9 @@ export class HttpgdViewer implements IHttpgdViewer { } protected handleWebviewMessage(msg: OutMessage): void { - if(msg.message === 'log'){ + if (msg.message === 'log') { console.log(msg.body); - } else if(msg.message === 'resize'){ + } else if (msg.message === 'resize') { const height = msg.height; const width = msg.width; const userTriggered = msg.userTriggered; @@ -776,50 +746,91 @@ export class HttpgdViewer implements IHttpgdViewer { // export plot // if no format supplied, show a quickpick menu etc. // if no filename supplied, show selector window - public async exportPlot(id?: PlotId, format?: ExportFormat, outFile?: string): Promise { + public async exportPlot(id?: HttpgdPlotId, rendererId?: HttpgdRendererId, outFile?: string): Promise { // make sure id is valid or return: - id ||= this.activePlot || this.plots[this.plots.length-1]?.id; + id ||= this.activePlot || this.plots[this.plots.length - 1]?.id; const plot = this.plots.find((plt) => plt.id === id); - if(!plot){ + if (!plot) { void vscode.window.showWarningMessage('No plot available for export.'); return; } // make sure format is valid or return: - if(!format){ - const formats: ExportFormat[] = ['svg']; + if (!rendererId) { + const renderers = this.api.getRenderers(); + const qpItems = renderers.map(renderer => ({ + label: renderer.name, + detail: renderer.descr, + id: renderer.id + })); const options: vscode.QuickPickOptions = { placeHolder: 'Please choose a file format' }; - format = await vscode.window.showQuickPick(formats, options) as ExportFormat | undefined; - if(!format){ + // format = await vscode.window.showQuickPick(formats, options); + const qpPick = await vscode.window.showQuickPick(qpItems, options); + rendererId = qpPick?.id; + if(!rendererId){ return; } } // make sure outFile is valid or return: - if(!outFile){ + if (!outFile) { const options: vscode.SaveDialogOptions = {}; + + // Suggest a file extension: + const renderer = this.api.getRenderers().find(r => r.id === rendererId); + const ext = renderer?.ext.replace(/^\./, ''); + + // try to set default URI: + if(this.lastExportUri){ + const noExtPath = this.lastExportUri.fsPath.replace(/\.[^.]*$/, ''); + const defaultPath = noExtPath + (ext ? `.${ext}` : ''); + options.defaultUri = vscode.Uri.file(defaultPath); + } else { + // construct default Uri + const defaultFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if(defaultFolder){ + const defaultName = 'plot' + (ext ? `.${ext}` : ''); + options.defaultUri = vscode.Uri.file(path.join(defaultFolder, defaultName)); + } + } + // set file extension filter + if(ext && renderer?.name){ + options.filters = { + [renderer.name]: [ext], + ['All']: ['*'], + }; + } + const outUri = await vscode.window.showSaveDialog(options); - outFile = outUri?.fsPath; - if(!outFile){ + if(outUri){ + this.lastExportUri = outUri; + outFile = outUri.fsPath; + } else { return; } } - // actually export plot: - if(format === 'svg'){ - // do export - fs.writeFileSync(outFile, plot.svg); - // const uri = vscode.Uri.file(outFile); - // await vscode.workspace.openTextDocument(uri); - // void vscode.window.showTextDocument(uri); - } else{ - void vscode.window.showWarningMessage('Format not implemented'); - } + // get plot: + const plt = await this.api.getPlot({ + id: this.activePlot, + renderer: rendererId + }) as unknown as Response; // I am not sure why eslint thinks this is the + // browser Response object and not the node-fetch one. + // cross-fetch problem or config problem in vscode-r? + + const dest = fs.createWriteStream(outFile); + dest.on('error', (err) => void vscode.window.showErrorMessage( + `Export failed: ${err.message}` + )); + dest.on('close', () => void vscode.window.showInformationMessage( + `Export done: ${outFile}` + )); + void plt.body.pipe(dest); } // Dispose-function to clean up when vscode closes // E.g. to close connections etc., notify R, ... public dispose(): void { - this.api?.dispose(); + this.api.disconnect(); } } @@ -831,40 +842,3 @@ function findItemOfType(arr: any[], type: string): T { const item = arr.find((elm) => typeof elm === type) as T; return item; } - - -function stripSize(plt: HttpgdPlot): void { - const re = /<(svg.*)width="([^"]*)" height="([^"]*)"(.*)>/; - const m = re.exec(plt.svg); - if(!plt.width || isNaN(plt.width)){ - plt.width = Number(m[2]); - } - if(!plt.height || isNaN(plt.height)){ - plt.height = Number(m[3]); - } - plt.svg = plt.svg.replace(re, '<$1 preserveAspectRatio="none" $4>'); -} - -function makeIdsUnique(plt: HttpgdPlot, upid: number): void { - const re = //g; - const ids: string[] = []; - let svg = plt.svg; - let m: RegExpExecArray; - do { - m = re.exec(svg); - if(m){ - ids.push(m[1]); - } - } while(m); - for(const id of ids){ - const newId = `$${upid}_${plt.id}_${plt.height}_${plt.width}_${id}`; - const re1 = new RegExp(``); - const replacement1 = ``; - const re2 = new RegExp(`clip-path='url\\(#${id}\\)'`, 'g'); - const replacement2 = `clip-path='url(#${newId})'`; - svg = svg.replace(re1, replacement1); - svg = svg.replace(re2, replacement2); - } - plt.svg = svg; - return; -} diff --git a/yarn.lock b/yarn.lock index fc55ecaa9..8e9baca73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -270,6 +270,13 @@ dependencies: "@types/node" "*" +"@types/ws@^8.2.0": + version "8.2.0" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.2.0.tgz#75faefbe2328f3b833cb8dc640658328990d04f3" + integrity sha512-cyeefcUCgJlEk+hk2h3N+MqKKsPViQgF5boi9TTHSK+PoR9KWBb/C5ccPcDyAqgsbAYHTwulch725DV84+pSpg== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@4.25.0": version "4.25.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.25.0.tgz#d82657b6ab4caa4c3f888ff923175fadc2f31f2a" @@ -902,6 +909,13 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= +cross-fetch@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" + integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ== + dependencies: + node-fetch "2.6.1" + cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -1530,6 +1544,16 @@ http-proxy-agent@^4.0.1: agent-base "6" debug "4" +httpgd@^0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/httpgd/-/httpgd-0.1.6.tgz#bce29a769aa2662128b8be553eb3230cf5fe6da3" + integrity sha512-HyozzYjOq+rGi3P+YZtLnvBPAWvdn2tiCfUuB4tSUradRtOoKAvwcZ+yvOYxusMzaZIGkf02s/BTkcDzj+XS/w== + dependencies: + "@types/ws" "^8.2.0" + cross-fetch "^3.1.4" + isomorphic-ws "^4.0.1" + ws "^8.2.3" + https-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2" @@ -1675,6 +1699,11 @@ isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= +isomorphic-ws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" + integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== + jake@^10.6.1: version "10.8.2" resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b" @@ -1979,7 +2008,7 @@ nerdbank-streams@2.5.60: caught "^0.1.3" msgpack-lite "^0.1.26" -node-fetch@^2.6.1: +node-fetch@2.6.1, node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== @@ -2848,6 +2877,11 @@ ws@^7.4.6: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== +ws@^8.2.3: + version "8.2.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.2.3.tgz#63a56456db1b04367d0b721a0b80cae6d8becbba" + integrity sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA== + y18n@^4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"