Lixen commited on
Commit
a7fe20c
·
unverified ·
1 Parent(s): c6d1333

feat: Collapsible Navigator (#571)

Browse files
ui/app/(app)/page.tsx CHANGED
@@ -1,6 +1,7 @@
1
  'use client'
2
 
3
- import { Group, Panel, Separator, useDefaultLayout } from 'react-resizable-panels'
 
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 id='left' defaultSize={160} minSize={160} maxSize={250}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  <Navigator />
51
  </Panel>
52
- <Separator className='w-1 bg-border/40 transition-colors hover:bg-border' />
 
 
 
 
 
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-1 bg-border/40 transition-colors hover:bg-border' />
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 border-r bg-muted/50'
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": "沒有文件",