Lixen commited on
feat: Collapsible Navigator (#571)
Browse files- ui/app/(app)/page.tsx +45 -5
- ui/components/Navigator.tsx +1 -1
- ui/components/canvas/ToolRail.tsx +28 -1
- ui/lib/stores/editorUiStore.ts +7 -0
- ui/public/locales/en-US/translation.json +2 -0
- ui/public/locales/es-ES/translation.json +2 -0
- ui/public/locales/ja-JP/translation.json +2 -0
- ui/public/locales/ko-KR/translation.json +2 -0
- ui/public/locales/pt-BR/translation.json +2 -0
- ui/public/locales/ru-RU/translation.json +2 -0
- ui/public/locales/tr-TR/translation.json +2 -0
- ui/public/locales/zh-CN/translation.json +2 -0
- ui/public/locales/zh-TW/translation.json +3 -1
ui/app/(app)/page.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import {
|
|
|
|
| 4 |
|
| 5 |
import { ActivityBubble } from '@/components/ActivityBubble'
|
| 6 |
import { AppErrorBoundary } from '@/components/AppErrorBoundary'
|
|
@@ -11,14 +12,34 @@ import { Panels } from '@/components/Panels'
|
|
| 11 |
import { WelcomeScreen } from '@/components/WelcomeScreen'
|
| 12 |
import { useScene } from '@/hooks/useScene'
|
| 13 |
import { useGetMeta } from '@/lib/api/default/default'
|
|
|
|
|
|
|
| 14 |
|
| 15 |
const LAYOUT_ID = 'koharu-main-layout-v3'
|
| 16 |
|
| 17 |
export default function Page() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
| 19 |
id: LAYOUT_ID,
|
| 20 |
panelIds: ['left', 'center', 'right'],
|
| 21 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
const { data: meta } = useGetMeta({
|
| 23 |
query: {
|
| 24 |
retry: false,
|
|
@@ -26,7 +47,6 @@ export default function Page() {
|
|
| 26 |
staleTime: Infinity,
|
| 27 |
},
|
| 28 |
})
|
| 29 |
-
const hasProject = useScene().scene !== null
|
| 30 |
|
| 31 |
if (!meta) {
|
| 32 |
return <AppInitializationSkeleton />
|
|
@@ -46,10 +66,30 @@ export default function Page() {
|
|
| 46 |
onLayoutChanged={onLayoutChanged}
|
| 47 |
className='flex min-h-0 flex-1'
|
| 48 |
>
|
| 49 |
-
<Panel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
<Navigator />
|
| 51 |
</Panel>
|
| 52 |
-
<Separator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
<Panel id='center' minSize={480}>
|
| 54 |
<AppErrorBoundary>
|
| 55 |
<div className='flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden'>
|
|
@@ -58,7 +98,7 @@ export default function Page() {
|
|
| 58 |
</div>
|
| 59 |
</AppErrorBoundary>
|
| 60 |
</Panel>
|
| 61 |
-
<Separator className='w-
|
| 62 |
<Panel id='right' defaultSize={280} minSize={280} maxSize={400}>
|
| 63 |
<AppErrorBoundary>
|
| 64 |
<Panels />
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useEffect, useRef } from 'react'
|
| 4 |
+
import { Group, Panel, Separator, useDefaultLayout, type PanelImperativeHandle } from 'react-resizable-panels'
|
| 5 |
|
| 6 |
import { ActivityBubble } from '@/components/ActivityBubble'
|
| 7 |
import { AppErrorBoundary } from '@/components/AppErrorBoundary'
|
|
|
|
| 12 |
import { WelcomeScreen } from '@/components/WelcomeScreen'
|
| 13 |
import { useScene } from '@/hooks/useScene'
|
| 14 |
import { useGetMeta } from '@/lib/api/default/default'
|
| 15 |
+
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 16 |
+
import { cn } from '@/lib/utils'
|
| 17 |
|
| 18 |
const LAYOUT_ID = 'koharu-main-layout-v3'
|
| 19 |
|
| 20 |
export default function Page() {
|
| 21 |
+
const hasProject = useScene().scene !== null
|
| 22 |
+
const showNavigator = useEditorUiStore((s) => s.showNavigator)
|
| 23 |
+
const setShowNavigator = useEditorUiStore((s) => s.setShowNavigator)
|
| 24 |
+
const leftPanelRef = useRef<PanelImperativeHandle>(null)
|
| 25 |
+
|
| 26 |
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
| 27 |
id: LAYOUT_ID,
|
| 28 |
panelIds: ['left', 'center', 'right'],
|
| 29 |
})
|
| 30 |
+
|
| 31 |
+
// Sync store -> panel state
|
| 32 |
+
useEffect(() => {
|
| 33 |
+
const panel = leftPanelRef.current
|
| 34 |
+
if (!panel) return
|
| 35 |
+
|
| 36 |
+
if (showNavigator && panel.isCollapsed()) {
|
| 37 |
+
panel.expand()
|
| 38 |
+
} else if (!showNavigator && !panel.isCollapsed()) {
|
| 39 |
+
panel.collapse()
|
| 40 |
+
}
|
| 41 |
+
}, [showNavigator])
|
| 42 |
+
|
| 43 |
const { data: meta } = useGetMeta({
|
| 44 |
query: {
|
| 45 |
retry: false,
|
|
|
|
| 47 |
staleTime: Infinity,
|
| 48 |
},
|
| 49 |
})
|
|
|
|
| 50 |
|
| 51 |
if (!meta) {
|
| 52 |
return <AppInitializationSkeleton />
|
|
|
|
| 66 |
onLayoutChanged={onLayoutChanged}
|
| 67 |
className='flex min-h-0 flex-1'
|
| 68 |
>
|
| 69 |
+
<Panel
|
| 70 |
+
panelRef={leftPanelRef}
|
| 71 |
+
id='left'
|
| 72 |
+
defaultSize={160}
|
| 73 |
+
minSize={160}
|
| 74 |
+
maxSize={250}
|
| 75 |
+
collapsible
|
| 76 |
+
collapsedSize={0}
|
| 77 |
+
onResize={(size) => {
|
| 78 |
+
if (size.asPercentage === 0 && showNavigator) {
|
| 79 |
+
setShowNavigator(false)
|
| 80 |
+
} else if (size.asPercentage > 0 && !showNavigator) {
|
| 81 |
+
setShowNavigator(true)
|
| 82 |
+
}
|
| 83 |
+
}}
|
| 84 |
+
>
|
| 85 |
<Navigator />
|
| 86 |
</Panel>
|
| 87 |
+
<Separator
|
| 88 |
+
className={cn(
|
| 89 |
+
'w-px bg-border transition-colors hover:bg-border',
|
| 90 |
+
!showNavigator && 'hidden',
|
| 91 |
+
)}
|
| 92 |
+
/>
|
| 93 |
<Panel id='center' minSize={480}>
|
| 94 |
<AppErrorBoundary>
|
| 95 |
<div className='flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden'>
|
|
|
|
| 98 |
</div>
|
| 99 |
</AppErrorBoundary>
|
| 100 |
</Panel>
|
| 101 |
+
<Separator className='w-px bg-border transition-colors hover:bg-border' />
|
| 102 |
<Panel id='right' defaultSize={280} minSize={280} maxSize={400}>
|
| 103 |
<AppErrorBoundary>
|
| 104 |
<Panels />
|
ui/components/Navigator.tsx
CHANGED
|
@@ -41,7 +41,7 @@ export function Navigator() {
|
|
| 41 |
<div
|
| 42 |
data-testid='navigator-panel'
|
| 43 |
data-total-pages={totalPages}
|
| 44 |
-
className='flex h-full min-h-0 w-full flex-col
|
| 45 |
>
|
| 46 |
<div className='flex items-center justify-between border-b border-border px-2 py-1.5'>
|
| 47 |
<div>
|
|
|
|
| 41 |
<div
|
| 42 |
data-testid='navigator-panel'
|
| 43 |
data-total-pages={totalPages}
|
| 44 |
+
className='flex h-full min-h-0 w-full flex-col bg-muted/50'
|
| 45 |
>
|
| 46 |
<div className='flex items-center justify-between border-b border-border px-2 py-1.5'>
|
| 47 |
<div>
|
ui/components/canvas/ToolRail.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { MousePointer, VectorSquare, Brush, Bandage, Eraser } from 'lucide-react'
|
| 4 |
import type { ComponentType } from 'react'
|
| 5 |
import { useTranslation } from 'react-i18next'
|
| 6 |
|
|
@@ -53,11 +53,38 @@ const MODES: ModeDefinition[] = [
|
|
| 53 |
export function ToolRail() {
|
| 54 |
const mode = useEditorUiStore((state) => state.mode)
|
| 55 |
const setMode = useEditorUiStore((state) => state.setMode)
|
|
|
|
|
|
|
| 56 |
const shortcuts = usePreferencesStore((state) => state.shortcuts)
|
| 57 |
const { t } = useTranslation()
|
| 58 |
|
| 59 |
return (
|
| 60 |
<div className='flex w-11 flex-col border-r border-border bg-card'>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<div className='flex flex-1 flex-col items-center gap-1 py-2'>
|
| 62 |
{MODES.map((item) => {
|
| 63 |
const label = t(item.labelKey)
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { MousePointer, VectorSquare, Brush, Bandage, Eraser, PanelLeft } from 'lucide-react'
|
| 4 |
import type { ComponentType } from 'react'
|
| 5 |
import { useTranslation } from 'react-i18next'
|
| 6 |
|
|
|
|
| 53 |
export function ToolRail() {
|
| 54 |
const mode = useEditorUiStore((state) => state.mode)
|
| 55 |
const setMode = useEditorUiStore((state) => state.setMode)
|
| 56 |
+
const showNavigator = useEditorUiStore((state) => state.showNavigator)
|
| 57 |
+
const setShowNavigator = useEditorUiStore((state) => state.setShowNavigator)
|
| 58 |
const shortcuts = usePreferencesStore((state) => state.shortcuts)
|
| 59 |
const { t } = useTranslation()
|
| 60 |
|
| 61 |
return (
|
| 62 |
<div className='flex w-11 flex-col border-r border-border bg-card'>
|
| 63 |
+
{/* Navigator Toggle Section - Height matches Navigator Header (py-1.5 + 2 lines of text-xs = 44px) */}
|
| 64 |
+
<div className='flex h-[44px] shrink-0 items-center justify-center'>
|
| 65 |
+
<Tooltip>
|
| 66 |
+
<TooltipTrigger asChild>
|
| 67 |
+
<Button
|
| 68 |
+
variant='ghost'
|
| 69 |
+
size='icon-sm'
|
| 70 |
+
data-testid='tool-navigator-toggle'
|
| 71 |
+
data-active={showNavigator}
|
| 72 |
+
onClick={() => setShowNavigator(!showNavigator)}
|
| 73 |
+
className='border border-transparent text-muted-foreground data-[active=true]:text-primary'
|
| 74 |
+
aria-label={showNavigator ? t('navigator.hide') : t('navigator.show')}
|
| 75 |
+
aria-pressed={showNavigator}
|
| 76 |
+
>
|
| 77 |
+
<PanelLeft className='h-4 w-4' />
|
| 78 |
+
</Button>
|
| 79 |
+
</TooltipTrigger>
|
| 80 |
+
<TooltipContent side='right' sideOffset={8}>
|
| 81 |
+
{showNavigator ? t('navigator.hide') : t('navigator.show')}
|
| 82 |
+
</TooltipContent>
|
| 83 |
+
</Tooltip>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div className='h-px w-full bg-border' />
|
| 87 |
+
|
| 88 |
<div className='flex flex-1 flex-col items-center gap-1 py-2'>
|
| 89 |
{MODES.map((item) => {
|
| 90 |
const label = t(item.labelKey)
|
ui/lib/stores/editorUiStore.ts
CHANGED
|
@@ -64,6 +64,10 @@ type EditorUiState = {
|
|
| 64 |
error?: { id: number; message: string }
|
| 65 |
showError: (message: string) => void
|
| 66 |
clearError: () => void
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
const initialState = {
|
|
@@ -80,6 +84,7 @@ const initialState = {
|
|
| 80 |
selectedTarget: undefined as LlmTarget | undefined,
|
| 81 |
selectedLanguage: undefined as string | undefined,
|
| 82 |
error: undefined as { id: number; message: string } | undefined,
|
|
|
|
| 83 |
}
|
| 84 |
|
| 85 |
export const useEditorUiStore = create<EditorUiState>((set) => ({
|
|
@@ -133,4 +138,6 @@ export const useEditorUiStore = create<EditorUiState>((set) => ({
|
|
| 133 |
clearDismissTimer()
|
| 134 |
set({ error: undefined })
|
| 135 |
},
|
|
|
|
|
|
|
| 136 |
}))
|
|
|
|
| 64 |
error?: { id: number; message: string }
|
| 65 |
showError: (message: string) => void
|
| 66 |
clearError: () => void
|
| 67 |
+
|
| 68 |
+
// page navigator panel
|
| 69 |
+
showNavigator: boolean
|
| 70 |
+
setShowNavigator: (show: boolean) => void
|
| 71 |
}
|
| 72 |
|
| 73 |
const initialState = {
|
|
|
|
| 84 |
selectedTarget: undefined as LlmTarget | undefined,
|
| 85 |
selectedLanguage: undefined as string | undefined,
|
| 86 |
error: undefined as { id: number; message: string } | undefined,
|
| 87 |
+
showNavigator: true,
|
| 88 |
}
|
| 89 |
|
| 90 |
export const useEditorUiStore = create<EditorUiState>((set) => ({
|
|
|
|
| 138 |
clearDismissTimer()
|
| 139 |
set({ error: undefined })
|
| 140 |
},
|
| 141 |
+
|
| 142 |
+
setShowNavigator: (show) => set({ showNavigator: show }),
|
| 143 |
}))
|
ui/public/locales/en-US/translation.json
CHANGED
|
@@ -84,6 +84,8 @@
|
|
| 84 |
},
|
| 85 |
"navigator": {
|
| 86 |
"title": "Navigator",
|
|
|
|
|
|
|
| 87 |
"pages_one": "{{count}} page",
|
| 88 |
"pages_other": "{{count}} pages",
|
| 89 |
"empty": "No documents",
|
|
|
|
| 84 |
},
|
| 85 |
"navigator": {
|
| 86 |
"title": "Navigator",
|
| 87 |
+
"show": "Show Navigator",
|
| 88 |
+
"hide": "Hide Navigator",
|
| 89 |
"pages_one": "{{count}} page",
|
| 90 |
"pages_other": "{{count}} pages",
|
| 91 |
"empty": "No documents",
|
ui/public/locales/es-ES/translation.json
CHANGED
|
@@ -68,6 +68,8 @@
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "Navegador",
|
|
|
|
|
|
|
| 71 |
"pages_one": "{{count}} página",
|
| 72 |
"pages_other": "{{count}} páginas",
|
| 73 |
"empty": "No hay documentos",
|
|
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "Navegador",
|
| 71 |
+
"show": "Mostrar navegador",
|
| 72 |
+
"hide": "Ocultar navegador",
|
| 73 |
"pages_one": "{{count}} página",
|
| 74 |
"pages_other": "{{count}} páginas",
|
| 75 |
"empty": "No hay documentos",
|
ui/public/locales/ja-JP/translation.json
CHANGED
|
@@ -68,6 +68,8 @@
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "ナビゲーター",
|
|
|
|
|
|
|
| 71 |
"pages_one": "{{count}} ページ",
|
| 72 |
"pages_other": "{{count}} ページ",
|
| 73 |
"empty": "ドキュメントがありません",
|
|
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "ナビゲーター",
|
| 71 |
+
"show": "ナビゲーターを表示",
|
| 72 |
+
"hide": "ナビゲーターを非表示",
|
| 73 |
"pages_one": "{{count}} ページ",
|
| 74 |
"pages_other": "{{count}} ページ",
|
| 75 |
"empty": "ドキュメントがありません",
|
ui/public/locales/ko-KR/translation.json
CHANGED
|
@@ -68,6 +68,8 @@
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "내비게이터",
|
|
|
|
|
|
|
| 71 |
"pages_one": "{{count}} 페이지",
|
| 72 |
"pages_other": "{{count}} 페이지",
|
| 73 |
"empty": "문서 없음",
|
|
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "내비게이터",
|
| 71 |
+
"show": "내비게이터 표시",
|
| 72 |
+
"hide": "내비게이터 숨기기",
|
| 73 |
"pages_one": "{{count}} 페이지",
|
| 74 |
"pages_other": "{{count}} 페이지",
|
| 75 |
"empty": "문서 없음",
|
ui/public/locales/pt-BR/translation.json
CHANGED
|
@@ -69,6 +69,8 @@
|
|
| 69 |
},
|
| 70 |
"navigator": {
|
| 71 |
"title": "Navegador",
|
|
|
|
|
|
|
| 72 |
"pages_one": "{{count}} página",
|
| 73 |
"pages_other": "{{count}} páginas",
|
| 74 |
"empty": "Nenhum documento",
|
|
|
|
| 69 |
},
|
| 70 |
"navigator": {
|
| 71 |
"title": "Navegador",
|
| 72 |
+
"show": "Mostrar navegador",
|
| 73 |
+
"hide": "Ocultar navegador",
|
| 74 |
"pages_one": "{{count}} página",
|
| 75 |
"pages_other": "{{count}} páginas",
|
| 76 |
"empty": "Nenhum documento",
|
ui/public/locales/ru-RU/translation.json
CHANGED
|
@@ -68,6 +68,8 @@
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "Навигатор",
|
|
|
|
|
|
|
| 71 |
"pages_one": "{{count}} стр.",
|
| 72 |
"pages_other": "{{count}} стр.",
|
| 73 |
"empty": "Нет документов",
|
|
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "Навигатор",
|
| 71 |
+
"show": "Показать навигатор",
|
| 72 |
+
"hide": "Скрыть навигатор",
|
| 73 |
"pages_one": "{{count}} стр.",
|
| 74 |
"pages_other": "{{count}} стр.",
|
| 75 |
"empty": "Нет документов",
|
ui/public/locales/tr-TR/translation.json
CHANGED
|
@@ -68,6 +68,8 @@
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "Gezgin",
|
|
|
|
|
|
|
| 71 |
"pages_one": "{{count}} sayfa",
|
| 72 |
"pages_other": "{{count}} sayfa",
|
| 73 |
"empty": "Belge yok",
|
|
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "Gezgin",
|
| 71 |
+
"show": "Gezgini Göster",
|
| 72 |
+
"hide": "Gezgini Gizle",
|
| 73 |
"pages_one": "{{count}} sayfa",
|
| 74 |
"pages_other": "{{count}} sayfa",
|
| 75 |
"empty": "Belge yok",
|
ui/public/locales/zh-CN/translation.json
CHANGED
|
@@ -68,6 +68,8 @@
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "导航",
|
|
|
|
|
|
|
| 71 |
"pages_one": "{{count}} 页",
|
| 72 |
"pages_other": "{{count}} 页",
|
| 73 |
"empty": "没有文档",
|
|
|
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
"title": "导航",
|
| 71 |
+
"show": "显示导航器",
|
| 72 |
+
"hide": "隐藏导航器",
|
| 73 |
"pages_one": "{{count}} 页",
|
| 74 |
"pages_other": "{{count}} 页",
|
| 75 |
"empty": "没有文档",
|
ui/public/locales/zh-TW/translation.json
CHANGED
|
@@ -67,7 +67,9 @@
|
|
| 67 |
"cancelling": "停止中..."
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
-
"title": "導
|
|
|
|
|
|
|
| 71 |
"pages_one": "{{count}} 頁",
|
| 72 |
"pages_other": "{{count}} 頁",
|
| 73 |
"empty": "沒有文件",
|
|
|
|
| 67 |
"cancelling": "停止中..."
|
| 68 |
},
|
| 69 |
"navigator": {
|
| 70 |
+
"title": "導航",
|
| 71 |
+
"show": "顯示導航器",
|
| 72 |
+
"hide": "隱藏導航器",
|
| 73 |
"pages_one": "{{count}} 頁",
|
| 74 |
"pages_other": "{{count}} 頁",
|
| 75 |
"empty": "沒有文件",
|