feat: 侧边面板改平铺标签条——所有打开的 tab 一起展示;内容区统一 bg-page(浏览器/插件面板背景一致)

This commit is contained in:
Rick Ye
2026-09-11 11:15:14 +08:00
parent 6098cf678a
commit f1cbe764bb
@@ -3,8 +3,9 @@ import { LuChevronDown, LuPlus, LuX } from 'react-icons/lu'
/**
* 右侧通用标签面板(TabPanel,ZCode 式)。
* 面板承载所有标签的通用机制:标签条、˅ 标签列表下拉(逐个关闭、收起面板)、
* 标签右键菜单(关闭/关闭其他)、空态「打开标签页」宫格、左缘宽度拖拽。
* 面板承载所有标签的通用机制:平铺标签条(所有打开的 tab 一起展示,点击切换,
* 悬停显示关闭、右键菜单:关闭/关闭其他/收起面板)、+ 新增标签、
* 空态「打开标签页」宫格、左缘宽度拖拽。
* 具体标签(浏览器等)通过 tabs 传入内容,与面板机制完全解耦;
* 新增标签类型只需在宿主的标签注册表里登记,面板零改动。
*/
@@ -58,7 +59,6 @@ export function SidePanel({
onOverlayChange
}: SidePanelProps): JSX.Element {
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const [listOpen, setListOpen] = useState(false)
const [plusOpen, setPlusOpen] = useState(false)
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; tabId: string } | null>(null)
@@ -86,26 +86,25 @@ export function SidePanel({
// 菜单打开时 Esc 关闭
useEffect(() => {
if (!listOpen && !plusOpen && !ctxMenu) return
if (!plusOpen && !ctxMenu) return
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') {
setListOpen(false)
setPlusOpen(false)
setCtxMenu(null)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [listOpen, plusOpen, ctxMenu])
}, [plusOpen, ctxMenu])
// 覆盖层开合上报(宿主借此暂停原生视图)。卸载必须归位:面板在菜单开着时
// 被收起(˅ 列表「收起面板」与 setListOpen 同批提交,归位 effect 不再执行),
// 被收起(与 setCtxMenu/setPlusOpen 同批提交,归位 effect 不再执行),
// 宿主的 suspended 会永久卡 true,下次展开面板时原生视图被隐藏成一片空白。
// cleanup 在依赖变化时也会短暂触发 false→当前值,同一提交内批处理合并不闪烁。
useEffect(() => {
onOverlayChange?.(listOpen || plusOpen || ctxMenu !== null)
onOverlayChange?.(plusOpen || ctxMenu !== null)
return () => onOverlayChange?.(false)
}, [listOpen, plusOpen, ctxMenu, onOverlayChange])
}, [plusOpen, ctxMenu, onOverlayChange])
const current = tabs.find((t) => t.id === activeId) ?? tabs[tabs.length - 1] ?? null
@@ -125,81 +124,45 @@ export function SidePanel({
{tabs.length > 0 && (
<div className="relative flex h-8 shrink-0 items-center gap-1 border-b border-line bg-surface px-1.5">
{/* 面板 tag 本身即下拉按钮(与网页 tab 条的 ˅ 列表按钮形态区分) */}
<div className="relative shrink-0">
<button
type="button"
aria-expanded={listOpen}
onClick={() => setListOpen((v) => !v)}
onContextMenu={(e) => {
e.preventDefault()
setCtxMenu({
x: Math.min(e.clientX, window.innerWidth - 170),
y: Math.min(e.clientY, window.innerHeight - 110),
tabId: current?.id ?? ''
})
}}
className={`flex h-7 items-center gap-1.5 rounded-md border px-2 text-[12px] transition-colors ${
listOpen
? 'border-accent/50 bg-accent-tint/40 text-accent-ink'
: 'border-line bg-surface text-ink-2 hover:border-accent/40 hover:text-ink'
}`}
>
{current && (
<>
<current.icon size={12} className="shrink-0" />
<span className="min-w-0 max-w-32 truncate">{current.title}</span>
</>
)}
<LuChevronDown
size={11}
className={`shrink-0 text-ink-3 transition-transform ${listOpen ? 'rotate-180' : ''}`}
/>
</button>
{listOpen && (
<>
<div className="fixed inset-0 z-20" onClick={() => setListOpen(false)} />
<div className="absolute left-0 top-8 z-30 w-56 rounded-lg border border-line bg-surface py-1.5 shadow-overlay">
<div className="px-3 pb-1 pt-1 text-[11px] text-ink-3">打开的tab</div>
{tabs.map((tab) => (
<div
key={tab.id}
onClick={() => {
onSelectTab(tab.id)
setListOpen(false)
}}
className="group flex cursor-default items-center gap-2 px-2 py-1.5 hover:bg-hover"
>
<tab.icon size={12} className="shrink-0 text-ink-3" />
<span className="min-w-0 flex-1 truncate text-[12px] text-ink-2">{tab.title}</span>
<button
type="button"
aria-label={`关闭${tab.title}标签`}
onClick={(e) => {
e.stopPropagation()
onCloseTab(tab.id)
}}
className="shrink-0 rounded p-0.5 text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2"
>
<LuX size={11} />
</button>
</div>
))}
<div className="my-1 border-t border-line-soft" />
{/* 标签条:所有打开的 tab 平铺展示(浏览器式);超出宽度横向滚动 */}
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
{tabs.map((tab) => {
const active = tab.id === current?.id
return (
<div
key={tab.id}
aria-current={active || undefined}
onClick={() => onSelectTab(tab.id)}
onContextMenu={(e) => {
e.preventDefault()
setCtxMenu({
x: Math.min(e.clientX, window.innerWidth - 170),
y: Math.min(e.clientY, window.innerHeight - 140),
tabId: tab.id
})
}}
className={`group flex h-7 shrink-0 cursor-default select-none items-center gap-1.5 rounded-md px-2 text-[12px] transition-colors ${
active ? 'bg-inset text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink-2'
}`}
>
<tab.icon size={12} className="shrink-0" />
<span className="min-w-0 max-w-32 truncate">{tab.title}</span>
<button
type="button"
onClick={() => {
setListOpen(false)
onCollapse()
aria-label={`关闭${tab.title}标签`}
onClick={(e) => {
e.stopPropagation()
onCloseTab(tab.id)
}}
className="w-full px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover"
className={`shrink-0 rounded p-0.5 text-ink-3 transition-colors hover:bg-hover-2 hover:text-ink-2 ${
active ? '' : 'opacity-0 group-hover:opacity-100'
}`}
>
收起面板
<LuX size={11} />
</button>
</div>
</>
)}
)
})}
</div>
{/* 新增标签:列出注册表中的类型,已打开的置灰 */}
@@ -220,7 +183,7 @@ export function SidePanel({
{plusOpen && (
<>
<div className="fixed inset-0 z-20" onClick={() => setPlusOpen(false)} />
<div className="absolute left-0 top-8 z-30 w-40 rounded-lg border border-line bg-surface py-1 shadow-overlay">
<div className="absolute right-0 top-8 z-30 w-40 rounded-lg border border-line bg-surface py-1 shadow-overlay">
{available.map(({ id, title, icon: Icon }) => {
const opened = tabs.some((t) => t.id === id)
return (
@@ -285,6 +248,18 @@ export function SidePanel({
<LuX size={11} className="shrink-0" />
关闭其他标签页
</button>
<div className="my-1 border-t border-line-soft" />
<button
type="button"
onClick={() => {
setCtxMenu(null)
onCollapse()
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[12px] text-ink-2 transition-colors hover:bg-hover"
>
<LuChevronDown size={11} className="shrink-0" />
收起面板
</button>
</div>
</>
)}
@@ -294,7 +269,7 @@ export function SidePanel({
{tabs.length === 0 ? (
<EmptyState available={available} onOpen={onOpenTab} />
) : (
<div className="relative min-h-0 flex-1">{current?.content ?? null}</div>
<div className="relative min-h-0 flex-1 bg-page">{current?.content ?? null}</div>
)}
</aside>
)