feat: custom title bar that is more compact
This commit is contained in:
@@ -472,6 +472,10 @@ export const TabBar: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tab-bar">
|
||||
{showLeftArrow && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.window-titlebar {
|
||||
position: relative;
|
||||
height: 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6,8 +7,96 @@
|
||||
background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
|
||||
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, #1e1e1e);
|
||||
flex-shrink: 0;
|
||||
app-region: drag;
|
||||
-webkit-app-region: drag;
|
||||
padding-right: 10px;
|
||||
padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));
|
||||
}
|
||||
|
||||
.window-titlebar-menu-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
margin-left: 6px;
|
||||
gap: 2px;
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.window-titlebar-menu-button {
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-titleBar-activeForeground, var(--vscode-foreground, #cccccc));
|
||||
padding: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.window-titlebar-menu-button:focus,
|
||||
.window-titlebar-menu-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.window-titlebar-menu-button:hover,
|
||||
.window-titlebar-menu-button.is-active {
|
||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||
}
|
||||
|
||||
.window-titlebar-menu-dropdown {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
min-width: 210px;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background-color: var(--vscode-menu-background, #252526);
|
||||
border: 1px solid var(--vscode-menu-border, #454545);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--vscode-widget-shadow, 0 8px 24px rgba(0, 0, 0, 0.4));
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.window-titlebar-menu-item {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-menu-foreground, var(--vscode-foreground, #cccccc));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
text-align: left;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.window-titlebar-menu-item:focus,
|
||||
.window-titlebar-menu-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||
}
|
||||
|
||||
.window-titlebar-menu-item:hover {
|
||||
background-color: var(--vscode-menu-selectionBackground, rgba(9, 71, 113, 0.45));
|
||||
}
|
||||
|
||||
.window-titlebar-menu-item-accelerator {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.window-titlebar-menu-separator {
|
||||
height: 1px;
|
||||
margin: 4px 2px;
|
||||
background-color: var(--vscode-menu-separatorBackground, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.window-titlebar-drag-region {
|
||||
@@ -15,6 +104,25 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.window-titlebar-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 45%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--vscode-titleBar-activeForeground, var(--vscode-foreground, #cccccc));
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.window-titlebar-actions {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -35,6 +143,7 @@
|
||||
color: var(--vscode-foreground, #cccccc);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,180 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { APP_MENU_GROUPS } from '../../../main/shared/menuCommands';
|
||||
import './WindowTitleBar.css';
|
||||
|
||||
type WindowControlsOverlayLike = {
|
||||
visible: boolean;
|
||||
getTitlebarAreaRect: () => DOMRect;
|
||||
addEventListener: (type: 'geometrychange', listener: EventListener) => void;
|
||||
removeEventListener: (type: 'geometrychange', listener: EventListener) => void;
|
||||
};
|
||||
|
||||
export const WindowTitleBar: React.FC = () => {
|
||||
const { sidebarVisible, toggleSidebar } = useAppStore();
|
||||
const [windowTitle, setWindowTitle] = useState<string>(document.title || 'Blogging Desktop Server');
|
||||
const [openMenu, setOpenMenu] = useState<{ label: string; left: number } | null>(null);
|
||||
const menuRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMac = navigator.platform.toLowerCase().includes('mac');
|
||||
const isDevMode = (window as Window & { __BDS_IS_DEV__?: boolean }).__BDS_IS_DEV__
|
||||
?? (typeof import.meta !== 'undefined' && Boolean(import.meta.env?.DEV));
|
||||
|
||||
const visibleMenuGroups = APP_MENU_GROUPS.map((group) => {
|
||||
if (group.label !== 'View') {
|
||||
return group;
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
items: group.items.filter(item => isDevMode || item.action !== 'toggleDevTools'),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const rootStyle = document.documentElement.style;
|
||||
const setInsets = (left: number, right: number) => {
|
||||
rootStyle.setProperty('--bds-titlebar-overlay-left', `${left}px`);
|
||||
rootStyle.setProperty('--bds-titlebar-overlay-right', `${right}px`);
|
||||
};
|
||||
|
||||
const overlay = (navigator as Navigator & { windowControlsOverlay?: WindowControlsOverlayLike }).windowControlsOverlay;
|
||||
if (!overlay) {
|
||||
setInsets(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const syncOverlayInsets = () => {
|
||||
if (!overlay.visible) {
|
||||
setInsets(0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const titlebarRect = overlay.getTitlebarAreaRect();
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || titlebarRect.right;
|
||||
|
||||
const leftInset = Math.max(0, Math.round(titlebarRect.left));
|
||||
const rightInset = Math.max(0, Math.round(viewportWidth - titlebarRect.right));
|
||||
setInsets(leftInset, rightInset);
|
||||
};
|
||||
|
||||
const onGeometryChange: EventListener = () => {
|
||||
syncOverlayInsets();
|
||||
};
|
||||
const onResize = () => {
|
||||
syncOverlayInsets();
|
||||
};
|
||||
|
||||
syncOverlayInsets();
|
||||
overlay.addEventListener('geometrychange', onGeometryChange);
|
||||
const canListenToResize = typeof window.addEventListener === 'function';
|
||||
if (canListenToResize) {
|
||||
window.addEventListener('resize', onResize);
|
||||
}
|
||||
|
||||
return () => {
|
||||
overlay.removeEventListener('geometrychange', onGeometryChange);
|
||||
if (canListenToResize && typeof window.removeEventListener === 'function') {
|
||||
window.removeEventListener('resize', onResize);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const updateTitle = () => {
|
||||
setWindowTitle(document.title || 'Blogging Desktop Server');
|
||||
};
|
||||
|
||||
updateTitle();
|
||||
|
||||
const titleElement = document.querySelector('title');
|
||||
if (!titleElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
updateTitle();
|
||||
});
|
||||
observer.observe(titleElement, { childList: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!openMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onDocumentMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (menuRootRef.current && !menuRootRef.current.contains(target)) {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpenMenu(null);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', onDocumentMouseDown);
|
||||
document.addEventListener('keydown', onEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown);
|
||||
document.removeEventListener('keydown', onEscape);
|
||||
};
|
||||
}, [openMenu]);
|
||||
|
||||
const handleMenuButtonClick = (event: React.MouseEvent<HTMLButtonElement>, label: string) => {
|
||||
const buttonRect = event.currentTarget.getBoundingClientRect();
|
||||
const rootRect = menuRootRef.current?.getBoundingClientRect();
|
||||
const left = rootRect ? buttonRect.left - rootRect.left : buttonRect.left;
|
||||
|
||||
if (openMenu?.label === label) {
|
||||
setOpenMenu(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenMenu({ label, left });
|
||||
};
|
||||
|
||||
const handleMenuItemClick = (action: string) => {
|
||||
setOpenMenu(null);
|
||||
void window.electronAPI?.app?.triggerMenuAction?.(action);
|
||||
};
|
||||
|
||||
const formatAccelerator = (accelerator: string): string => {
|
||||
const firstPass = accelerator
|
||||
.replace(/CmdOrCtrl/g, isMac ? '⌘' : 'Ctrl')
|
||||
.replace(/Alt/g, isMac ? '⌥' : 'Alt')
|
||||
.replace(/Shift/g, isMac ? '⇧' : 'Shift');
|
||||
return firstPass;
|
||||
};
|
||||
|
||||
const activeMenu = openMenu ? visibleMenuGroups.find(group => group.label === openMenu.label) : null;
|
||||
|
||||
return (
|
||||
<div className="window-titlebar" data-testid="window-titlebar">
|
||||
<div className="window-titlebar" data-testid="window-titlebar" ref={menuRootRef}>
|
||||
<div className="window-titlebar-menu-bar" data-testid="window-titlebar-menu-bar">
|
||||
{visibleMenuGroups.map(group => (
|
||||
<button
|
||||
key={group.label}
|
||||
className={`window-titlebar-menu-button${openMenu?.label === group.label ? ' is-active' : ''}`}
|
||||
type="button"
|
||||
onClick={(event) => handleMenuButtonClick(event, group.label)}
|
||||
aria-label={group.label}
|
||||
>
|
||||
{group.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="window-titlebar-drag-region" />
|
||||
<div className="window-titlebar-title" data-testid="window-titlebar-title" title={windowTitle}>
|
||||
{windowTitle}
|
||||
</div>
|
||||
<div className="window-titlebar-actions">
|
||||
<button
|
||||
className="window-titlebar-action-button"
|
||||
@@ -20,6 +187,34 @@ export const WindowTitleBar: React.FC = () => {
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{openMenu && activeMenu && (
|
||||
<div
|
||||
className="window-titlebar-menu-dropdown"
|
||||
data-testid="window-titlebar-menu-dropdown"
|
||||
style={{ left: `${openMenu.left}px` }}
|
||||
>
|
||||
{activeMenu.items.map(item => {
|
||||
if (item.separator) {
|
||||
return <div key={item.action} className="window-titlebar-menu-separator" />;
|
||||
}
|
||||
|
||||
const acceleratorText = item.accelerator ? formatAccelerator(item.accelerator) : null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.action}
|
||||
type="button"
|
||||
className="window-titlebar-menu-item"
|
||||
onClick={() => handleMenuItemClick(item.action)}
|
||||
aria-label={acceleratorText ? `${item.label} ${acceleratorText}` : item.label}
|
||||
>
|
||||
<span className="window-titlebar-menu-item-label">{item.label}</span>
|
||||
{acceleratorText && <span className="window-titlebar-menu-item-accelerator">{acceleratorText}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user