feat: 右侧改造成 ZCode 式通用侧边面板 + 标签页系统
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import type { JSX } from 'react'
|
import type { JSX } from 'react'
|
||||||
import { useAgent } from './hooks/useAgent'
|
import { useAgent } from './hooks/useAgent'
|
||||||
import { useBrowser } from './hooks/useBrowser'
|
import { useBrowser } from './hooks/useBrowser'
|
||||||
@@ -10,8 +10,47 @@ import { Composer } from './components/Composer'
|
|||||||
import { SettingsModal } from './components/SettingsModal'
|
import { SettingsModal } from './components/SettingsModal'
|
||||||
import { Search } from './components/Search'
|
import { Search } from './components/Search'
|
||||||
import { BrowserPanel } from './components/browser/BrowserPanel'
|
import { BrowserPanel } from './components/browser/BrowserPanel'
|
||||||
|
import { SidePanel, SIDE_TAB_ORDER, type SideTabType } from './components/SidePanel'
|
||||||
import { applyThemeColor } from './lib/theme'
|
import { applyThemeColor } from './lib/theme'
|
||||||
|
|
||||||
|
// ---- 侧边面板状态(开合 / 标签 / 宽度),跨启动持久化 ----
|
||||||
|
|
||||||
|
const SIDE_STORAGE_KEY = 'aso.sidepanel.v1'
|
||||||
|
const SIDE_DEFAULT_WIDTH = 480
|
||||||
|
const SIDE_MIN_WIDTH = 380
|
||||||
|
const SIDE_MAX_WIDTH = 760
|
||||||
|
|
||||||
|
interface SideState {
|
||||||
|
open: boolean
|
||||||
|
tabs: SideTabType[]
|
||||||
|
active: SideTabType | null
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSideState(): SideState {
|
||||||
|
const fallback: SideState = { open: false, tabs: [], active: null, width: SIDE_DEFAULT_WIDTH }
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SIDE_STORAGE_KEY)
|
||||||
|
if (!raw) return fallback
|
||||||
|
const parsed = JSON.parse(raw) as Partial<SideState>
|
||||||
|
const tabs = Array.isArray(parsed.tabs)
|
||||||
|
? parsed.tabs.filter((t): t is SideTabType => SIDE_TAB_ORDER.includes(t))
|
||||||
|
: []
|
||||||
|
return {
|
||||||
|
open: Boolean(parsed.open),
|
||||||
|
tabs,
|
||||||
|
active:
|
||||||
|
parsed.active && tabs.includes(parsed.active) ? parsed.active : (tabs[tabs.length - 1] ?? null),
|
||||||
|
width:
|
||||||
|
typeof parsed.width === 'number'
|
||||||
|
? Math.min(SIDE_MAX_WIDTH, Math.max(SIDE_MIN_WIDTH, parsed.width))
|
||||||
|
: SIDE_DEFAULT_WIDTH
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -33,8 +72,54 @@ export default function App(): JSX.Element {
|
|||||||
const prefillRef = useRef<string | null>(null)
|
const prefillRef = useRef<string | null>(null)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [browserOpen, setBrowserOpen] = useState(false)
|
|
||||||
const browser = useBrowser()
|
const browser = useBrowser()
|
||||||
|
const [side, setSide] = useState<SideState>(loadSideState)
|
||||||
|
const { open: sideOpen, tabs: sideTabs, active: activeSideTab, width: sideWidth } = side
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDE_STORAGE_KEY, JSON.stringify(side))
|
||||||
|
} catch {
|
||||||
|
// 持久化失败(隐私模式等)不影响功能
|
||||||
|
}
|
||||||
|
}, [side])
|
||||||
|
|
||||||
|
const openSideTab = useCallback((type: SideTabType): void => {
|
||||||
|
setSide((s) => ({
|
||||||
|
...s,
|
||||||
|
open: true,
|
||||||
|
tabs: s.tabs.includes(type) ? s.tabs : [...s.tabs, type],
|
||||||
|
active: type
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const closeSideTab = useCallback((type: SideTabType): void => {
|
||||||
|
setSide((s) => {
|
||||||
|
const tabs = s.tabs.filter((t) => t !== type)
|
||||||
|
return { ...s, tabs, active: s.active === type ? (tabs[tabs.length - 1] ?? null) : s.active }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleSide = useCallback((): void => {
|
||||||
|
setSide((s) => ({ ...s, open: !s.open }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSideWidth = useCallback((width: number): void => {
|
||||||
|
setSide((s) => ({ ...s, width }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setSideActive = useCallback((type: SideTabType): void => {
|
||||||
|
setSide((s) => ({ ...s, active: type }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Agent 首次创建受管浏览器页签时,自动打开侧边面板的浏览器标签
|
||||||
|
const browserTabCount = browser.state?.tabs.length ?? 0
|
||||||
|
const prevBrowserTabCount = useRef(0)
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevBrowserTabCount.current
|
||||||
|
prevBrowserTabCount.current = browserTabCount
|
||||||
|
if (browserTabCount > 0 && prev === 0) openSideTab('browser')
|
||||||
|
}, [browserTabCount, openSideTab])
|
||||||
|
|
||||||
// 启动时应用持久化的主题色
|
// 启动时应用持久化的主题色
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,11 +185,7 @@ export default function App(): JSX.Element {
|
|||||||
onOpenWorkspace={openWorkspace}
|
onOpenWorkspace={openWorkspace}
|
||||||
/>
|
/>
|
||||||
<main className="relative flex min-w-0 flex-1 flex-col">
|
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||||
<TitleBar
|
<TitleBar title={sessionTitle} panelOpen={sideOpen} onTogglePanel={toggleSide} />
|
||||||
title={sessionTitle}
|
|
||||||
browserOpen={browserOpen}
|
|
||||||
onToggleBrowser={() => setBrowserOpen((open) => !open)}
|
|
||||||
/>
|
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||||
<ChatView
|
<ChatView
|
||||||
@@ -143,20 +224,32 @@ export default function App(): JSX.Element {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{browserOpen && (
|
{sideOpen && (
|
||||||
<BrowserPanel
|
<SidePanel
|
||||||
state={browser.state}
|
tabs={sideTabs}
|
||||||
suspended={settingsOpen || searchOpen}
|
active={activeSideTab}
|
||||||
onNavigate={browser.navigate}
|
width={sideWidth}
|
||||||
onGoBack={browser.goBack}
|
onWidthChange={setSideWidth}
|
||||||
onGoForward={browser.goForward}
|
onSelectTab={setSideActive}
|
||||||
onReload={browser.reload}
|
onCloseTab={closeSideTab}
|
||||||
onNewTab={browser.newTab}
|
onOpenTab={openSideTab}
|
||||||
onSelectTab={browser.selectTab}
|
onCollapse={toggleSide}
|
||||||
onCloseTab={browser.closeTab}
|
browser={
|
||||||
onAcknowledgeRisk={browser.acknowledgeRisk}
|
<BrowserPanel
|
||||||
onDetectSources={browser.detectSources}
|
state={browser.state}
|
||||||
onImportProfile={browser.importProfile}
|
suspended={settingsOpen || searchOpen}
|
||||||
|
onNavigate={browser.navigate}
|
||||||
|
onGoBack={browser.goBack}
|
||||||
|
onGoForward={browser.goForward}
|
||||||
|
onReload={browser.reload}
|
||||||
|
onNewTab={browser.newTab}
|
||||||
|
onSelectTab={browser.selectTab}
|
||||||
|
onCloseTab={browser.closeTab}
|
||||||
|
onAcknowledgeRisk={browser.acknowledgeRisk}
|
||||||
|
onDetectSources={browser.detectSources}
|
||||||
|
onImportProfile={browser.importProfile}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { useEffect, useRef, useState, type ComponentType, type JSX, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
LuChevronDown,
|
||||||
|
LuFileDiff,
|
||||||
|
LuGlobe,
|
||||||
|
LuMessagesSquare,
|
||||||
|
LuPlus,
|
||||||
|
LuSquareTerminal,
|
||||||
|
LuX
|
||||||
|
} from 'react-icons/lu'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右侧通用侧边面板(ZCode 式标签页容器)。
|
||||||
|
* 面板本身只负责:标签栏(收起 / 标签 / “+”菜单)、空态「打开标签页」宫格、
|
||||||
|
* 左缘宽度拖拽;各标签的内容由外部传入(浏览器)或使用占位面板。
|
||||||
|
* 每种标签同时至多一个实例;浏览器标签承载原生 WebContentsView 的对齐容器。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SideTabType = 'assistant' | 'review' | 'terminal' | 'browser'
|
||||||
|
|
||||||
|
export const SIDE_TAB_ORDER: SideTabType[] = ['assistant', 'review', 'terminal', 'browser']
|
||||||
|
|
||||||
|
type TabIcon = ComponentType<{ size?: number; className?: string }>
|
||||||
|
|
||||||
|
export const SIDE_TAB_META: Record<SideTabType, { title: string; icon: TabIcon }> = {
|
||||||
|
assistant: { title: '辅助对话', icon: LuMessagesSquare },
|
||||||
|
review: { title: '审查', icon: LuFileDiff },
|
||||||
|
terminal: { title: '终端', icon: LuSquareTerminal },
|
||||||
|
browser: { title: '浏览器', icon: LuGlobe }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDER_DESCRIPTION: Record<Exclude<SideTabType, 'browser'>, string> = {
|
||||||
|
assistant: '在侧边面板中与 Agent 展开一路并行的小对话,不打断主会话(即将支持)。',
|
||||||
|
review: '查看本会话中 Agent 产生的文件改动与差异,并逐项确认(即将支持)。',
|
||||||
|
terminal: '在侧边面板中打开与当前工作区绑定的终端(即将支持)。'
|
||||||
|
}
|
||||||
|
|
||||||
|
const PANEL_MIN_WIDTH = 380
|
||||||
|
const PANEL_MAX_WIDTH = 760
|
||||||
|
|
||||||
|
interface SidePanelProps {
|
||||||
|
tabs: SideTabType[]
|
||||||
|
active: SideTabType | null
|
||||||
|
width: number
|
||||||
|
onWidthChange: (width: number) => void
|
||||||
|
onSelectTab: (type: SideTabType) => void
|
||||||
|
onCloseTab: (type: SideTabType) => void
|
||||||
|
onOpenTab: (type: SideTabType) => void
|
||||||
|
/** 收起整个面板(标签栏左端的 ˅ 按钮,与标题栏开关等效) */
|
||||||
|
onCollapse: () => void
|
||||||
|
/** 浏览器标签的内容 */
|
||||||
|
browser: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidePanel({
|
||||||
|
tabs,
|
||||||
|
active,
|
||||||
|
width,
|
||||||
|
onWidthChange,
|
||||||
|
onSelectTab,
|
||||||
|
onCloseTab,
|
||||||
|
onOpenTab,
|
||||||
|
onCollapse,
|
||||||
|
browser
|
||||||
|
}: SidePanelProps): JSX.Element {
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
|
||||||
|
|
||||||
|
// 面板宽度拖拽(左边缘把手)
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: PointerEvent): void => {
|
||||||
|
if (!dragRef.current) return
|
||||||
|
const delta = dragRef.current.startX - e.clientX
|
||||||
|
onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta)))
|
||||||
|
}
|
||||||
|
const onUp = (): void => {
|
||||||
|
dragRef.current = null
|
||||||
|
}
|
||||||
|
window.addEventListener('pointermove', onMove)
|
||||||
|
window.addEventListener('pointerup', onUp)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointermove', onMove)
|
||||||
|
window.removeEventListener('pointerup', onUp)
|
||||||
|
}
|
||||||
|
}, [onWidthChange])
|
||||||
|
|
||||||
|
const startResize = (e: React.PointerEvent): void => {
|
||||||
|
dragRef.current = { startX: e.clientX, startWidth: width }
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = active ?? tabs[tabs.length - 1] ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className="relative flex h-full shrink-0 flex-col border-l border-zinc-200 bg-zinc-50"
|
||||||
|
style={{ width }}
|
||||||
|
aria-label="侧边面板"
|
||||||
|
>
|
||||||
|
{/* 左边缘拖拽把手 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 z-10 w-1 cursor-col-resize hover:bg-accent/40"
|
||||||
|
onPointerDown={startResize}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tabs.length > 0 && (
|
||||||
|
<div className="flex h-9 shrink-0 items-center gap-1 border-b border-zinc-200 bg-white px-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="收起侧边面板"
|
||||||
|
title="收起侧边面板"
|
||||||
|
onClick={onCollapse}
|
||||||
|
className="flex size-7 shrink-0 items-center justify-center rounded text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600"
|
||||||
|
>
|
||||||
|
<LuChevronDown size={13} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{tabs.map((type) => {
|
||||||
|
const { title, icon: Icon } = SIDE_TAB_META[type]
|
||||||
|
const isActive = type === current
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectTab(type)}
|
||||||
|
className={`group flex h-7 max-w-44 min-w-20 shrink-0 items-center gap-1.5 rounded px-2 text-left text-[11.5px] transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-accent-tint text-accent-ink'
|
||||||
|
: 'text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={11} className="shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1 truncate">{title}</span>
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={`关闭${title}标签`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onCloseTab(type)
|
||||||
|
}}
|
||||||
|
className="hidden shrink-0 rounded p-0.5 hover:bg-zinc-200 group-hover:block"
|
||||||
|
>
|
||||||
|
<LuX size={10} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="打开新的标签"
|
||||||
|
title="打开新的标签"
|
||||||
|
onClick={() => setMenuOpen((v) => !v)}
|
||||||
|
className="flex size-7 shrink-0 items-center justify-center rounded text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600"
|
||||||
|
>
|
||||||
|
<LuPlus size={13} />
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute right-0 top-8 z-20 w-40 rounded-lg border border-zinc-200 bg-white py-1 shadow-xl shadow-zinc-300/40">
|
||||||
|
{SIDE_TAB_ORDER.map((type) => {
|
||||||
|
const { title, icon: Icon } = SIDE_TAB_META[type]
|
||||||
|
const opened = tabs.includes(type)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
disabled={opened}
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
onOpenTab(type)
|
||||||
|
}}
|
||||||
|
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-[12px] text-zinc-600 transition-colors hover:bg-zinc-100 disabled:cursor-default disabled:text-zinc-300 disabled:hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<Icon size={12} className="shrink-0" />
|
||||||
|
{title}
|
||||||
|
{opened && <span className="ml-auto text-[10px] text-zinc-300">已打开</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tabs.length === 0 ? (
|
||||||
|
<EmptyState onOpen={onOpenTab} />
|
||||||
|
) : (
|
||||||
|
<div className="relative min-h-0 flex-1">
|
||||||
|
{current === 'browser' ? (
|
||||||
|
browser
|
||||||
|
) : current ? (
|
||||||
|
<PlaceholderPanel
|
||||||
|
icon={SIDE_TAB_META[current].icon}
|
||||||
|
title={SIDE_TAB_META[current].title}
|
||||||
|
description={PLACEHOLDER_DESCRIPTION[current as Exclude<SideTabType, 'browser'>]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空态:无任何标签时的「打开标签页」宫格 */
|
||||||
|
function EmptyState({ onOpen }: { onOpen: (type: SideTabType) => void }): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-w-0 flex-1 flex-col items-center justify-center gap-6 px-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-[16px] font-medium text-zinc-700">打开标签页</h2>
|
||||||
|
<p className="mt-1.5 text-[12px] text-zinc-400">选择要在侧边面板中打开的标签。</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{SIDE_TAB_ORDER.map((type) => {
|
||||||
|
const { title, icon: Icon } = SIDE_TAB_META[type]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(type)}
|
||||||
|
className="flex h-[76px] w-[112px] flex-col items-center justify-center gap-2 rounded-xl border border-zinc-200 bg-white text-zinc-500 transition-colors hover:border-accent/40 hover:bg-accent-tint/50 hover:text-accent-ink"
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
<span className="text-[12px]">{title}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尚未落地的标签占位页 */
|
||||||
|
function PlaceholderPanel({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
icon: TabIcon
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
}): JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||||
|
<Icon size={28} className="text-zinc-300" />
|
||||||
|
<div className="text-[13px] font-medium text-zinc-600">{title}</div>
|
||||||
|
<p className="max-w-60 text-[11.5px] leading-relaxed text-zinc-400">{description}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, type JSX } from 'react'
|
import { useEffect, useState, type JSX } from 'react'
|
||||||
import { LuGlobe } from 'react-icons/lu'
|
import { LuPanelRight } from 'react-icons/lu'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
||||||
@@ -9,12 +9,12 @@ import { LuGlobe } from 'react-icons/lu'
|
|||||||
*/
|
*/
|
||||||
export function TitleBar({
|
export function TitleBar({
|
||||||
title,
|
title,
|
||||||
browserOpen,
|
panelOpen,
|
||||||
onToggleBrowser
|
onTogglePanel
|
||||||
}: {
|
}: {
|
||||||
title?: string
|
title?: string
|
||||||
browserOpen: boolean
|
panelOpen: boolean
|
||||||
onToggleBrowser: () => void
|
onTogglePanel: () => void
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const name = title?.trim()
|
const name = title?.trim()
|
||||||
const [maximized, setMaximized] = useState(false)
|
const [maximized, setMaximized] = useState(false)
|
||||||
@@ -27,14 +27,14 @@ export function TitleBar({
|
|||||||
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
|
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={browserOpen ? '关闭内置浏览器' : '打开内置浏览器'}
|
aria-label={panelOpen ? '关闭侧边面板' : '打开侧边面板'}
|
||||||
title="内置浏览器"
|
title="侧边面板"
|
||||||
onClick={onToggleBrowser}
|
onClick={onTogglePanel}
|
||||||
className={`flex h-7 w-[30px] items-center justify-center rounded transition-colors ${
|
className={`flex h-7 w-[30px] items-center justify-center rounded transition-colors ${
|
||||||
browserOpen ? 'bg-accent-tint text-accent-ink' : 'text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700'
|
panelOpen ? 'bg-accent-tint text-accent-ink' : 'text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<LuGlobe size={14} />
|
<LuPanelRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full shrink-0 [-webkit-app-region:no-drag]">
|
<div className="flex h-full shrink-0 [-webkit-app-region:no-drag]">
|
||||||
|
|||||||
@@ -14,17 +14,12 @@ import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared
|
|||||||
import { BrowserSlot } from './BrowserSlot'
|
import { BrowserSlot } from './BrowserSlot'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内置受管浏览器右侧面板。
|
* 内置受管浏览器的「chrome」部分(页签栏/地址栏/工具条/导入浮层)。
|
||||||
*
|
* 作为侧边面板的一个标签内容渲染;页面本体是主进程的原生 WebContentsView
|
||||||
* 页面本体是主进程的原生 WebContentsView(盖在 DOM 之上),面板 DOM 只负责
|
* (盖在 DOM 之上),BrowserSlot 量出页面区域矩形后由主进程对齐原生视图。
|
||||||
* 标签栏/地址栏/工具条的「chrome」部分;BrowserSlot 量出页面区域矩形后由主进程
|
* 任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。
|
||||||
* 对齐原生视图。任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const PANEL_DEFAULT_WIDTH = 480
|
|
||||||
const PANEL_MIN_WIDTH = 380
|
|
||||||
const PANEL_MAX_WIDTH = 760
|
|
||||||
|
|
||||||
interface BrowserPanelProps {
|
interface BrowserPanelProps {
|
||||||
state: BrowserUiState | null
|
state: BrowserUiState | null
|
||||||
/** 全屏浮层(设置/搜索)打开时暂停原生视图,避免其盖住浮层 */
|
/** 全屏浮层(设置/搜索)打开时暂停原生视图,避免其盖住浮层 */
|
||||||
@@ -43,10 +38,8 @@ interface BrowserPanelProps {
|
|||||||
|
|
||||||
export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
||||||
const { state, suspended } = props
|
const { state, suspended } = props
|
||||||
const [width, setWidth] = useState(PANEL_DEFAULT_WIDTH)
|
|
||||||
const [address, setAddress] = useState('')
|
const [address, setAddress] = useState('')
|
||||||
const [importOpen, setImportOpen] = useState(false)
|
const [importOpen, setImportOpen] = useState(false)
|
||||||
const dragState = useRef<{ startX: number; startWidth: number } | null>(null)
|
|
||||||
|
|
||||||
const activeTab = useMemo(
|
const activeTab = useMemo(
|
||||||
() => state?.tabs.find((t) => t.tabId === state.activeTabId) ?? null,
|
() => state?.tabs.find((t) => t.tabId === state.activeTabId) ?? null,
|
||||||
@@ -61,28 +54,6 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [activeTab?.url])
|
}, [activeTab?.url])
|
||||||
|
|
||||||
// 面板宽度拖拽(左边缘把手)
|
|
||||||
useEffect(() => {
|
|
||||||
const onMove = (e: PointerEvent): void => {
|
|
||||||
if (!dragState.current) return
|
|
||||||
const delta = dragState.current.startX - e.clientX
|
|
||||||
setWidth(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragState.current.startWidth + delta)))
|
|
||||||
}
|
|
||||||
const onUp = (): void => {
|
|
||||||
dragState.current = null
|
|
||||||
}
|
|
||||||
window.addEventListener('pointermove', onMove)
|
|
||||||
window.addEventListener('pointerup', onUp)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('pointermove', onMove)
|
|
||||||
window.removeEventListener('pointerup', onUp)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const startResize = (e: React.PointerEvent): void => {
|
|
||||||
dragState.current = { startX: e.clientX, startWidth: width }
|
|
||||||
}
|
|
||||||
|
|
||||||
const submitAddress = (): void => {
|
const submitAddress = (): void => {
|
||||||
const value = address.trim()
|
const value = address.trim()
|
||||||
if (value) props.onNavigate(value)
|
if (value) props.onNavigate(value)
|
||||||
@@ -93,20 +64,8 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
const showSlot = Boolean(activeTab) && !suspended && !importOpen
|
const showSlot = Boolean(activeTab) && !suspended && !importOpen
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<div className="flex h-full min-w-0 flex-1 flex-col bg-zinc-50" aria-label="内置浏览器">
|
||||||
className="relative flex h-full shrink-0 flex-col border-l border-zinc-200 bg-zinc-50"
|
{/* 页签栏 */}
|
||||||
style={{ width }}
|
|
||||||
aria-label="内置浏览器"
|
|
||||||
>
|
|
||||||
{/* 左边缘拖拽把手 */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 left-0 z-10 w-1 cursor-col-resize hover:bg-accent/40"
|
|
||||||
onPointerDown={startResize}
|
|
||||||
role="separator"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 标签栏 */}
|
|
||||||
<div className="flex h-9 shrink-0 items-center gap-1 overflow-x-auto border-b border-zinc-200 bg-white px-1.5">
|
<div className="flex h-9 shrink-0 items-center gap-1 overflow-x-auto border-b border-zinc-200 bg-white px-1.5">
|
||||||
{(state?.tabs ?? []).map((tab) => (
|
{(state?.tabs ?? []).map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -259,7 +218,7 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
|||||||
onImportProfile={props.onImportProfile}
|
onImportProfile={props.onImportProfile}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user