feat: proper tab handling
This commit is contained in:
200
src/renderer/components/TabBar/TabBar.css
Normal file
200
src/renderer/components/TabBar/TabBar.css
Normal file
@@ -0,0 +1,200 @@
|
||||
/* TabBar Styles - VS Code inspired */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
|
||||
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder, #1e1e1e);
|
||||
height: 35px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-bar-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
flex: 1;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but allow scrolling */
|
||||
.tab-bar-tabs::-webkit-scrollbar {
|
||||
height: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Navigation arrows */
|
||||
.tab-scroll-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
background-color: var(--vscode-editorGroupHeader-tabsBackground, #252526);
|
||||
border: none;
|
||||
color: var(--vscode-icon-foreground, #c5c5c5);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tab-scroll-button:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||
}
|
||||
|
||||
.tab-scroll-left {
|
||||
border-right: 1px solid var(--vscode-tab-border, #252526);
|
||||
}
|
||||
|
||||
.tab-scroll-right {
|
||||
border-left: 1px solid var(--vscode-tab-border, #252526);
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 10px;
|
||||
height: 100%;
|
||||
min-width: 120px;
|
||||
max-width: 200px;
|
||||
cursor: pointer;
|
||||
background-color: var(--vscode-tab-inactiveBackground, #2d2d2d);
|
||||
border-right: 1px solid var(--vscode-tab-border, #252526);
|
||||
color: var(--vscode-tab-inactiveForeground, #969696);
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background-color: var(--vscode-tab-hoverBackground, #2a2d2e);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: var(--vscode-tab-activeBackground, #1e1e1e);
|
||||
color: var(--vscode-tab-activeForeground, #fff);
|
||||
}
|
||||
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background-color: var(--vscode-tab-activeBorderTop, #0078d4);
|
||||
}
|
||||
|
||||
.tab.transient .tab-title {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.tab.active .tab-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-title.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-dirty-indicator {
|
||||
color: var(--vscode-editorWarning-foreground, #e2c08d);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-icon-foreground, #c5c5c5);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tab:hover .tab-close {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab.active .tab-close {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
opacity: 1 !important;
|
||||
background-color: var(--vscode-toolbar-hoverBackground, rgba(90, 93, 94, 0.31));
|
||||
}
|
||||
|
||||
.tab-close:active {
|
||||
background-color: var(--vscode-toolbar-activeBackground, rgba(99, 102, 103, 0.31));
|
||||
}
|
||||
|
||||
/* When tab is dirty, show close button on hover, swap with dirty indicator */
|
||||
.tab.dirty .tab-dirty-indicator {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tab.dirty .tab-close {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab.dirty:hover .tab-close {
|
||||
display: flex;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tab.dirty:hover .tab-dirty-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Focus styles for keyboard navigation */
|
||||
.tab:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder, #007fd4);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* Empty state when no tabs */
|
||||
.tab-bar-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
color: var(--vscode-descriptionForeground, #999);
|
||||
font-size: 12px;
|
||||
}
|
||||
249
src/renderer/components/TabBar/TabBar.tsx
Normal file
249
src/renderer/components/TabBar/TabBar.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { useAppStore, Tab } from '../../store';
|
||||
import './TabBar.css';
|
||||
|
||||
const getTabTitle = (tab: Tab, posts: { id: string; title: string }[], media: { id: string; originalName: string }[]): string => {
|
||||
if (tab.type === 'settings') {
|
||||
return 'Settings';
|
||||
}
|
||||
|
||||
if (tab.type === 'post') {
|
||||
const post = posts.find(p => p.id === tab.id);
|
||||
return post?.title || 'Untitled';
|
||||
}
|
||||
|
||||
if (tab.type === 'media') {
|
||||
const mediaItem = media.find(m => m.id === tab.id);
|
||||
return mediaItem?.originalName || 'Media';
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
const getTabIcon = (tab: Tab): React.ReactNode => {
|
||||
switch (tab.type) {
|
||||
case 'post':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.85 4.44l-3.28-3.3-.35-.14H2.5l-.5.5v13l.5.5h11l.5-.5V4.8l-.15-.36zm-.85 10.06H3V2h6v3.5l.5.5H13v8.5z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'media':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M14.5 2h-13l-.5.5v11l.5.5h13l.5-.5v-11l-.5-.5zM14 13H2V3h12v10zm-3.5-4.5L9 7.5l-2 2.5-1.5-1L3 12h10l-2.5-3.5z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'settings':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v3l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4h-3L5.9 12.5 4 14l-2-2 1.4-2.1L1 9.4v-3l2.4-.5L2 4l2-2 2.1 1.4.4-2.4h3zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M13.85 4.44l-3.28-3.3-.35-.14H2.5l-.5.5v13l.5.5h11l.5-.5V4.8l-.15-.36zm-.85 10.06H3V2h6v3.5l.5.5H13v8.5z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const CloseIcon: React.FC = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronLeftIcon: React.FC = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M10.354 3.146l.707.708L6.768 8l4.293 4.146-.707.708L5.354 8l5-4.854z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronRightIcon: React.FC = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M5.646 12.854l-.707-.708L9.232 8 4.939 3.854l.707-.708L10.646 8l-5 4.854z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TabBar: React.FC = () => {
|
||||
const {
|
||||
tabs,
|
||||
activeTabId,
|
||||
posts,
|
||||
media,
|
||||
dirtyPosts,
|
||||
setActiveTab,
|
||||
closeTab,
|
||||
pinTab,
|
||||
} = useAppStore();
|
||||
|
||||
const tabsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(false);
|
||||
|
||||
// Check if arrows are needed based on scroll position
|
||||
const updateArrowVisibility = useCallback(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = container;
|
||||
setShowLeftArrow(scrollLeft > 0);
|
||||
setShowRightArrow(scrollLeft + clientWidth < scrollWidth - 1);
|
||||
}, []);
|
||||
|
||||
// Update arrow visibility on scroll or resize
|
||||
useEffect(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
updateArrowVisibility();
|
||||
|
||||
container.addEventListener('scroll', updateArrowVisibility);
|
||||
const resizeObserver = new ResizeObserver(updateArrowVisibility);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateArrowVisibility);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [updateArrowVisibility, tabs]);
|
||||
|
||||
// Scroll to active tab when it changes
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !tabsContainerRef.current) return;
|
||||
|
||||
const container = tabsContainerRef.current;
|
||||
const activeTab = container.querySelector(`[data-tab-id="${activeTabId}"]`) as HTMLElement;
|
||||
if (activeTab) {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const tabRect = activeTab.getBoundingClientRect();
|
||||
|
||||
if (tabRect.left < containerRect.left) {
|
||||
container.scrollLeft -= containerRect.left - tabRect.left + 10;
|
||||
} else if (tabRect.right > containerRect.right) {
|
||||
container.scrollLeft += tabRect.right - containerRect.right + 10;
|
||||
}
|
||||
}
|
||||
}, [activeTabId]);
|
||||
|
||||
// Keyboard shortcut handler (Ctrl+W to close active tab)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
|
||||
e.preventDefault();
|
||||
if (activeTabId) {
|
||||
closeTab(activeTabId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [activeTabId, closeTab]);
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
const handleTabDoubleClick = (tab: Tab) => {
|
||||
// Double-click on transient tab pins it
|
||||
if (tab.isTransient) {
|
||||
pinTab(tab.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClose = (e: React.MouseEvent, tabId: string) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tabId);
|
||||
};
|
||||
|
||||
const handleMiddleClick = (e: React.MouseEvent, tabId: string) => {
|
||||
// Middle-click closes the tab
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
closeTab(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
const scrollLeft = () => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
container.scrollBy({ left: -150, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const scrollRight = () => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
container.scrollBy({ left: 150, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-bar">
|
||||
{showLeftArrow && (
|
||||
<button
|
||||
className="tab-scroll-button tab-scroll-left"
|
||||
onClick={scrollLeft}
|
||||
title="Scroll tabs left"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="tab-bar-tabs" ref={tabsContainerRef}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const isDirty = tab.type === 'post' && dirtyPosts.has(tab.id);
|
||||
const title = getTabTitle(tab, posts, media);
|
||||
const icon = getTabIcon(tab);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
className={`tab ${isActive ? 'active' : ''} ${tab.isTransient ? 'transient' : ''} ${isDirty ? 'dirty' : ''}`}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
onDoubleClick={() => handleTabDoubleClick(tab)}
|
||||
onMouseDown={(e) => handleMiddleClick(e, tab.id)}
|
||||
title={`${title}${tab.isTransient ? ' (Preview)' : ''}${isDirty ? ' • Modified' : ''}`}
|
||||
>
|
||||
<span className="tab-icon">{icon}</span>
|
||||
<span className={`tab-title ${tab.isTransient ? 'italic' : ''}`}>
|
||||
{title}
|
||||
</span>
|
||||
<div className="tab-actions">
|
||||
{isDirty && <span className="tab-dirty-indicator">●</span>}
|
||||
<button
|
||||
className="tab-close"
|
||||
onClick={(e) => handleTabClose(e, tab.id)}
|
||||
title="Close (Ctrl+W)"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showRightArrow && (
|
||||
<button
|
||||
className="tab-scroll-button tab-scroll-right"
|
||||
onClick={scrollRight}
|
||||
title="Scroll tabs right"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabBar;
|
||||
1
src/renderer/components/TabBar/index.ts
Normal file
1
src/renderer/components/TabBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { TabBar } from './TabBar';
|
||||
Reference in New Issue
Block a user