2020-10-02 15:10:00 +00:00
|
|
|
import * as BaseView from 'app/client/components/BaseView';
|
|
|
|
import {Cursor} from 'app/client/components/Cursor';
|
2022-01-12 13:30:51 +00:00
|
|
|
import * as commands from 'app/client/components/commands';
|
|
|
|
import {GristDoc} from 'app/client/components/GristDoc';
|
|
|
|
import {ConfigNotifier, CustomSectionAPIImpl, GristDocAPIImpl, GristViewImpl,
|
|
|
|
MinimumLevel, RecordNotifier, TableNotifier, WidgetAPIImpl,
|
|
|
|
WidgetFrame} from 'app/client/components/WidgetFrame';
|
|
|
|
import {CustomSectionElement, ViewProcess} from 'app/client/lib/CustomSectionElement';
|
|
|
|
import {Disposable} from 'app/client/lib/dispose';
|
2020-10-02 15:10:00 +00:00
|
|
|
import * as dom from 'app/client/lib/dom';
|
|
|
|
import * as kd from 'app/client/lib/koDom';
|
|
|
|
import * as DataTableModel from 'app/client/models/DataTableModel';
|
2022-01-12 13:30:51 +00:00
|
|
|
import {ViewSectionRec} from 'app/client/models/DocModel';
|
|
|
|
import {CustomViewSectionDef} from 'app/client/models/entities/ViewSectionRec';
|
|
|
|
import {UserError} from 'app/client/models/errors';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {SortedRowSet} from 'app/client/models/rowset';
|
2022-01-12 13:30:51 +00:00
|
|
|
import {PluginInstance} from 'app/common/PluginInstance';
|
2020-10-02 15:10:00 +00:00
|
|
|
import {Events as BackboneEvents} from 'backbone';
|
2022-01-12 13:30:51 +00:00
|
|
|
import {dom as grains} from 'grainjs';
|
2020-10-02 15:10:00 +00:00
|
|
|
import * as ko from 'knockout';
|
|
|
|
import defaults = require('lodash/defaults');
|
2022-01-12 13:30:51 +00:00
|
|
|
import {AccessLevel} from 'app/common/CustomWidget';
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* CustomView components displays arbitrary html. There are two modes available, in the "url" mode
|
|
|
|
* the content is hosted by a third-party (for instance a github page), as opposed to the "plugin"
|
|
|
|
* mode where the contents is provided by a plugin. In both cases the content is rendered safely
|
|
|
|
* within an iframe (or webview if running electron). Configuration of the component is done within
|
|
|
|
* the view config tab in the side pane. In "plugin" mode, shows notification if either the plugin
|
|
|
|
* of the section could not be found.
|
|
|
|
*/
|
|
|
|
export class CustomView extends Disposable {
|
|
|
|
|
2022-01-12 13:30:51 +00:00
|
|
|
private static _commands = {
|
|
|
|
async openWidgetConfiguration(this: CustomView) {
|
|
|
|
if (!this.isDisposed() && !this._frame?.isDisposed()) {
|
|
|
|
try {
|
|
|
|
await this._frame.editOptions();
|
|
|
|
} catch(err) {
|
|
|
|
if (err.message === "Unknown interface") {
|
|
|
|
throw new UserError("Custom widget doesn't expose configuration screen.");
|
|
|
|
} else {
|
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
};
|
2020-10-02 15:10:00 +00:00
|
|
|
/**
|
|
|
|
* The HTMLElement embedding the content.
|
|
|
|
*/
|
|
|
|
public viewPane: HTMLElement;
|
|
|
|
|
|
|
|
// viewSection, sortedRows, tableModel, gristDoc, and cursor are inherited from BaseView
|
|
|
|
protected viewSection: ViewSectionRec;
|
|
|
|
protected sortedRows: SortedRowSet;
|
|
|
|
protected tableModel: DataTableModel;
|
|
|
|
protected gristDoc: GristDoc;
|
|
|
|
protected cursor: Cursor;
|
|
|
|
|
|
|
|
private _customDef: CustomViewSectionDef;
|
|
|
|
|
|
|
|
// state of the component
|
|
|
|
private _foundPlugin: ko.Observable<boolean>;
|
|
|
|
private _foundSection: ko.Observable<boolean>;
|
|
|
|
// Note the invariant: this._customSection != undefined if this._foundSection() == true
|
|
|
|
private _customSection: ViewProcess|undefined;
|
|
|
|
private _pluginInstance: PluginInstance|undefined;
|
|
|
|
|
2022-01-12 13:30:51 +00:00
|
|
|
private _frame: WidgetFrame; // plugin frame (holding external page)
|
2021-08-10 11:18:37 +00:00
|
|
|
private _emptyWidgetPage: string;
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
public create(gristDoc: GristDoc, viewSectionModel: ViewSectionRec) {
|
|
|
|
BaseView.call(this as any, gristDoc, viewSectionModel);
|
|
|
|
|
|
|
|
this._customDef = this.viewSection.customDef;
|
|
|
|
|
2021-08-10 11:18:37 +00:00
|
|
|
this._emptyWidgetPage = new URL("custom-widget.html", gristDoc.app.topAppModel.getUntrustedContentOrigin()).href;
|
|
|
|
|
2020-10-02 15:10:00 +00:00
|
|
|
this.autoDisposeCallback(() => {
|
|
|
|
if (this._customSection) {
|
|
|
|
this._customSection.dispose();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
this._foundPlugin = ko.observable(false);
|
|
|
|
this._foundSection = ko.observable(false);
|
|
|
|
// Ensure that selecting another section in same plugin update the view.
|
|
|
|
this._foundSection.extend({notify: 'always'});
|
|
|
|
|
|
|
|
this.autoDispose(this._customDef.pluginId.subscribe(this._updatePluginInstance, this));
|
|
|
|
this.autoDispose(this._customDef.sectionId.subscribe(this._updateCustomSection, this));
|
2022-01-12 13:30:51 +00:00
|
|
|
this.autoDispose(commands.createGroup(CustomView._commands, this, this.viewSection.hasFocus));
|
2020-10-02 15:10:00 +00:00
|
|
|
|
|
|
|
this.viewPane = this.autoDispose(this._buildDom());
|
|
|
|
this._updatePluginInstance();
|
|
|
|
}
|
|
|
|
|
2020-10-09 21:39:13 +00:00
|
|
|
public async triggerPrint() {
|
2022-01-12 13:30:51 +00:00
|
|
|
if (!this.isDisposed() && this._frame) {
|
|
|
|
return await this._frame.callRemote('print');
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-01-12 13:30:51 +00:00
|
|
|
* Find a plugin instance that matches the plugin id, update the `found` observables, then tries to
|
2020-10-02 15:10:00 +00:00
|
|
|
* find a matching section.
|
|
|
|
*/
|
|
|
|
private _updatePluginInstance() {
|
|
|
|
|
|
|
|
const pluginId = this._customDef.pluginId();
|
|
|
|
this._pluginInstance = this.gristDoc.docPluginManager.pluginsList.find(p => p.definition.id === pluginId);
|
|
|
|
|
|
|
|
if (this._pluginInstance) {
|
|
|
|
this._foundPlugin(true);
|
|
|
|
} else {
|
|
|
|
this._foundPlugin(false);
|
|
|
|
this._foundSection(false);
|
|
|
|
}
|
|
|
|
this._updateCustomSection();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* If a plugin was found, find a custom section matching the section id and update the `found`
|
|
|
|
* observables.
|
|
|
|
*/
|
|
|
|
private _updateCustomSection() {
|
|
|
|
|
|
|
|
if (!this._pluginInstance) { return; }
|
|
|
|
|
|
|
|
const sectionId = this._customDef.sectionId();
|
|
|
|
this._customSection = CustomSectionElement.find(this._pluginInstance, sectionId);
|
|
|
|
|
|
|
|
if (this._customSection) {
|
|
|
|
const el = this._customSection.element;
|
|
|
|
el.classList.add("flexitem");
|
|
|
|
this._foundSection(true);
|
|
|
|
} else {
|
|
|
|
this._foundSection(false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private _buildDom() {
|
|
|
|
const {mode, url, access} = this._customDef;
|
|
|
|
const showPlugin = ko.pureComputed(() => this._customDef.mode() === "plugin");
|
|
|
|
|
|
|
|
// When both plugin and section are not found, let's show only plugin notification.
|
|
|
|
const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin());
|
|
|
|
const showSectionNotification = ko.pureComputed(() => showPlugin() && this._foundPlugin() && !this._foundSection());
|
|
|
|
const showPluginContent = ko.pureComputed(() => showPlugin() && this._foundSection())
|
|
|
|
// For the view to update when switching from one section to another one, the computed
|
|
|
|
// observable must always notify.
|
|
|
|
.extend({notify: 'always'});
|
|
|
|
return dom('div.flexauto.flexvbox.custom_view_container',
|
|
|
|
dom.autoDispose(showPlugin),
|
|
|
|
dom.autoDispose(showPluginNotification),
|
|
|
|
dom.autoDispose(showSectionNotification),
|
|
|
|
dom.autoDispose(showPluginContent),
|
|
|
|
// todo: should display content in webview when running electron
|
|
|
|
kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
|
2022-01-12 13:30:51 +00:00
|
|
|
_mode === "url" ? this._buildIFrame(_url, (_access || AccessLevel.none) as AccessLevel) : null),
|
2020-10-02 15:10:00 +00:00
|
|
|
kd.maybe(showPluginNotification, () => buildNotification('Plugin ',
|
|
|
|
dom('strong', kd.text(this._customDef.pluginId)), ' was not found',
|
|
|
|
dom.testId('customView_notification_plugin')
|
|
|
|
)),
|
|
|
|
kd.maybe(showSectionNotification, () => buildNotification('Section ',
|
|
|
|
dom('strong', kd.text(this._customDef.sectionId)), ' was not found in plugin ',
|
|
|
|
dom('strong', kd.text(this._customDef.pluginId)),
|
|
|
|
dom.testId('customView_notification_section')
|
|
|
|
)),
|
|
|
|
// When showPluginContent() is true then _foundSection() is also and _customSection is not
|
|
|
|
// undefined (invariant).
|
|
|
|
kd.maybe(showPluginContent, () => this._customSection!.element)
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2022-01-12 13:30:51 +00:00
|
|
|
private _promptAccess(access: AccessLevel) {
|
|
|
|
if (this.gristDoc.isReadonly.get()) {
|
|
|
|
return;
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
2022-01-12 13:30:51 +00:00
|
|
|
this.viewSection.desiredAccessLevel(access);
|
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-01-12 13:30:51 +00:00
|
|
|
private _buildIFrame(baseUrl: string, access: AccessLevel) {
|
|
|
|
return grains.create(WidgetFrame, {
|
|
|
|
url: baseUrl || this._emptyWidgetPage,
|
|
|
|
access,
|
|
|
|
readonly: this.gristDoc.isReadonly.get(),
|
|
|
|
configure: (frame) => {
|
|
|
|
this._frame = frame;
|
|
|
|
// Need to cast myself to a BaseView
|
|
|
|
const view = this as unknown as BaseView;
|
|
|
|
frame.exposeAPI(
|
|
|
|
"GristDocAPI",
|
|
|
|
new GristDocAPIImpl(this.gristDoc),
|
|
|
|
GristDocAPIImpl.defaultAccess);
|
|
|
|
frame.exposeAPI(
|
|
|
|
"GristView",
|
|
|
|
new GristViewImpl(view), new MinimumLevel(AccessLevel.read_table));
|
|
|
|
frame.exposeAPI(
|
|
|
|
"CustomSectionAPI",
|
|
|
|
new CustomSectionAPIImpl(
|
|
|
|
this.viewSection,
|
|
|
|
access,
|
|
|
|
this._promptAccess.bind(this)),
|
|
|
|
new MinimumLevel(AccessLevel.none));
|
|
|
|
frame.useEvents(RecordNotifier.create(frame, view), new MinimumLevel(AccessLevel.read_table));
|
|
|
|
frame.useEvents(TableNotifier.create(frame, view), new MinimumLevel(AccessLevel.read_table));
|
|
|
|
frame.exposeAPI(
|
|
|
|
"WidgetAPI",
|
|
|
|
new WidgetAPIImpl(this.viewSection),
|
|
|
|
new MinimumLevel(AccessLevel.none)); // none access is enough
|
|
|
|
frame.useEvents(
|
|
|
|
ConfigNotifier.create(frame, this.viewSection, access),
|
|
|
|
new MinimumLevel(AccessLevel.none)); // none access is enough
|
|
|
|
},
|
|
|
|
onElem: (iframe) => onFrameFocus(iframe, () => {
|
|
|
|
if (this.isDisposed()) { return; }
|
|
|
|
if (!this.viewSection.isDisposed() && !this.viewSection.hasFocus()) {
|
|
|
|
this.viewSection.hasFocus(true);
|
2021-07-30 14:10:54 +00:00
|
|
|
}
|
2022-01-12 13:30:51 +00:00
|
|
|
})
|
|
|
|
});
|
2020-10-02 15:10:00 +00:00
|
|
|
|
2022-01-12 13:30:51 +00:00
|
|
|
}
|
2020-10-02 15:10:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Getting an ES6 class to work with old-style multiple base classes takes a little hacking. Credits: ./ChartView.ts
|
|
|
|
defaults(CustomView.prototype, BaseView.prototype);
|
|
|
|
Object.assign(CustomView.prototype, BackboneEvents);
|
|
|
|
|
|
|
|
|
|
|
|
// helper to build the notification's frame.
|
|
|
|
function buildNotification(...args: any[]) {
|
|
|
|
return dom('div.custom_view_notification.bg-warning', dom('p', ...args));
|
|
|
|
}
|
2022-01-12 13:30:51 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* There is no way to detect if the frame was clicked. This causes a bug, when
|
|
|
|
* there are 2 custom widgets on a page then user can't switch focus from 1 section
|
|
|
|
* to another. The only solution is too pool and test if the iframe is an active element
|
|
|
|
* in the dom.
|
|
|
|
* (See https://stackoverflow.com/questions/2381336/detect-click-into-iframe-using-javascript).
|
|
|
|
*
|
|
|
|
* For a single iframe, it will gain focus through a hack in ViewLayout.ts.
|
|
|
|
*/
|
|
|
|
function onFrameFocus(frame: HTMLIFrameElement, handler: () => void) {
|
|
|
|
let timer: NodeJS.Timeout|null = null;
|
|
|
|
// Flag that will prevent mouseenter event to be fired
|
|
|
|
// after dom is disposed. This shouldn't happen.
|
|
|
|
let disposed = false;
|
|
|
|
// Stops pooling.
|
|
|
|
function stop() {
|
|
|
|
if (timer) {
|
|
|
|
clearInterval(timer);
|
|
|
|
timer = null;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return grains.update(frame,
|
|
|
|
grains.on("mouseenter", () => {
|
|
|
|
// Make sure we weren't dispose (should not happen)
|
|
|
|
if (disposed) { return; }
|
|
|
|
// If frame already has focus, do nothing.
|
|
|
|
// NOTE: Frame will always be an active element from our perspective,
|
|
|
|
// even if the focus is somewhere inside the iframe.
|
|
|
|
if (document.activeElement === frame) { return; }
|
|
|
|
// Start pooling for frame focus.
|
|
|
|
timer = setInterval(() => {
|
|
|
|
if (document.activeElement === frame) {
|
|
|
|
try {
|
|
|
|
handler();
|
|
|
|
} finally {
|
|
|
|
// Stop checking, we will start again after next mouseenter.
|
|
|
|
stop();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, 70); // 70 is enough to make it look like a click.
|
|
|
|
}),
|
|
|
|
grains.on("mouseleave", stop),
|
|
|
|
grains.onDispose(() => {
|
|
|
|
stop();
|
|
|
|
disposed = true;
|
|
|
|
})
|
|
|
|
);
|
|
|
|
}
|