diff --git a/app/client/components/CustomView.ts b/app/client/components/CustomView.ts index 439ff4fb..ae14ed61 100644 --- a/app/client/components/CustomView.ts +++ b/app/client/components/CustomView.ts @@ -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, () => { diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index d46ae3ce..c7b535fa 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -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; diff --git a/app/client/components/WidgetFrame.ts b/app/client/components/WidgetFrame.ts index 4d4a1f3c..731b57a4 100644 --- a/app/client/components/WidgetFrame.ts +++ b/app/client/components/WidgetFrame.ts @@ -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; +} + /** * 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; - 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) => { - 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[]) => { + 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, }); } } diff --git a/app/client/lib/DocPluginManager.ts b/app/client/lib/DocPluginManager.ts index 063dce67..a43edef5 100644 --- a/app/client/lib/DocPluginManager.ts +++ b/app/client/lib/DocPluginManager.ts @@ -4,6 +4,8 @@ import {SafeBrowser} from 'app/client/lib/SafeBrowser'; import {ActiveDocAPI} from 'app/common/ActiveDocAPI'; import {LocalPlugin} from 'app/common/plugin'; import {createRpcLogger, PluginInstance} from 'app/common/PluginInstance'; +import {Theme} from 'app/common/ThemePrefs'; +import {Computed} from 'grainjs'; import {Rpc} from 'grain-rpc'; /** @@ -13,15 +15,31 @@ export class DocPluginManager { public pluginsList: PluginInstance[]; - constructor(localPlugins: LocalPlugin[], private _untrustedContentOrigin: string, private _docComm: ActiveDocAPI, - private _clientScope: ClientScope) { + private _clientScope = this._options.clientScope; + private _docComm = this._options.docComm; + private _localPlugins = this._options.plugins; + private _theme = this._options.theme; + private _untrustedContentOrigin = this._options.untrustedContentOrigin; + + constructor(private _options: { + plugins: LocalPlugin[], + untrustedContentOrigin: string, + docComm: ActiveDocAPI, + clientScope: ClientScope, + theme: Computed, + }) { this.pluginsList = []; - for (const plugin of localPlugins) { + for (const plugin of this._localPlugins) { try { const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `PLUGIN ${plugin.id}:`)); const components = plugin.manifest.components || {}; - const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance, - this._clientScope, this._untrustedContentOrigin, components.safeBrowser); + const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser({ + pluginInstance, + clientScope: this._clientScope, + untrustedContentOrigin: this._untrustedContentOrigin, + mainPath: components.safeBrowser, + theme: this._theme, + }); if (components.safeBrowser) { pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser); } diff --git a/app/client/lib/HomePluginManager.ts b/app/client/lib/HomePluginManager.ts index 41f3f753..e96e4438 100644 --- a/app/client/lib/HomePluginManager.ts +++ b/app/client/lib/HomePluginManager.ts @@ -14,8 +14,8 @@ export class HomePluginManager { public pluginsList: PluginInstance[]; constructor(localPlugins: LocalPlugin[], - _untrustedContentOrigin: string, - _clientScope: ClientScope) { + untrustedContentOrigin: string, + clientScope: ClientScope) { this.pluginsList = []; for (const plugin of localPlugins) { try { @@ -30,8 +30,12 @@ export class HomePluginManager { continue; } const pluginInstance = new PluginInstance(plugin, createRpcLogger(console, `HOME PLUGIN ${plugin.id}:`)); - const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser(pluginInstance, - _clientScope, _untrustedContentOrigin, components.safeBrowser); + const safeBrowser = pluginInstance.safeBrowser = new SafeBrowser({ + pluginInstance, + clientScope, + untrustedContentOrigin, + mainPath: components.safeBrowser, + }); if (components.safeBrowser) { pluginInstance.rpc.registerForwarder(components.safeBrowser, safeBrowser); } diff --git a/app/client/lib/SafeBrowser.ts b/app/client/lib/SafeBrowser.ts index e0d01a7b..fe50c688 100644 --- a/app/client/lib/SafeBrowser.ts +++ b/app/client/lib/SafeBrowser.ts @@ -36,12 +36,15 @@ import * as Mousetrap from 'app/client/lib/Mousetrap'; import { ActionRouter } from 'app/common/ActionRouter'; import { BaseComponent, BaseLogger, createRpcLogger, PluginInstance, warnIfNotReady } from 'app/common/PluginInstance'; import { tbind } from 'app/common/tbind'; +import { Theme } from 'app/common/ThemePrefs'; import { getOriginUrl } from 'app/common/urlUtils'; import { GristAPI, RPC_GRISTAPI_INTERFACE } from 'app/plugin/GristAPI'; import { RenderOptions, RenderTarget } from 'app/plugin/RenderOptions'; import { checkers } from 'app/plugin/TypeCheckers'; -import { IMsgCustom, IMsgRpcCall, Rpc } from 'grain-rpc'; +import { Computed, dom as grainjsDom, Observable } from 'grainjs'; +import { IMsgCustom, IMsgRpcCall, IRpcLogger, MsgType, Rpc } from 'grain-rpc'; import { Disposable } from './dispose'; +import isEqual from 'lodash/isEqual'; const G = getBrowserGlobals('document', 'window'); /** @@ -54,7 +57,6 @@ const G = getBrowserGlobals('document', 'window'); // client processes and among other thing will expose both renderImpl and // disposeImpl. ClientProcess will hold a reference to ProcessManager instead of SafeBrowser. export class SafeBrowser extends BaseComponent { - /** * Create a webview ClientProcess to render safe browser process in electron. */ @@ -71,6 +73,8 @@ export class SafeBrowser extends BaseComponent { new IframeProcess(safeBrowser, rpc, src); } + public theme? = this._options.theme; + // All view processes. This is not used anymore to dispose all processes on deactivation (this is // now achieved using `this._mainProcess.autoDispose(...)`) but rather to be able to dispatch // events to all processes (such as doc actions which will need soon). @@ -80,17 +84,30 @@ export class SafeBrowser extends BaseComponent { private _mainProcess: WorkerProcess|undefined; private _viewCount: number = 0; - constructor( - private _plugin: PluginInstance, - private _clientScope: ClientScope, - private _untrustedContentOrigin: string, - private _mainPath: string = "", - private _baseLogger: BaseLogger = console, - rpcLogger = createRpcLogger(_baseLogger, `PLUGIN ${_plugin.definition.id} SafeBrowser:`), - ) { - super(_plugin.definition.manifest, rpcLogger); - this._pluginId = _plugin.definition.id; - this._pluginRpc = _plugin.rpc; + private _plugin = this._options.pluginInstance; + private _clientScope = this._options.clientScope; + private _untrustedContentOrigin = this._options.untrustedContentOrigin; + private _mainPath = this._options.mainPath ?? ''; + private _baseLogger = this._options.baseLogger ?? console; + + constructor(private _options: { + pluginInstance: PluginInstance, + clientScope: ClientScope, + untrustedContentOrigin: string, + mainPath?: string, + baseLogger?: BaseLogger, + rpcLogger?: IRpcLogger, + theme?: Computed, + }) { + super( + _options.pluginInstance.definition.manifest, + _options.rpcLogger ?? createRpcLogger( + _options.baseLogger ?? console, + `PLUGIN ${_options.pluginInstance.definition.id} SafeBrowser:` + ) + ); + this._pluginId = this._plugin.definition.id; + this._pluginRpc = this._plugin.rpc; } /** @@ -274,6 +291,9 @@ class WorkerProcess extends ClientProcess { export class ViewProcess extends ClientProcess { public element: HTMLElement; + + // Set once all of the plugin's onOptions handlers have been called. + protected _optionsInitialized: Observable; } /** @@ -282,10 +302,23 @@ export class ViewProcess extends ClientProcess { class IframeProcess extends ViewProcess { public create(safeBrowser: SafeBrowser, rpc: Rpc, src: string) { super.create(safeBrowser, rpc, src); - const iframe = this.element = this.autoDispose(dom(`iframe.safe_browser_process.clipboard_focus`, - { src })); - const listener = (event: MessageEvent) => { + this._optionsInitialized = Observable.create(this, false); + const iframe = this.element = this.autoDispose( + grainjsDom(`iframe.safe_browser_process.clipboard_focus`, + {src}, + grainjsDom.style('visibility', use => use(this._optionsInitialized) ? 'visible' : 'hidden'), + ) as HTMLIFrameElement + ); + const listener = async (event: MessageEvent) => { if (event.source === iframe.contentWindow) { + if (event.data.mtype === MsgType.Ready) { + await this._sendSettings({theme: safeBrowser.theme?.get()}, true); + } + + if (event.data.data?.settings?.status === 'initialized') { + this._optionsInitialized.set(true); + } + this.rpc.receiveMessage(event.data); } }; @@ -294,8 +327,21 @@ class IframeProcess extends ViewProcess { G.window.removeEventListener('message', listener); }); this.rpc.setSendMessage(msg => iframe.contentWindow!.postMessage(msg, '*')); + + if (safeBrowser.theme) { + this.autoDispose( + safeBrowser.theme.addListener(async (newTheme, oldTheme) => { + if (isEqual(newTheme, oldTheme)) { return; } + + await this._sendSettings({theme: safeBrowser.theme?.get()}); + }) + ); + } } + private async _sendSettings(settings: {theme?: Theme}, fromReady = false) { + await this.rpc.postMessage({settings, fromReady}); + } } /** diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 71f8226b..ac671298 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -266,6 +266,13 @@ export interface CustomViewSectionDef { * The section id. */ sectionId: modelUtil.KoSaveableObservable; + /** + * If set, render the widget after `grist.ready()`. + * + * Currently, this is only used to defer rendering a widget until it has had + * a chance to apply the Grist theme. + */ + renderAfterReady: modelUtil.KoSaveableObservable; } /** Information about filters for a field or hidden column. */ @@ -317,7 +324,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): widgetDef: null, access: '', pluginId: '', - sectionId: '' + sectionId: '', + renderAfterReady: false, }; const customDefObj = modelUtil.jsonObservable(this.optionsObj.prop('customView'), (obj: any) => defaults(obj || {}, customViewDefaults)); @@ -330,7 +338,8 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): columnsMapping: customDefObj.prop('columnsMapping'), access: customDefObj.prop('access'), pluginId: customDefObj.prop('pluginId'), - sectionId: customDefObj.prop('sectionId') + sectionId: customDefObj.prop('sectionId'), + renderAfterReady: customDefObj.prop('renderAfterReady'), }; this.selectedFields = ko.observable([]); diff --git a/app/client/ui/CustomSectionConfig.ts b/app/client/ui/CustomSectionConfig.ts index 2d151bf4..840a3ad2 100644 --- a/app/client/ui/CustomSectionConfig.ts +++ b/app/client/ui/CustomSectionConfig.ts @@ -327,6 +327,8 @@ export class CustomSectionConfig extends Disposable { if (value === CUSTOM_ID) { // Select Custom URL bundleChanges(() => { + // Reset whether widget should render after `grist.ready()`. + _section.customDef.renderAfterReady(false); // Clear url. _section.customDef.url(null); // Clear widget definition. @@ -355,6 +357,8 @@ export class CustomSectionConfig extends Disposable { return; } bundleChanges(() => { + // Reset whether widget should render after `grist.ready()`. + _section.customDef.renderAfterReady(false); // Clear access level _section.customDef.access(AccessLevel.none); // When widget wants some access, set desired access level. @@ -378,7 +382,13 @@ export class CustomSectionConfig extends Disposable { // Url for the widget, taken either from widget definition, or provided by hand for Custom URL. // For custom widget, we will store url also in section definition. this._url = Computed.create(this, use => use(_section.customDef.url) || ''); - this._url.onWrite(newUrl => _section.customDef.url.setAndSave(newUrl)); + this._url.onWrite(async newUrl => { + bundleChanges(() => { + _section.customDef.renderAfterReady(false); + _section.customDef.url(newUrl); + }); + await _section.saveCustomDef(); + }); // Compute current access level. this._currentAccess = Computed.create( diff --git a/app/common/CustomWidget.ts b/app/common/CustomWidget.ts index 0de53566..115e0a2b 100644 --- a/app/common/CustomWidget.ts +++ b/app/common/CustomWidget.ts @@ -18,6 +18,13 @@ export interface ICustomWidget { * Optional desired access level. */ accessLevel?: AccessLevel; + /** + * If set, Grist will render the widget after `grist.ready()`. + * + * Currently, this is only used to defer rendering a widget until it has had + * a chance to apply the Grist theme. + */ + renderAfterReady?: boolean; } /** diff --git a/app/plugin/CustomSectionAPI-ti.ts b/app/plugin/CustomSectionAPI-ti.ts index bc972d80..7fb03df8 100644 --- a/app/plugin/CustomSectionAPI-ti.ts +++ b/app/plugin/CustomSectionAPI-ti.ts @@ -24,6 +24,7 @@ export const InteractionOptionsRequest = t.iface([], { export const InteractionOptions = t.iface([], { "accessLevel": "string", + "theme": "any", }); export const WidgetColumnMap = t.iface([], { diff --git a/app/plugin/CustomSectionAPI.ts b/app/plugin/CustomSectionAPI.ts index 7608d968..1024c1b3 100644 --- a/app/plugin/CustomSectionAPI.ts +++ b/app/plugin/CustomSectionAPI.ts @@ -63,7 +63,17 @@ export interface InteractionOptions{ /** * Granted access level. */ - accessLevel: string, + accessLevel: string, + /** + * Information about the current Grist theme. + * + * Includes the theme appearance ("light" or "dark"), and a mapping of UI elements to + * CSS color values. The CSS values are also accessible within a widget via CSS variables + * prefixed with "--grist-theme-" (e.g. `var(--grist-theme-text)`). + * + * NOTE: the variables aren't yet finalized and may change in the future. + */ + theme: any; } /** diff --git a/app/plugin/grist-plugin-api.ts b/app/plugin/grist-plugin-api.ts index 9480ac2f..f617b33e 100644 --- a/app/plugin/grist-plugin-api.ts +++ b/app/plugin/grist-plugin-api.ts @@ -43,6 +43,7 @@ export * from './WidgetAPI'; export * from './CustomSectionAPI'; import {IRpcLogger, Rpc} from 'grain-rpc'; +import isEqual from 'lodash/isEqual'; export const rpc: Rpc = new Rpc({logger: createRpcLogger()}); @@ -376,6 +377,13 @@ export function onRecords(callback: (data: RowRecord[], mappings: WidgetColumnMa }); } +/* Keep track of the completion status of all initial onOptions calls made prior to sending + * the ready message. The `ready` function will wait for this list of calls to settle after + * it receives the initial options message from Grist. + * + * Note that this includes all internal calls to `onOptions`, such as the one made by + * `syncThemeWithGrist`. */ +const _pendingInitializeOptionsCalls: Promise[] = []; /** * For custom widgets, add a handler that will be called whenever the @@ -385,9 +393,16 @@ export function onRecords(callback: (data: RowRecord[], mappings: WidgetColumnMa * the document that contains it. */ export function onOptions(callback: (options: any, settings: InteractionOptions) => unknown) { + let finishInitialization: () => void; + if (!_readyCalled) { + const promise = new Promise(resolve => { finishInitialization = resolve; }); + _pendingInitializeOptionsCalls.push(promise); + } + on('message', async function(msg) { if (msg.settings) { - callback(msg.options || null, msg.settings); + await callback(msg.options || null, msg.settings); + finishInitialization?.(); } }); } @@ -444,6 +459,24 @@ export function ready(settings?: ReadyPayload): void { rpc.registerFunc('editOptions', settings.onEditOptions); } on('message', async function(msg) { + if (msg.settings && msg.fromReady) { + /* Grist sends an options message immediately after receiving a ready message, containing + * some initial settings (such as the Grist theme). Grist may decide to wait until all + * initial onOptions calls have completed before making the custom widget's iframe visible + * to the client. This is the case when a widget's manifest explicitly declares a widget + * must be rendered after the ready, or in subsequent renders of a custom widget that + * previously sent Grist a ready message. The reason for this behavior is to make the + * experience of embedding iframes within a Grist document feel more seamless and polished. + * + * Here, we check for that initial options message, waiting for all onOptions calls to + * settle before notifying Grist that all settings have been initialized. */ + await Promise.all(_pendingInitializeOptionsCalls); + + void (async function() { + await rpc.postMessage({settings: {status: 'initialized'}}); + })(); + } + if (msg.tableId && msg.tableId !== _tableId) { if (!_tableId) { _setInitialized(); } _tableId = msg.tableId; @@ -521,3 +554,55 @@ function createRpcLogger(): IRpcLogger { warn(msg: string) { console.warn("%s %s", prefix, msg); }, }; } + +let _theme: any; + +function syncThemeWithGrist() { + onOptions((_options, settings) => { + if (settings.theme && !isEqual(settings.theme, _theme)) { + _theme = settings.theme; + attachCssThemeVars(_theme); + } + }); +} + +function attachCssThemeVars({appearance, colors}: any) { + // Prepare the custom properties needed for applying the theme. + const properties = Object.entries(colors) + .map(([name, value]) => `--grist-theme-${name}: ${value};`); + + // Include properties for styling the scrollbar. + properties.push(...getCssScrollbarProperties(appearance)); + + // Apply the properties to the theme style element. + getOrCreateStyleElement('grist-theme').textContent = `:root { +${properties.join('\n')} + }`; + + // Make the browser aware of the color scheme. + document.documentElement.style.setProperty(`color-scheme`, appearance); +} + +function getCssScrollbarProperties(appearance: 'light' | 'dark') { + return [ + '--scroll-bar-fg: ' + + (appearance === 'dark' ? '#6B6B6B;' : '#A8A8A8;'), + '--scroll-bar-hover-fg: ' + + (appearance === 'dark' ? '#7B7B7B;' : '#8F8F8F;'), + '--scroll-bar-active-fg: ' + + (appearance === 'dark' ? '#8B8B8B;' : '#7C7C7C;'), + '--scroll-bar-bg: ' + + (appearance === 'dark' ? '#2B2B2B;' : '#F0F0F0;'), + ]; +} + +function getOrCreateStyleElement(id: string) { + let style = document.head.querySelector(`#${id}`); + if (style) { return style; } + style = document.createElement('style'); + style.setAttribute('id', id); + document.head.append(style); + return style; +} + +syncThemeWithGrist(); diff --git a/static/custom-widget.html b/static/custom-widget.html index 3e1fef15..f7492ae6 100644 --- a/static/custom-widget.html +++ b/static/custom-widget.html @@ -4,22 +4,46 @@ Custom widget + +
-
- Custom widget - (not configured) +
+ Custom widget + (not configured)

The Custom widget allows a user to insert almost anything in their document. diff --git a/test/client/lib/SafeBrowser.ts b/test/client/lib/SafeBrowser.ts index 26963e0d..5d7b21ba 100644 --- a/test/client/lib/SafeBrowser.ts +++ b/test/client/lib/SafeBrowser.ts @@ -180,7 +180,13 @@ describe('SafeBrowser', function() { }; function createSafeBrowser(mainPath: string): {safeBrowser: SafeBrowser, pluginRpc: Rpc} { const pluginInstance = new PluginInstance(localPlugin, {}); - const safeBrowser = new SafeBrowser(pluginInstance, clientScope, '', mainPath, {}); + const safeBrowser = new SafeBrowser({ + pluginInstance, + clientScope, + untrustedContentOrigin: '', + mainPath, + baseLogger: {}, + }); cleanup.push(() => safeBrowser.deactivate()); pluginInstance.rpc.registerForwarder(mainPath, safeBrowser); return {safeBrowser, pluginRpc: pluginInstance.rpc}; diff --git a/test/fixtures/sites/deferred-ready/index.html b/test/fixtures/sites/deferred-ready/index.html new file mode 100644 index 00000000..5cdef3c5 --- /dev/null +++ b/test/fixtures/sites/deferred-ready/index.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/test/nbrowser/CustomView.ts b/test/nbrowser/CustomView.ts index 1292bcd5..5919d96a 100644 --- a/test/nbrowser/CustomView.ts +++ b/test/nbrowser/CustomView.ts @@ -42,19 +42,15 @@ describe('CustomView', function() { // This tests if test id works. Feels counterintuitive to "test the test" but grist-widget repository test suite // depends on this. it('informs about ready called', async () => { - // Add a custom inline widget to a new doc. + // Add a custom widget to a new doc. const session = await gu.session().teamSite.login(); await session.tempNewDoc(cleanup); await gu.addNewSection('Custom', 'Table1'); - // Create an inline widget that will call ready message. - await inFrame(async () => { - const customWidget = ` - - - `; - await driver.executeScript("document.write(`" + customWidget + "`);"); - }); + // Point to a widget that doesn't immediately call ready. + await gu.toggleSidePanel('right', 'open'); + await driver.find('.test-config-widget-url').click(); + await gu.sendKeys(`${serving.url}/deferred-ready`, Key.ENTER); // We should have a single iframe. assert.equal(await driver.findAll('iframe').then(f => f.length), 1); diff --git a/test/nbrowser/CustomWidgets.ts b/test/nbrowser/CustomWidgets.ts index 6563a2de..0deb22bd 100644 --- a/test/nbrowser/CustomWidgets.ts +++ b/test/nbrowser/CustomWidgets.ts @@ -19,6 +19,16 @@ const CUSTOM_URL = 'Custom URL'; // Create some widgets: const widget1: ICustomWidget = {widgetId: '1', name: 'W1', url: widgetEndpoint + '?name=W1'}; const widget2: ICustomWidget = {widgetId: '2', name: 'W2', url: widgetEndpoint + '?name=W2'}; +const widgetWithTheme: ICustomWidget = { + widgetId: '3', + name: 'WithTheme', + url: widgetEndpoint + '?name=WithTheme', +}; +const widgetNoPluginApi: ICustomWidget = { + widgetId: '4', + name: 'NoPluginApi', + url: widgetEndpoint + '?name=NoPluginApi', +}; const fromAccess = (level: AccessLevel) => ({widgetId: level, name: level, url: widgetEndpoint, accessLevel: level}) as ICustomWidget; const widgetNone = fromAccess(AccessLevel.none); @@ -54,8 +64,13 @@ describe('CustomWidgets', function () { app.get(widgetEndpoint, (req, res) => res .header('Content-Type', 'text/html') - .send('\n' + + .send('' + + (req.query.name === 'NoPluginApi' ? '' : '') + + (req.query.name === 'WithTheme' ? '' : '') + + '\n' + + (req.query.name === 'WithTheme' ? '' : '') + (req.query.name || req.query.access) + // send back widget name from query string or access level + (req.query.name === 'WithTheme' ? '' : '') + '\n') .end() ); @@ -146,7 +161,7 @@ describe('CustomWidgets', function () { const setUrl = async (url: string) => { await driver.find('.test-config-widget-url').click(); // First clear textbox. - await gu.clearInput(); + await gu.sendKeys(await gu.selectAllKey(), Key.DELETE); if (url) { await gu.sendKeys(`${widgetServerUrl}${url}`, Key.ENTER); } else { @@ -242,6 +257,64 @@ describe('CustomWidgets', function () { await gu.undo(7); }); + it('should support theme variables', async () => { + widgets = [widgetWithTheme]; + await useManifest(manifestEndpoint); + await recreatePanel(); + await toggle(); + await select(widgetWithTheme.name); + assert.equal(await current(), widgetWithTheme.name); + assert.equal(await content(), widgetWithTheme.name); + + const getWidgetColor = async () => { + const iframe = driver.find('iframe'); + await driver.switchTo().frame(iframe); + const color = await driver.find('span').getCssValue('color'); + await driver.switchTo().defaultContent(); + return color; + }; + + // Check that the widget is using the text color from the GristLight theme. + assert.equal(await getWidgetColor(), 'rgba(38, 38, 51, 1)'); + + // Switch the theme to GristDark. + await gu.setGristTheme({appearance: 'dark'}); + await driver.navigate().back(); + await gu.waitForDocToLoad(); + + // Check that the span is using the text color from the GristDark theme. + assert.equal(await getWidgetColor(), 'rgba(239, 239, 239, 1)'); + + // Switch back to GristLight. + await gu.setGristTheme({appearance: 'light'}); + await driver.navigate().back(); + await gu.waitForDocToLoad(); + + // Check that the widget is back to using the GristLight text color. + assert.equal(await getWidgetColor(), 'rgba(38, 38, 51, 1)'); + + // Re-enable widget repository. + await driver.executeScript('window.gristConfig.enableWidgetRepository = true;'); + }); + + it("should support widgets that don't use the plugin api", async () => { + widgets = [widgetNoPluginApi]; + await useManifest(manifestEndpoint); + await recreatePanel(); + await toggle(); + await select(widgetNoPluginApi.name); + assert.equal(await current(), widgetNoPluginApi.name); + + // Check that the widget loaded and its iframe is visible. + assert.equal(await content(), widgetNoPluginApi.name); + assert.isTrue(await driver.find('iframe').isDisplayed()); + + // Revert to original configuration. + widgets = [widget1, widget2]; + await useManifest(manifestEndpoint); + await recreatePanel(); + }); + it('should show error message for invalid widget url list', async () => { const testError = async (url: string, error: string) => { // Switch section to rebuild the creator panel. @@ -283,10 +356,6 @@ describe('CustomWidgets', function () { }); it('should switch access level to none on new widget', async () => { - widgets = [widget1, widget2]; - await useManifest(manifestEndpoint); - await recreatePanel(); - await toggle(); await select(widget1.name); assert.equal(await access(), AccessLevel.none); diff --git a/test/nbrowser/gristUtils.ts b/test/nbrowser/gristUtils.ts index 3cab6560..70220941 100644 --- a/test/nbrowser/gristUtils.ts +++ b/test/nbrowser/gristUtils.ts @@ -3131,6 +3131,20 @@ export async function downloadSectionCsvGridCells( return ([] as string[]).concat(...csvRows); } +export async function setGristTheme(options: { + appearance: 'light' | 'dark', + skipOpenSettingsPage?: boolean, +}) { + const {appearance, skipOpenSettingsPage} = options; + if (!skipOpenSettingsPage) { + await openProfileSettingsPage(); + } + await driver.find('.test-theme-config-appearance .test-select-open').click(); + await driver.findContent('.test-select-menu li', appearance === 'light' ? 'Light' : 'Dark') + .click(); + await waitForServer(); +} + } // end of namespace gristUtils stackWrapOwnMethods(gristUtils);