mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-12 10:21:30 +00:00
feat: interface zoom control settings (#7255)
* feat: interface zoom control settings * fix: allow zoom controls using shortcuts * fix: maintain consitency in zoom shortcuts and ui interface * fix: added min max to 50% and 150% for zoom * fix: moved percentageToZoomLevel function in bruno-common * chore: abstractions --------- Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
.zoom-field {
|
||||
min-width: 120px;
|
||||
max-width: 150px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zoom-field label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
width: 100%;
|
||||
height: 2.25rem;
|
||||
padding: 0 2rem 0 0.625rem;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-color: ${(props) => props.theme.input.background};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 0.375rem;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.custom-select:hover {
|
||||
border-color: ${(props) => props.theme.input.hoverBorder || props.theme.input.border};
|
||||
}
|
||||
|
||||
.custom-select .selected-value {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-select .chevron-icon {
|
||||
color: ${(props) => props.theme.input.border};
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background-color: ${(props) => props.theme.input.background};
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
z-index: 50;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.dropdown-option:hover {
|
||||
background-color: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
.dropdown-option.selected {
|
||||
background-color: ${(props) => props.theme.input.focusBorder || props.theme.input.border}22;
|
||||
}
|
||||
|
||||
.dropdown-option .option-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dropdown-option .check-icon {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
height: 2.25rem;
|
||||
background: transparent;
|
||||
border: 1px solid ${(props) => props.theme.input.border};
|
||||
border-radius: 0.375rem;
|
||||
color: ${(props) => props.theme.textLink};
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.input.border};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.input.focusBorder};
|
||||
box-shadow: 0 0 0 2px ${(props) => props.theme.input.focusBorder}33;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconChevronDown, IconCheck } from '@tabler/icons';
|
||||
const { percentageToZoomLevel } = require('@usebruno/common');
|
||||
|
||||
// Zoom options for dropdown (50% to 150%)
|
||||
const ZOOM_OPTIONS = [
|
||||
{ label: '50%', value: 50 },
|
||||
{ label: '60%', value: 60 },
|
||||
{ label: '70%', value: 70 },
|
||||
{ label: '80%', value: 80 },
|
||||
{ label: '90%', value: 90 },
|
||||
{ label: '100%', value: 100 },
|
||||
{ label: '110%', value: 110 },
|
||||
{ label: '120%', value: 120 },
|
||||
{ label: '130%', value: 130 },
|
||||
{ label: '140%', value: 140 },
|
||||
{ label: '150%', value: 150 }
|
||||
];
|
||||
|
||||
const DEFAULT_ZOOM = 100;
|
||||
|
||||
const Zoom = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dropdownRef = useRef(null);
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
// Get saved zoom percentage from Redux preferences (single source of truth)
|
||||
const savedZoom = get(preferences, 'display.zoomPercentage', DEFAULT_ZOOM);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (zoom) => {
|
||||
// Apply zoom level to Electron window immediately
|
||||
if (ipcRenderer) {
|
||||
const zoomLevel = percentageToZoomLevel(zoom);
|
||||
ipcRenderer.invoke('renderer:set-zoom-level', zoomLevel);
|
||||
}
|
||||
|
||||
// Save to preferences via Redux (same pattern as layout)
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
display: {
|
||||
...get(preferences, 'display', {}),
|
||||
zoomPercentage: zoom
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
handleSelect(DEFAULT_ZOOM);
|
||||
};
|
||||
|
||||
const selectedOption = ZOOM_OPTIONS.find((opt) => opt.value === savedZoom);
|
||||
const isDefault = savedZoom === DEFAULT_ZOOM;
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="flex flex-row gap-4 items-end">
|
||||
<div className="zoom-field" ref={dropdownRef}>
|
||||
<label className="block">Interface Zoom</label>
|
||||
<div className="custom-select mt-2" onClick={() => setIsOpen(!isOpen)}>
|
||||
<span className="selected-value">{selectedOption?.label}</span>
|
||||
<IconChevronDown size={14} className="chevron-icon" />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="dropdown-menu">
|
||||
{ZOOM_OPTIONS.map((option) => (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`dropdown-option ${option.value === savedZoom ? 'selected' : ''}`}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.value === savedZoom && <IconCheck size={14} className="check-icon" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className="reset-btn"
|
||||
onClick={handleResetToDefault}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Zoom;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import Font from './Font/index';
|
||||
import Zoom from './Zoom/index';
|
||||
|
||||
const Display = ({ close }) => {
|
||||
return (
|
||||
@@ -9,6 +10,9 @@ const Display = ({ close }) => {
|
||||
<div className="w-fit flex flex-col gap-2">
|
||||
<Font close={close} />
|
||||
</div>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<Zoom />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { mockDataFunctions, timeBasedDynamicVars } from './utils/faker-functions';
|
||||
export { default as interpolate, interpolateObject } from './interpolate';
|
||||
export { percentageToZoomLevel } from './zoom';
|
||||
export { default as isRequestTagsIncluded } from './tags';
|
||||
|
||||
export * as utils from './utils';
|
||||
|
||||
5
packages/bruno-common/src/zoom/index.ts
Normal file
5
packages/bruno-common/src/zoom/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Convert percentage to zoom level (Electron uses logarithmic scale)
|
||||
// Formula: percentage = 100 * 1.2^level
|
||||
export const percentageToZoomLevel = (percentage: number): number => {
|
||||
return Math.log(percentage / 100) / Math.log(1.2);
|
||||
};
|
||||
@@ -62,9 +62,27 @@ const template = [
|
||||
submenu: [
|
||||
{ role: 'toggledevtools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetzoom' },
|
||||
{ role: 'zoomin' },
|
||||
{ role: 'zoomout' },
|
||||
{
|
||||
label: 'Actual Size',
|
||||
accelerator: 'CommandOrControl+0',
|
||||
click() {
|
||||
ipcMain.emit('menu:reset-zoom');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Zoom In',
|
||||
accelerator: 'CommandOrControl+Plus',
|
||||
click() {
|
||||
ipcMain.emit('menu:zoom-in');
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Zoom Out',
|
||||
accelerator: 'CommandOrControl+-',
|
||||
click() {
|
||||
ipcMain.emit('menu:zoom-out');
|
||||
}
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' }
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ const { execSync } = require('node:child_process');
|
||||
const isDev = require('electron-is-dev');
|
||||
const os = require('os');
|
||||
const { initializeShellEnv } = require('@usebruno/requests');
|
||||
const { percentageToZoomLevel } = require('@usebruno/common');
|
||||
|
||||
if (isDev) {
|
||||
if (!fs.existsSync(path.join(__dirname, '../../bruno-js/src/sandbox/bundle-browser-rollup.js'))) {
|
||||
@@ -47,6 +48,7 @@ const collectionWatcher = require('./app/collection-watcher');
|
||||
const WorkspaceWatcher = require('./app/workspace-watcher');
|
||||
const ApiSpecWatcher = require('./app/apiSpecsWatcher');
|
||||
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
|
||||
const { preferencesUtil, getPreferences, savePreferences } = require('./store/preferences');
|
||||
const { globalEnvironmentsManager } = require('./store/workspace-environments');
|
||||
const registerNotificationsIpc = require('./ipc/notifications');
|
||||
const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
|
||||
@@ -92,6 +94,25 @@ const isLinux = process.platform === 'linux';
|
||||
let mainWindow;
|
||||
let appProtocolUrl;
|
||||
|
||||
// Helper function to save zoom percentage to preferences and notify renderer
|
||||
const saveZoomPreferences = async (percentage) => {
|
||||
if (!mainWindow) return;
|
||||
|
||||
const clampedPercentage = Math.max(50, Math.min(150, percentage));
|
||||
|
||||
const prefs = getPreferences();
|
||||
prefs.display = prefs.display || {};
|
||||
prefs.display.zoomPercentage = clampedPercentage;
|
||||
|
||||
try {
|
||||
await savePreferences(prefs);
|
||||
// Notify renderer to update Redux state only after successful save
|
||||
mainWindow.webContents.send('main:load-preferences', prefs);
|
||||
} catch (err) {
|
||||
console.error('Failed to save zoom preference:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to focus and restore the main window
|
||||
const focusMainWindow = () => {
|
||||
if (mainWindow) {
|
||||
@@ -246,17 +267,32 @@ app.on('ready', async () => {
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:reset-zoom', () => {
|
||||
mainWindow.webContents.setZoomLevel(0);
|
||||
updateZoomLevel(100);
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:zoom-in', () => {
|
||||
const currentZoom = mainWindow.webContents.getZoomLevel();
|
||||
mainWindow.webContents.setZoomLevel(currentZoom + 0.5);
|
||||
incrementZoomAndPersist(10);
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:zoom-out', () => {
|
||||
const currentZoom = mainWindow.webContents.getZoomLevel();
|
||||
mainWindow.webContents.setZoomLevel(currentZoom - 0.5);
|
||||
incrementZoomAndPersist(-10);
|
||||
});
|
||||
|
||||
// Menu event handlers for zoom (from menu-template.js)
|
||||
ipcMain.on('menu:reset-zoom', () => {
|
||||
updateZoomLevel(100);
|
||||
});
|
||||
|
||||
ipcMain.on('menu:zoom-in', () => {
|
||||
incrementZoomAndPersist(10);
|
||||
});
|
||||
|
||||
ipcMain.on('menu:zoom-out', () => {
|
||||
incrementZoomAndPersist(-10);
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:set-zoom-level', (event, zoomLevel) => {
|
||||
mainWindow.webContents.setZoomLevel(zoomLevel);
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:toggle-fullscreen', () => {
|
||||
@@ -282,6 +318,12 @@ app.on('ready', async () => {
|
||||
});
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
// Apply saved zoom level from preferences before showing window
|
||||
const zoomPercentage = preferencesUtil.getZoomPercentage();
|
||||
if (zoomPercentage) {
|
||||
const zoomLevel = percentageToZoomLevel(zoomPercentage);
|
||||
mainWindow.webContents.setZoomLevel(zoomLevel);
|
||||
}
|
||||
mainWindow.show();
|
||||
});
|
||||
const devPort = process.env.BRUNO_DEV_PORT || 3000;
|
||||
@@ -445,7 +487,7 @@ app.on('open-file', (event, path) => {
|
||||
app.on('browser-window-focus', () => {
|
||||
// Quick fix for Electron issue #29996: https://github.com/electron/electron/issues/29996
|
||||
globalShortcut.register('Ctrl+=', () => {
|
||||
mainWindow.webContents.setZoomLevel(mainWindow.webContents.getZoomLevel() + 1);
|
||||
incrementZoomAndPersist(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -453,3 +495,24 @@ app.on('browser-window-focus', () => {
|
||||
app.on('browser-window-blur', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {number} inc (+/- amount to zoom in / out);
|
||||
*/
|
||||
function incrementZoomAndPersist(inc) {
|
||||
const currentPercentage = preferencesUtil.getZoomPercentage();
|
||||
const nextPercentage = Math.min(
|
||||
Math.max(currentPercentage + inc, 50),
|
||||
150
|
||||
);
|
||||
updateZoomLevel(nextPercentage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} percent percentage to increase or decrease zoom by, percentage is converted to chrome's log value internally
|
||||
*/
|
||||
function updateZoomLevel(percent) {
|
||||
const zoomLevel = percentageToZoomLevel(percent);
|
||||
mainWindow.webContents.setZoomLevel(zoomLevel);
|
||||
saveZoomPreferences(percent);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ const defaultPreferences = {
|
||||
autoSave: {
|
||||
enabled: false,
|
||||
interval: 1000
|
||||
},
|
||||
display: {
|
||||
zoomPercentage: 100
|
||||
}
|
||||
};
|
||||
|
||||
@@ -110,6 +113,9 @@ const preferencesSchema = Yup.object().shape({
|
||||
autoSave: Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
interval: Yup.number().min(100)
|
||||
}),
|
||||
display: Yup.object({
|
||||
zoomPercentage: Yup.number().min(50).max(150)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -286,6 +292,9 @@ const preferencesUtil = {
|
||||
isBetaFeatureEnabled: (featureName) => {
|
||||
return get(getPreferences(), `beta.${featureName}`, false);
|
||||
},
|
||||
getZoomPercentage: () => {
|
||||
return get(getPreferences(), 'display.zoomPercentage', 100);
|
||||
},
|
||||
hasLaunchedBefore: () => {
|
||||
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user