feat(browser): per-site appearance and zoom, from the page menu and Cmd/Ctrl +/-/0 (#31621)

This commit is contained in:
Kelvin Amoaba
2026-10-21 23:47:12 +00:00
committed by GitHub
parent 7e795b6be6
commit c5e07bc3af
55 changed files with 1285 additions and 806 deletions
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import {
browserPageZoomLevelToPercent,
nextBrowserPageZoomLevel,
normalizeBrowserPageZoomLevel
} from './browser-page-zoom'
const percentsFrom = (level: number, direction: 'in' | 'out', steps: number): number[] => {
const percents: number[] = []
for (let step = 0; step < steps; step++) {
level = nextBrowserPageZoomLevel(level, direction)
percents.push(browserPageZoomLevelToPercent(level))
}
return percents
}
describe('browser page zoom presets', () => {
it("steps through Chrome's presets and stops at 25% and 500%", () => {
expect(percentsFrom(0, 'in', 10)).toEqual([110, 125, 150, 175, 200, 250, 300, 400, 500, 500])
expect(percentsFrom(0, 'out', 8)).toEqual([90, 80, 75, 67, 50, 33, 25, 25])
})
it('snaps levels saved before the presets to the nearest one', () => {
// 0.5-level steps used to give 110%, 131%, 144% and 173%.
expect(
[0.5, 1.5, 2, 3].map(normalizeBrowserPageZoomLevel).map(browserPageZoomLevelToPercent)
).toEqual([110, 125, 150, 175])
expect(normalizeBrowserPageZoomLevel(Number.NaN)).toBe(0)
})
it('resets to the level it is given', () => {
expect(nextBrowserPageZoomLevel(3, 'reset', normalizeBrowserPageZoomLevel(1.5))).toBe(
normalizeBrowserPageZoomLevel(1.5)
)
})
})
+37 -24
View File
@@ -1,34 +1,49 @@
export type BrowserPageZoomDirection = 'in' | 'out' | 'reset'
export function isBrowserPageZoomDirection(value: unknown): value is BrowserPageZoomDirection {
return value === 'in' || value === 'out' || value === 'reset'
}
export type BrowserPageZoomCommand = {
browserPageId: string
direction: BrowserPageZoomDirection
}
export const BROWSER_PAGE_ZOOM_STEP = 0.5
export const BROWSER_PAGE_ZOOM_MIN = -3
export const BROWSER_PAGE_ZOOM_MAX = 5
export const DEFAULT_BROWSER_PAGE_ZOOM_LEVEL = 0
export const BROWSER_PAGE_ZOOM_LEVELS: readonly number[] = Array.from(
{
length: Math.round((BROWSER_PAGE_ZOOM_MAX - BROWSER_PAGE_ZOOM_MIN) / BROWSER_PAGE_ZOOM_STEP) + 1
},
(_, index) => BROWSER_PAGE_ZOOM_MIN + index * BROWSER_PAGE_ZOOM_STEP
// Why: Chrome's page zoom presets; Electron levels are log base 1.2 of the zoom factor.
const BROWSER_PAGE_ZOOM_PERCENTS = [
25, 33, 50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300, 400, 500
] as const
export const BROWSER_PAGE_ZOOM_LEVELS: readonly number[] = BROWSER_PAGE_ZOOM_PERCENTS.map(
(percent) => Math.log(percent / 100) / Math.log(1.2)
)
export function normalizeBrowserPageZoomLevel(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
/** The nearest preset; anything that is not a finite level counts as 100%. */
function nearestZoomLevelIndex(level: unknown): number {
if (typeof level !== 'number' || !Number.isFinite(level)) {
return BROWSER_PAGE_ZOOM_PERCENTS.indexOf(100)
}
const roundedToStep = Math.round(value / BROWSER_PAGE_ZOOM_STEP) * BROWSER_PAGE_ZOOM_STEP
return Math.max(BROWSER_PAGE_ZOOM_MIN, Math.min(BROWSER_PAGE_ZOOM_MAX, roundedToStep))
let nearest = 0
for (let index = 1; index < BROWSER_PAGE_ZOOM_LEVELS.length; index++) {
if (
Math.abs(BROWSER_PAGE_ZOOM_LEVELS[index] - level) <
Math.abs(BROWSER_PAGE_ZOOM_LEVELS[nearest] - level)
) {
nearest = index
}
}
return nearest
}
/** Snaps any level, including ones saved before the presets, to the nearest preset. */
export function normalizeBrowserPageZoomLevel(value: unknown): number {
return BROWSER_PAGE_ZOOM_LEVELS[nearestZoomLevelIndex(value)]
}
export function browserPageZoomLevelToPercent(level: number): number {
// Why: Electron zoom levels are exponential; show the same percentage users
// expect from Chromium browser zoom controls.
return Math.round(100 * 1.2 ** normalizeBrowserPageZoomLevel(level))
return BROWSER_PAGE_ZOOM_PERCENTS[nearestZoomLevelIndex(level)]
}
export function nextBrowserPageZoomLevel(
@@ -36,12 +51,10 @@ export function nextBrowserPageZoomLevel(
direction: BrowserPageZoomDirection,
resetLevel: number = DEFAULT_BROWSER_PAGE_ZOOM_LEVEL
): number {
const rawNext =
direction === 'in'
? current + BROWSER_PAGE_ZOOM_STEP
: direction === 'out'
? current - BROWSER_PAGE_ZOOM_STEP
: normalizeBrowserPageZoomLevel(resetLevel)
return normalizeBrowserPageZoomLevel(rawNext)
if (direction === 'reset') {
return normalizeBrowserPageZoomLevel(resetLevel)
}
const index = nearestZoomLevelIndex(current)
const next = direction === 'in' ? index + 1 : index - 1
return BROWSER_PAGE_ZOOM_LEVELS[Math.max(0, Math.min(BROWSER_PAGE_ZOOM_LEVELS.length - 1, next))]
}
+44
View File
@@ -0,0 +1,44 @@
import { BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN } from './browser-site-permission'
/** A site's own color scheme; a site with none matches Orca's theme. */
export type BrowserSiteColorScheme = 'light' | 'dark'
/** What a browser profile remembers about how one site is shown. */
export type BrowserSiteDisplay = {
zoomLevel?: number
colorScheme?: BrowserSiteColorScheme
}
/** How the page in a tab is shown right now, for the browser menu. */
export type BrowserPageSiteDisplay = {
/** Null for pages no site owns; neither setting applies to them. */
site: string | null
zoomLevel: number
/** False while the site uses Default Zoom. */
siteHasZoom: boolean
colorScheme: BrowserSiteColorScheme | null
}
export function isBrowserSiteColorScheme(value: unknown): value is BrowserSiteColorScheme {
return value === 'light' || value === 'dark'
}
/**
* The hostname, port ignored: Chromium shares page zoom across ports, so `localhost:3000` and
* `localhost:5173` must be one site or they would keep resetting each other. Null for pages no site
* owns (about:blank, data:, chrome errors).
*/
export function browserSiteDisplayKey(url: string): string | null {
try {
const parsed = new URL(url)
if (parsed.protocol === 'file:') {
return BROWSER_SITE_PERMISSION_LOCAL_FILES_ORIGIN
}
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.hostname || null
}
} catch {
// Not a URL; no site.
}
return null
}