mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-01-02 18:39:05 +01:00
pdf viewer (#448)
* checkpoint on pdf viewer * implement a pdf renderer (fix emain shFrameNavHandler to allow it)
This commit is contained in:
parent
bff51c851a
commit
4c68fc4ceb
@ -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 };
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
7
src/plugins/pdf/pdf.less
Normal 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
49
src/plugins/pdf/pdf.tsx
Normal 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 };
|
@ -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 {
|
||||||
|
@ -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))
|
||||||
|
@ -35,6 +35,7 @@ var BareMetaCmds = []BareMetaCmdDecl{
|
|||||||
{"markdownview", "markdownview"},
|
{"markdownview", "markdownview"},
|
||||||
{"mdview", "markdownview"},
|
{"mdview", "markdownview"},
|
||||||
{"csvview", "csvview"},
|
{"csvview", "csvview"},
|
||||||
|
{"pdfview", "pdfview"},
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
Loading…
Reference in New Issue
Block a user