pdf viewer (#448)

* checkpoint on pdf viewer

* implement a pdf renderer (fix emain shFrameNavHandler to allow it)
This commit is contained in:
Mike Sawka 2024-03-13 23:25:11 -07:00 committed by GitHub
parent bff51c851a
commit 4c68fc4ceb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 111 additions and 279 deletions

View File

@ -1,276 +0,0 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobxReact from "mobx-react";
import * as mobx from "mobx";
import { debounce } from "throttle-debounce";
import * as util from "@/util/util";
import { GlobalModel } from "@/models";
class SimpleBlobRendererModel {
context: RendererContext;
opts: RendererOpts;
isDone: OV<boolean>;
api: RendererModelContainerApi;
savedHeight: number;
loading: OV<boolean>;
loadError: OV<string> = mobx.observable.box(null, {
name: "renderer-loadError",
});
lineState: LineStateType;
ptyData: PtyDataType;
ptyDataSource: (termContext: TermContextUnion) => Promise<PtyDataType>;
dataBlob: Blob;
readOnly: boolean;
notFound: boolean;
initialize(params: RendererModelInitializeParams): void {
this.loading = mobx.observable.box(true, { name: "renderer-loading" });
this.isDone = mobx.observable.box(params.isDone, {
name: "renderer-isDone",
});
this.context = params.context;
this.opts = params.opts;
this.api = params.api;
this.lineState = params.lineState;
this.savedHeight = params.savedHeight;
this.ptyDataSource = params.ptyDataSource;
if (this.isDone.get()) {
setTimeout(() => this.reload(0), 10);
}
}
dispose(): void {
return;
}
giveFocus(): void {
return;
}
updateOpts(update: RendererOptsUpdate): void {
Object.assign(this.opts, update);
}
updateHeight(newHeight: number): void {
if (this.savedHeight != newHeight) {
this.savedHeight = newHeight;
this.api.saveHeight(newHeight);
}
}
setIsDone(): void {
if (this.isDone.get()) {
return;
}
mobx.action(() => {
this.isDone.set(true);
})();
this.reload(0);
}
reload(delayMs: number): void {
mobx.action(() => {
this.loading.set(true);
})();
if (delayMs == 0) {
this.reload_noDelay();
} else {
setTimeout(() => {
this.reload_noDelay();
}, delayMs);
}
}
reload_noDelay(): void {
let source = this.lineState["prompt:source"] || "pty";
if (source == "pty") {
this.reloadPtyData();
} else if (source == "file") {
this.reloadFileData();
} else {
mobx.action(() => {
this.loadError.set("error: invalid load source: " + source);
})();
}
}
reloadFileData(): void {
// todo add file methods to API, so we don't have a GlobalModel dependency here!
let path = this.lineState["prompt:file"];
if (util.isBlank(path)) {
mobx.action(() => {
this.loadError.set("renderer has file source, but no prompt:file specified");
})();
return;
}
let rtnp = GlobalModel.readRemoteFile(this.context.screenId, this.context.lineId, path);
rtnp.then((file) => {
this.notFound = (file as any).notFound;
this.readOnly = (file as any).readOnly;
this.dataBlob = file;
mobx.action(() => {
this.loading.set(false);
this.loadError.set(null);
})();
}).catch((e) => {
mobx.action(() => {
this.loadError.set("error loading file data: " + e);
})();
});
}
reloadPtyData(): void {
this.readOnly = true;
let rtnp = this.ptyDataSource(this.context);
if (rtnp == null) {
console.log("no promise returned from ptyDataSource (simplerenderer)", this.context);
return;
}
rtnp.then((ptydata) => {
this.ptyData = ptydata;
this.dataBlob = new Blob([this.ptyData.data]);
mobx.action(() => {
this.loading.set(false);
this.loadError.set(null);
})();
}).catch((e) => {
mobx.action(() => {
this.loadError.set("error loading data: " + e);
})();
});
}
receiveData(pos: number, data: Uint8Array, reason?: string): void {
// this.dataBuf.receiveData(pos, data, reason);
}
}
@mobxReact.observer
class SimpleBlobRenderer extends React.Component<
{
rendererContainer: RendererContainerType;
lineId: string;
plugin: RendererPluginType;
onHeightChange: () => void;
initParams: RendererModelInitializeParams;
scrollToBringIntoViewport: () => void;
isSelected: boolean;
shouldFocus: boolean;
},
{}
> {
model: SimpleBlobRendererModel;
wrapperDivRef: React.RefObject<any> = React.createRef();
rszObs: ResizeObserver;
updateHeight_debounced: (newHeight: number) => void;
constructor(props: any) {
super(props);
let { rendererContainer, lineId, plugin, initParams } = this.props;
this.model = new SimpleBlobRendererModel();
this.model.initialize(initParams);
rendererContainer.registerRenderer(lineId, this.model);
this.updateHeight_debounced = debounce(1000, this.updateHeight.bind(this));
}
updateHeight(newHeight: number): void {
this.model.updateHeight(newHeight);
}
handleResize(entries: ResizeObserverEntry[]): void {
if (this.model.loading.get()) {
return;
}
if (this.props.onHeightChange) {
this.props.onHeightChange();
}
if (!this.model.loading.get() && this.wrapperDivRef.current != null) {
let height = this.wrapperDivRef.current.offsetHeight;
this.updateHeight_debounced(height);
}
}
checkRszObs() {
if (this.rszObs != null) {
return;
}
if (this.wrapperDivRef.current == null) {
return;
}
this.rszObs = new ResizeObserver(this.handleResize.bind(this));
this.rszObs.observe(this.wrapperDivRef.current);
}
componentDidMount() {
this.checkRszObs();
}
componentWillUnmount() {
let { rendererContainer, lineId } = this.props;
rendererContainer.unloadRenderer(lineId);
if (this.rszObs != null) {
this.rszObs.disconnect();
this.rszObs = null;
}
}
componentDidUpdate() {
this.checkRszObs();
}
render() {
let { plugin } = this.props;
let model = this.model;
if (model.loadError.get() != null) {
let errorText = model.loadError.get();
let height = this.model.savedHeight;
return (
<div ref={this.wrapperDivRef} style={{ minHeight: height, fontSize: model.opts.termFontSize }}>
<div className="load-error-text">ERROR: {errorText}</div>
</div>
);
}
if (model.loading.get()) {
let height = this.model.savedHeight;
return (
<div
ref={this.wrapperDivRef}
className="renderer-loading"
style={{ minHeight: height, fontSize: model.opts.termFontSize }}
>
loading content <i className="fa fa-ellipsis fa-fade" />
</div>
);
}
let Comp = plugin.simpleComponent;
if (Comp == null) {
<div ref={this.wrapperDivRef}>(no component found in plugin)</div>;
}
let { festate, cmdstr, exitcode } = this.props.initParams.rawCmd;
return (
<div ref={this.wrapperDivRef} className="sr-wrapper">
<Comp
cwd={festate.cwd}
cmdstr={cmdstr}
exitcode={exitcode}
data={model.dataBlob as ExtBlob}
readOnly={model.readOnly}
notFound={model.notFound}
lineState={model.lineState}
context={model.context}
opts={model.opts}
savedHeight={model.savedHeight}
scrollToBringIntoViewport={this.props.scrollToBringIntoViewport}
isSelected={this.props.isSelected}
shouldFocus={this.props.shouldFocus}
rendererApi={model.api}
/>
</div>
);
}
}
export { SimpleBlobRendererModel, SimpleBlobRenderer };

View File

@ -308,16 +308,21 @@ function shFrameNavHandler(event: Electron.Event<Electron.WebContentsWillFrameNa
// only use this handler to process iframe events (non-iframe events go to shNavHandler) // only use this handler to process iframe events (non-iframe events go to shNavHandler)
return; return;
} }
event.preventDefault();
const url = event.url; const url = event.url;
console.log(`frame-navigation url=${url} frame=${event.frame.name}`); console.log(`frame-navigation url=${url} frame=${event.frame.name}`);
if (event.frame.name == "webview") { if (event.frame.name == "webview") {
// "webview" links always open in new window // "webview" links always open in new window
// this will *not* effect the initial load because srcdoc does not count as an electron navigation // this will *not* effect the initial load because srcdoc does not count as an electron navigation
console.log("open external, frameNav", url); console.log("open external, frameNav", url);
event.preventDefault();
electron.shell.openExternal(url); electron.shell.openExternal(url);
return; return;
} }
if (event.frame.name == "pdfview" && url.startsWith("blob:file:///")) {
// allowed
return;
}
event.preventDefault();
console.log("frame navigation canceled"); console.log("frame navigation canceled");
} }

View File

@ -1550,12 +1550,15 @@ class Model {
return remote.remotecanonicalname; return remote.remotecanonicalname;
} }
readRemoteFile(screenId: string, lineId: string, path: string): Promise<ExtFile> { readRemoteFile(screenId: string, lineId: string, path: string, mimetype?: string): Promise<ExtFile> {
const urlParams = { const urlParams: Record<string, string> = {
screenid: screenId, screenid: screenId,
lineid: lineId, lineid: lineId,
path: path, path: path,
}; };
if (mimetype != null) {
urlParams["mimetype"] = mimetype;
}
const usp = new URLSearchParams(urlParams); const usp = new URLSearchParams(urlParams);
const url = new URL(this.getBaseHostPort() + "/api/read-file?" + usp.toString()); const url = new URL(this.getBaseHostPort() + "/api/read-file?" + usp.toString());
const fetchHeaders = this.getFetchHeaders(); const fetchHeaders = this.getFetchHeaders();

7
src/plugins/pdf/pdf.less Normal file
View File

@ -0,0 +1,7 @@
.pdf-renderer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: var(--termpad);
}

49
src/plugins/pdf/pdf.tsx Normal file
View File

@ -0,0 +1,49 @@
// Copyright 2024, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import * as React from "react";
import * as mobx from "mobx";
import * as mobxReact from "mobx-react";
import "./pdf.less";
@mobxReact.observer
class SimplePdfRenderer extends React.Component<
{ data: ExtBlob; context: RendererContext; opts: RendererOpts; savedHeight: number },
{}
> {
objUrl: string = null;
componentWillUnmount() {
if (this.objUrl != null) {
URL.revokeObjectURL(this.objUrl);
}
}
render() {
let dataBlob = this.props.data;
if (dataBlob == null || dataBlob.notFound) {
return (
<div className="pdf-renderer" style={{ fontSize: this.props.opts.termFontSize }}>
<div className="load-error-text">
ERROR: file {dataBlob && dataBlob.name ? JSON.stringify(dataBlob.name) : ""} not found
</div>
</div>
);
}
if (this.objUrl == null) {
const pdfBlob = new File([dataBlob], "test.pdf", { type: "application/pdf" });
this.objUrl = URL.createObjectURL(pdfBlob);
}
const opts = this.props.opts;
const maxHeight = opts.maxSize.height - 10;
const maxWidth = opts.maxSize.width - 10;
return (
<div className="pdf-renderer">
<iframe src={this.objUrl} width={maxWidth} height={maxHeight} name="pdfview" />
</div>
);
}
}
export { SimplePdfRenderer };

View File

@ -7,6 +7,7 @@ import { SourceCodeRenderer } from "./code/code";
import { SimpleMustacheRenderer } from "./mustache/mustache"; import { SimpleMustacheRenderer } from "./mustache/mustache";
import { CSVRenderer } from "./csv/csv"; import { CSVRenderer } from "./csv/csv";
import { OpenAIRenderer, OpenAIRendererModel } from "./openai/openai"; import { OpenAIRenderer, OpenAIRendererModel } from "./openai/openai";
import { SimplePdfRenderer } from "./pdf/pdf";
import { isBlank } from "@/util/util"; import { isBlank } from "@/util/util";
import { sprintf } from "sprintf-js"; import { sprintf } from "sprintf-js";
@ -78,6 +79,16 @@ const PluginConfigs: RendererPluginType[] = [
mimeTypes: ["image/*"], mimeTypes: ["image/*"],
simpleComponent: SimpleImageRenderer, simpleComponent: SimpleImageRenderer,
}, },
{
name: "pdf",
rendererType: "simple",
heightType: "pixels",
dataType: "blob",
collapseType: "hide",
globalCss: null,
mimeTypes: ["application/pdf"],
simpleComponent: SimplePdfRenderer,
},
]; ];
class PluginModelClass { class PluginModelClass {

View File

@ -279,6 +279,7 @@ func init() {
registerCmdFn("imageview", ImageViewCommand) registerCmdFn("imageview", ImageViewCommand)
registerCmdFn("mdview", MarkdownViewCommand) registerCmdFn("mdview", MarkdownViewCommand)
registerCmdFn("markdownview", MarkdownViewCommand) registerCmdFn("markdownview", MarkdownViewCommand)
registerCmdFn("pdfview", PdfViewCommand)
registerCmdFn("csvview", CSVViewCommand) registerCmdFn("csvview", CSVViewCommand)
} }
@ -4883,6 +4884,37 @@ func ImageViewCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (sc
return update, nil return update, nil
} }
func PdfViewCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
if len(pk.Args) == 0 {
return nil, fmt.Errorf("%s requires an argument (file name)", GetCmdStr(pk))
}
// TODO more error checking on filename format?
if pk.Args[0] == "" {
return nil, fmt.Errorf("%s argument cannot be empty", GetCmdStr(pk))
}
ids, err := resolveUiIds(ctx, pk, R_Session|R_Screen|R_RemoteConnected)
if err != nil {
return nil, err
}
outputStr := fmt.Sprintf("%s %q", GetCmdStr(pk), pk.Args[0])
cmd, err := makeStaticCmd(ctx, GetCmdStr(pk), ids, pk.GetRawStr(), []byte(outputStr))
if err != nil {
// TODO tricky error since the command was a success, but we can't show the output
return nil, err
}
// set the line state
lineState := make(map[string]any)
lineState[sstore.LineState_Source] = "file"
lineState[sstore.LineState_File] = pk.Args[0]
update, err := addLineForCmd(ctx, "/"+GetCmdStr(pk), false, ids, cmd, "pdf", lineState)
if err != nil {
// TODO tricky error since the command was a success, but we can't show the output
return nil, err
}
update.AddUpdate(sstore.InteractiveUpdate(pk.Interactive))
return update, nil
}
func MarkdownViewCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) { func MarkdownViewCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
if len(pk.Args) == 0 { if len(pk.Args) == 0 {
return nil, fmt.Errorf("%s requires an argument (file name)", GetCmdStr(pk)) return nil, fmt.Errorf("%s requires an argument (file name)", GetCmdStr(pk))

View File

@ -35,6 +35,7 @@ var BareMetaCmds = []BareMetaCmdDecl{
{"markdownview", "markdownview"}, {"markdownview", "markdownview"},
{"mdview", "markdownview"}, {"mdview", "markdownview"},
{"csvview", "csvview"}, {"csvview", "csvview"},
{"pdfview", "pdfview"},
} }
const ( const (