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