(core) Support dark mode in custom widgets

Test Plan: Manual.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D4036
This commit is contained in:
George Gevoian
2023-09-19 14:44:22 -04:00
parent ffbf93b85f
commit 4c25aa7d3d
18 changed files with 444 additions and 71 deletions

View File

@@ -153,8 +153,14 @@ export class CustomView extends Disposable {
}
private _buildDom() {
const {mode, url, access} = this.customDef;
const {mode, url, access, renderAfterReady} = this.customDef;
const showPlugin = ko.pureComputed(() => this.customDef.mode() === "plugin");
const showAfterReady = () => {
// The empty widget page calls `grist.ready()`.
if (!url()) { return true; }
return this.customDef.widgetDef()?.renderAfterReady ?? renderAfterReady();
};
// When both plugin and section are not found, let's show only plugin notification.
const showPluginNotification = ko.pureComputed(() => showPlugin() && !this._foundPlugin());
@@ -170,7 +176,14 @@ export class CustomView extends Disposable {
dom.autoDispose(showPluginContent),
// todo: should display content in webview when running electron
kd.scope(() => [mode(), url(), access()], ([_mode, _url, _access]: string[]) =>
_mode === "url" ? this._buildIFrame(_url, (_access || AccessLevel.none) as AccessLevel) : null),
_mode === "url" ?
this._buildIFrame({
baseUrl: _url,
access: (_access as AccessLevel || AccessLevel.none),
showAfterReady: showAfterReady(),
})
: null
),
kd.maybe(showPluginNotification, () => buildNotification('Plugin ',
dom('strong', kd.text(this.customDef.pluginId)), ' was not found',
dom.testId('customView_notification_plugin')
@@ -193,11 +206,22 @@ export class CustomView extends Disposable {
this.viewSection.desiredAccessLevel(access);
}
private _buildIFrame(baseUrl: string, access: AccessLevel) {
private _buildIFrame(options: {
baseUrl: string|null,
access: AccessLevel,
showAfterReady?: boolean,
}) {
const {baseUrl, access, showAfterReady} = options;
return grains.create(WidgetFrame, {
url: baseUrl || this.getEmptyWidgetPage(),
access,
readonly: this.gristDoc.isReadonly.get(),
showAfterReady,
onSettingsInitialized: async () => {
if (!this.customDef.renderAfterReady.peek()) {
await this.customDef.renderAfterReady.setAndSave(true);
}
},
configure: (frame) => {
this._frame = frame;
// Need to cast myself to a BaseView
@@ -223,7 +247,10 @@ export class CustomView extends Disposable {
new WidgetAPIImpl(this.viewSection),
new MinimumLevel(AccessLevel.none)); // none access is enough
frame.useEvents(
ConfigNotifier.create(frame, this.viewSection, access),
ConfigNotifier.create(frame, this.viewSection, {
access,
theme: this.gristDoc.currentTheme,
}),
new MinimumLevel(AccessLevel.none)); // none access is enough
},
onElem: (iframe) => onFrameFocus(iframe, () => {

View File

@@ -224,8 +224,13 @@ export class GristDoc extends DisposableWithEvents {
this.docData = new DocData(this.docComm, openDocResponse.doc);
this.docModel = new DocModel(this.docData, this.docPageModel);
this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm);
this.docPluginManager = new DocPluginManager(plugins,
app.topAppModel.getUntrustedContentOrigin(), this.docComm, app.clientScope);
this.docPluginManager = new DocPluginManager({
plugins,
untrustedContentOrigin: app.topAppModel.getUntrustedContentOrigin(),
docComm: this.docComm,
clientScope: app.clientScope,
theme: this.currentTheme,
});
// Maintain the MetaRowModel for the global document info, including docId and peers.
this.docInfo = this.docModel.docInfoRow;

View File

@@ -8,6 +8,7 @@ import {AccessLevel, isSatisfied} from 'app/common/CustomWidget';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {BulkColValues, fromTableDataAction, RowRecord} from 'app/common/DocActions';
import {extractInfoFromColType, reencodeAsAny} from 'app/common/gristTypes';
import {Theme} from 'app/common/ThemePrefs';
import {AccessTokenOptions, CursorPos, CustomSectionAPI, GristDocAPI, GristView,
InteractionOptionsRequest, WidgetAPI, WidgetColumnMap} from 'app/plugin/grist-plugin-api';
import {MsgType, Rpc} from 'grain-rpc';
@@ -48,6 +49,20 @@ export interface WidgetFrameOptions {
* If document is in readonly mode.
*/
readonly: boolean;
/**
* If set, show the iframe after `grist.ready()`.
*
* Currently, this is only used to defer showing a widget until it has had
* a chance to apply the Grist theme.
*/
showAfterReady?: boolean;
/**
* Handler for the settings initialized message.
*
* Currently, this is only used to defer showing a widget until it has had
* a chance to apply the Grist theme.
*/
onSettingsInitialized: () => void;
/**
* Optional callback to configure exposed API.
*/
@@ -68,6 +83,8 @@ export class WidgetFrame extends DisposableWithEvents {
private _iframe: HTMLIFrameElement | null;
// If widget called ready() method, this will be set to true.
private _readyCalled = Observable.create(this, false);
// Whether the iframe is visible.
private _visible = Observable.create(this, !this._options.showAfterReady);
constructor(private _options: WidgetFrameOptions) {
super();
@@ -162,6 +179,7 @@ export class WidgetFrame extends DisposableWithEvents {
const onElem = this._options.onElem ?? ((el: HTMLIFrameElement) => el);
return onElem(
(this._iframe = dom('iframe',
dom.style('visibility', use => use(this._visible) ? 'visible' : 'hidden'),
dom.cls('clipboard_focus'),
dom.cls('custom_view'), {
src: fullUrl,
@@ -189,6 +207,10 @@ export class WidgetFrame extends DisposableWithEvents {
this.trigger('ready', this);
this._readyCalled.set(true);
}
if (event.data.data?.settings?.status === 'initialized') {
this._visible.set(true);
this._options.onSettingsInitialized();
}
this._rpc.receiveMessage(event.data);
}
}
@@ -523,38 +545,48 @@ export class RecordNotifier extends BaseEventSource {
}
}
export interface ConfigNotifierOptions {
access: AccessLevel;
theme: Computed<Theme>;
}
/**
* Notifies about options change. Exposed in the API as a onOptions handler.
*/
export class ConfigNotifier extends BaseEventSource {
private _accessLevel = this._options.access;
private _theme = this._options.theme;
private _currentConfig: Computed<any | null>;
private _debounced: () => void; // debounced call to let the view know linked cursor changed.
constructor(private _section: ViewSectionRec, private _accessLevel: AccessLevel) {
// Debounced call to let the view know linked cursor changed.
private _debounced: (fromReady?: boolean) => void;
constructor(private _section: ViewSectionRec, private _options: ConfigNotifierOptions) {
super();
this._currentConfig = Computed.create(this, use => {
const options = use(this._section.activeCustomOptions);
return options;
});
this._debounced = debounce(() => this._update(), 0);
const subscribe = (obs: Observable<any>) => {
this.autoDispose(
obs.addListener((cur, prev) => {
if (isEqual(prev, cur)) {
return;
}
this._debounced();
})
);
this._debounced = debounce((fromReady?: boolean) => this._update(fromReady), 0);
const subscribe = (...observables: Observable<any>[]) => {
for (const obs of observables) {
this.autoDispose(
obs.addListener((cur, prev) => {
if (isEqual(prev, cur)) {
return;
}
this._debounced();
})
);
}
};
subscribe(this._currentConfig);
subscribe(this._currentConfig, this._theme);
}
protected _ready() {
// On ready, send initial configuration.
this._debounced();
this._debounced(true);
}
private _update() {
private _update(fromReady = false) {
if (this.isDisposed()) {
return;
}
@@ -562,7 +594,9 @@ export class ConfigNotifier extends BaseEventSource {
options: this._currentConfig.get(),
settings: {
accessLevel: this._accessLevel,
theme: this._theme.get(),
},
fromReady,
});
}
}