fix: persist visual zoom level limits across navigations

setVisualZoomLevelLimits previously only called through to the renderer
via IPC, and ElectronBrowserClient::OverrideWebPreferences unconditionally
reset default_minimum/maximum_page_scale_factor to 1.f on every navigation,
wiping out any limits that had been set.

Additionally, for webview guests, Chromium routes touchpad pinch-to-zoom
through the root (embedder) compositor rather than the guest's compositor,
so setting page scale limits on the guest alone had no effect.

This stores the limits in WebContentsPreferences so they survive navigation
resets, and propagates them to the embedder for webview guests.
This commit is contained in:
Shelley Vohr
2026-04-15 14:41:39 +01:00
parent 02d90a5ba3
commit 0bf0dbd07b
6 changed files with 188 additions and 41 deletions

View File

@@ -152,11 +152,10 @@ WebContents.prototype.sendToFrame = function (frameId, channel, ...args) {
};
// Following methods are mapped to webFrame.
const webFrameMethods = ['insertCSS', 'insertText', 'removeInsertedCSS', 'setVisualZoomLevelLimits'] as (
const webFrameMethods = ['insertCSS', 'insertText', 'removeInsertedCSS'] as (
| 'insertCSS'
| 'insertText'
| 'removeInsertedCSS'
| 'setVisualZoomLevelLimits'
)[];
for (const method of webFrameMethods) {
@@ -165,6 +164,20 @@ for (const method of webFrameMethods) {
};
}
// setVisualZoomLevelLimits persists the limits in WebContentsPreferences so
// they survive cross-navigation preference resets, then forwards to the
// renderer for immediate effect on the current page.
WebContents.prototype.setVisualZoomLevelLimits = function (minimumLevel: number, maximumLevel: number): Promise<void> {
this._setVisualZoomLevelLimits(minimumLevel, maximumLevel);
return ipcMainUtils.invokeInWebContents(
this,
IPC_MESSAGES.RENDERER_WEB_FRAME_METHOD,
'setVisualZoomLevelLimits',
minimumLevel,
maximumLevel
);
};
const waitTillCanExecuteJavaScript = async (webContents: Electron.WebContents) => {
if (webContents.getURL() && !webContents.isLoadingMainFrame()) return;

View File

@@ -4024,6 +4024,26 @@ void WebContents::SetImageAnimationPolicy(const std::string& new_policy) {
web_contents()->OnWebPreferencesChanged();
}
void WebContents::SetVisualZoomLevelLimits(double min_level, double max_level) {
auto* web_preferences = WebContentsPreferences::From(web_contents());
if (web_preferences) {
web_preferences->SetVisualZoomLevelLimits(static_cast<float>(min_level),
static_cast<float>(max_level));
}
// Touchpad pinch-to-zoom for child frames (webview guests) is handled by the
// root compositor, so propagate the limits to the embedder as well.
if (embedder_) {
auto* embedder_prefs =
WebContentsPreferences::From(embedder_->web_contents());
if (embedder_prefs) {
embedder_prefs->SetVisualZoomLevelLimits(static_cast<float>(min_level),
static_cast<float>(max_level));
embedder_->web_contents()->OnWebPreferencesChanged();
}
}
}
void WebContents::SetBackgroundColor(std::optional<SkColor> maybe_color) {
SkColor color = maybe_color.value_or((is_guest() && guest_transparent_) ||
type_ == Type::kBrowserView
@@ -4727,6 +4747,8 @@ void WebContents::FillObjectTemplate(v8::Isolate* isolate,
.SetMethod("takeHeapSnapshot", &WebContents::TakeHeapSnapshot)
.SetMethod("setImageAnimationPolicy",
&WebContents::SetImageAnimationPolicy)
.SetMethod("_setVisualZoomLevelLimits",
&WebContents::SetVisualZoomLevelLimits)
.SetMethod("_getProcessMemoryInfo", &WebContents::GetProcessMemoryInfo)
.SetProperty("id", &WebContents::ID)
.SetProperty("session", &WebContents::Session)

View File

@@ -431,6 +431,7 @@ class WebContents final : public ExclusiveAccessContext,
void SetTemporaryZoomLevel(double level);
void SetImageAnimationPolicy(const std::string& new_policy);
void SetVisualZoomLevelLimits(double min_level, double max_level);
// content::RenderWidgetHost::InputEventObserver:
void OnInputEvent(const content::RenderWidgetHost& rfh,

View File

@@ -149,6 +149,8 @@ void WebContentsPreferences::Clear() {
v8_cache_options_ = blink::mojom::V8CacheOptions::kDefault;
deprecated_paste_enabled_ = false;
focus_on_navigation_ = true;
default_minimum_page_scale_factor_ = std::nullopt;
default_maximum_page_scale_factor_ = std::nullopt;
#if BUILDFLAG(IS_MAC)
scroll_bounce_ = false;
@@ -278,6 +280,12 @@ bool WebContentsPreferences::SetImageAnimationPolicy(std::string policy) {
return false;
}
void WebContentsPreferences::SetVisualZoomLevelLimits(float min_level,
float max_level) {
default_minimum_page_scale_factor_ = min_level;
default_maximum_page_scale_factor_ = max_level;
}
bool WebContentsPreferences::IsSandboxed() const {
if (sandbox_)
return *sandbox_;
@@ -471,6 +479,13 @@ void WebContentsPreferences::OverrideWebkitPrefs(
prefs->v8_cache_options = v8_cache_options_;
prefs->dom_paste_enabled = deprecated_paste_enabled_;
if (default_minimum_page_scale_factor_)
prefs->default_minimum_page_scale_factor =
*default_minimum_page_scale_factor_;
if (default_maximum_page_scale_factor_)
prefs->default_maximum_page_scale_factor =
*default_maximum_page_scale_factor_;
}
WEB_CONTENTS_USER_DATA_KEY_IMPL(WebContentsPreferences);

View File

@@ -69,6 +69,7 @@ class WebContentsPreferences
}
bool ShouldIgnoreMenuShortcuts() const { return ignore_menu_shortcuts_; }
bool SetImageAnimationPolicy(std::string policy);
void SetVisualZoomLevelLimits(float min_level, float max_level);
bool ShouldDisableHtmlFullscreenWindowResize() const {
return disable_html_fullscreen_window_resize_;
}
@@ -135,6 +136,8 @@ class WebContentsPreferences
blink::mojom::V8CacheOptions v8_cache_options_;
bool deprecated_paste_enabled_ = false;
bool focus_on_navigation_;
std::optional<float> default_minimum_page_scale_factor_;
std::optional<float> default_maximum_page_scale_factor_;
#if BUILDFLAG(IS_MAC)
bool scroll_bounce_;

View File

@@ -18,7 +18,10 @@ declare namespace Electron {
setDesktopName(name: string): void;
setAppPath(path: string | null): void;
_clientCertRequestPasswordHandler: ((params: ClientCertRequestParams) => Promise<string>) | null;
on(event: '-client-certificate-request-password', listener: (event: Event<ClientCertRequestParams>, callback: (password: string) => void) => Promise<void>): this;
on(
event: '-client-certificate-request-password',
listener: (event: Event<ClientCertRequestParams>, callback: (password: string) => void) => Promise<void>
): this;
}
interface AutoUpdater {
@@ -34,7 +37,10 @@ declare namespace Electron {
_setEscapeTouchBarItem: (item: TouchBarItemType | {}) => void;
_refreshTouchBarItem: (itemID: string) => void;
on(event: '-touch-bar-interaction', listener: (event: Event, itemID: string, details: any) => void): this;
removeListener(event: '-touch-bar-interaction', listener: (event: Event, itemID: string, details: any) => void): this;
removeListener(
event: '-touch-bar-interaction',
listener: (event: Event, itemID: string, details: any) => void
): this;
}
interface BrowserWindow extends BaseWindow {
@@ -45,12 +51,15 @@ declare namespace Electron {
frameName: string;
_browserViews: BrowserView[];
on(event: '-touch-bar-interaction', listener: (event: Event, itemID: string, details: any) => void): this;
removeListener(event: '-touch-bar-interaction', listener: (event: Event, itemID: string, details: any) => void): this;
removeListener(
event: '-touch-bar-interaction',
listener: (event: Event, itemID: string, details: any) => void
): this;
}
interface BrowserView {
ownerWindow: BrowserWindow | null
webContentsView: WebContentsView
ownerWindow: BrowserWindow | null;
webContentsView: WebContentsView;
}
interface BrowserWindowConstructorOptions {
@@ -63,7 +72,7 @@ declare namespace Electron {
overrideGlobalValueFromIsolatedWorld(keys: string[], value: any): void;
overrideGlobalValueWithDynamicPropsFromIsolatedWorld(keys: string[], value: any): void;
overrideGlobalPropertyFromIsolatedWorld(keys: string[], getter: Function, setter?: Function): void;
}
};
}
interface ServiceWorkers {
@@ -73,7 +82,7 @@ declare namespace Electron {
interface ServiceWorkerMain {
_send(internal: boolean, channel: string, args: any): void;
_startExternalRequest(hasTimeout: boolean): { id: string, ok: boolean };
_startExternalRequest(hasTimeout: boolean): { id: string; ok: boolean };
_finishExternalRequest(uuid: string): void;
_countExternalRequests(): number;
}
@@ -95,8 +104,18 @@ declare namespace Electron {
_getPreloadScript(): Electron.PreloadScript | null;
browserWindowOptions: BrowserWindowConstructorOptions;
_windowOpenHandler: ((details: Electron.HandlerDetails) => any) | null;
_callWindowOpenHandler(event: any, details: Electron.HandlerDetails): {browserWindowConstructorOptions: Electron.BrowserWindowConstructorOptions | null, outlivesOpener: boolean, createWindow?: Electron.CreateWindowFunction};
_setNextChildWebPreferences(prefs: Partial<Electron.BrowserWindowConstructorOptions['webPreferences']> & Pick<Electron.BrowserWindowConstructorOptions, 'backgroundColor'>): void;
_callWindowOpenHandler(
event: any,
details: Electron.HandlerDetails
): {
browserWindowConstructorOptions: Electron.BrowserWindowConstructorOptions | null;
outlivesOpener: boolean;
createWindow?: Electron.CreateWindowFunction;
};
_setNextChildWebPreferences(
prefs: Partial<Electron.BrowserWindowConstructorOptions['webPreferences']> &
Pick<Electron.BrowserWindowConstructorOptions, 'backgroundColor'>
): void;
_send(internal: boolean, channel: string, args: any): boolean;
_sendInternal(channel: string, ...args: any[]): void;
_printToPDF(options: any): Promise<Buffer>;
@@ -114,8 +133,8 @@ declare namespace Electron {
_goToIndex(index: number): void;
_removeNavigationEntryAtIndex(index: number): boolean;
_getHistory(): Electron.NavigationEntry[];
_restoreHistory(index: number, entries: Electron.NavigationEntry[]): void
_clearHistory():void
_restoreHistory(index: number, entries: Electron.NavigationEntry[]): void;
_clearHistory(): void;
destroy(): void;
// <webview>
attachToIframe(embedderWebContents: Electron.WebContents, embedderFrameToken: string): void;
@@ -123,6 +142,7 @@ declare namespace Electron {
setEmbedder(embedder: Electron.WebContents): void;
viewInstanceId: number;
_setOwnerWindow(w: BaseWindow | null): void;
_setVisualZoomLevelLimits(minLevel: number, maxLevel: number): void;
}
interface WebFrameMain {
@@ -166,7 +186,15 @@ declare namespace Electron {
commandsMap: Record<string, MenuItem>;
groupsMap: Record<string, MenuItem[]>;
getItemCount(): number;
popupAt(window: BaseWindow, frame: WebFrameMain | undefined, x: number, y: number, positioning: number, sourceType: Required<Electron.PopupOptions>['sourceType'], callback: () => void): void;
popupAt(
window: BaseWindow,
frame: WebFrameMain | undefined,
x: number,
y: number,
positioning: number,
sourceType: Required<Electron.PopupOptions>['sourceType'],
callback: () => void
): void;
closePopupAt(id: number): void;
setSublabel(index: number, label: string): void;
setToolTip(index: number, tooltip: string): void;
@@ -234,17 +262,76 @@ declare namespace Electron {
}
interface WebContents {
on(event: '-new-window', listener: (event: Electron.Event, url: string, frameName: string, disposition: Electron.HandlerDetails['disposition'],
rawFeatures: string, referrer: Electron.Referrer, postData: LoadURLOptions['postData']) => void): this;
on(event: '-add-new-contents', listener: (event: Event, webContents: Electron.WebContents, disposition: string,
_userGesture: boolean, _left: number, _top: number, _width: number, _height: number, url: string, frameName: string,
referrer: Electron.Referrer, rawFeatures: string, postData: LoadURLOptions['postData']) => void): this;
on(event: '-will-add-new-contents', listener: (event: Electron.Event, url: string, frameName: string, rawFeatures: string, disposition: Electron.HandlerDetails['disposition'], referrer: Electron.Referrer, postData: LoadURLOptions['postData']) => void): this;
on(event: '-ipc-message', listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) => void): this;
on(event: '-ipc-message-sync', listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) => void): this;
on(event: '-ipc-invoke', listener: (event: Electron.IpcMainInvokeEvent, internal: boolean, channel: string, args: any[]) => void): this;
on(event: '-ipc-ports', listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, message: any, ports: any[]) => void): this;
on(event: '-run-dialog', listener: (info: {frame: WebFrameMain, dialogType: 'prompt' | 'confirm' | 'alert', messageText: string, defaultPromptText: string}, callback: (success: boolean, user_input: string) => void) => void): this;
on(
event: '-new-window',
listener: (
event: Electron.Event,
url: string,
frameName: string,
disposition: Electron.HandlerDetails['disposition'],
rawFeatures: string,
referrer: Electron.Referrer,
postData: LoadURLOptions['postData']
) => void
): this;
on(
event: '-add-new-contents',
listener: (
event: Event,
webContents: Electron.WebContents,
disposition: string,
_userGesture: boolean,
_left: number,
_top: number,
_width: number,
_height: number,
url: string,
frameName: string,
referrer: Electron.Referrer,
rawFeatures: string,
postData: LoadURLOptions['postData']
) => void
): this;
on(
event: '-will-add-new-contents',
listener: (
event: Electron.Event,
url: string,
frameName: string,
rawFeatures: string,
disposition: Electron.HandlerDetails['disposition'],
referrer: Electron.Referrer,
postData: LoadURLOptions['postData']
) => void
): this;
on(
event: '-ipc-message',
listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) => void
): this;
on(
event: '-ipc-message-sync',
listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, args: any[]) => void
): this;
on(
event: '-ipc-invoke',
listener: (event: Electron.IpcMainInvokeEvent, internal: boolean, channel: string, args: any[]) => void
): this;
on(
event: '-ipc-ports',
listener: (event: Electron.IpcMainEvent, internal: boolean, channel: string, message: any, ports: any[]) => void
): this;
on(
event: '-run-dialog',
listener: (
info: {
frame: WebFrameMain;
dialogType: 'prompt' | 'confirm' | 'alert';
messageText: string;
defaultPromptText: string;
},
callback: (success: boolean, user_input: string) => void
) => void
): this;
on(event: '-cancel-dialogs', listener: () => void): this;
on(event: 'ready-to-show', listener: () => void): this;
on(event: '-before-unload-fired', listener: (event: Electron.Event, proceed: boolean) => void): this;
@@ -263,7 +350,12 @@ declare namespace Electron {
declare namespace ElectronInternal {
interface DesktopCapturer {
startHandling(captureWindow: boolean, captureScreen: boolean, thumbnailSize: Electron.Size, fetchWindowIcons: boolean): void;
startHandling(
captureWindow: boolean,
captureScreen: boolean,
thumbnailSize: Electron.Size,
fetchWindowIcons: boolean
): void;
_onerror?: (error: string) => void;
_onfinished?: (sources: Electron.DesktopCapturerSource[], fetchWindowIcons: boolean) => void;
}
@@ -283,7 +375,8 @@ declare namespace ElectronInternal {
appIcon: Electron.NativeImage | null;
}
interface IpcRendererInternal extends NodeJS.EventEmitter, Pick<Electron.IpcRenderer, 'send' | 'sendSync' | 'invoke'> {
interface IpcRendererInternal
extends NodeJS.EventEmitter, Pick<Electron.IpcRenderer, 'send' | 'sendSync' | 'invoke'> {
invoke<T>(channel: string, ...args: any[]): Promise<T>;
}
@@ -305,21 +398,21 @@ declare namespace ElectronInternal {
}
type MediaSize = {
name: string,
custom_display_name: string,
height_microns: number,
width_microns: number,
imageable_area_left_microns?: number,
imageable_area_bottom_microns?: number,
imageable_area_right_microns?: number,
imageable_area_top_microns?: number,
is_default?: 'true',
}
name: string;
custom_display_name: string;
height_microns: number;
width_microns: number;
imageable_area_left_microns?: number;
imageable_area_bottom_microns?: number;
imageable_area_right_microns?: number;
imageable_area_top_microns?: number;
is_default?: 'true';
};
type PageSize = {
width: number,
height: number,
}
width: number;
height: number;
};
type ModuleLoader = () => any;
@@ -329,7 +422,7 @@ declare namespace ElectronInternal {
}
interface UtilityProcessWrapper extends NodeJS.EventEmitter {
readonly pid: (number) | (undefined);
readonly pid: number | undefined;
kill(): boolean;
postMessage(message: any, transfer?: any[]): void;
}