2
2
// SPDX-License-Identifier: Apache-2.0
3
3
4
4
import logoUrl from "@/app/asset/logo.svg?url" ;
5
- import { atoms , replaceBlock } from "@/app/store/global" ;
5
+ import { atoms , globalStore , replaceBlock } from "@/app/store/global" ;
6
+ import { checkKeyPressed , keydownWrapper } from "@/util/keyutil" ;
6
7
import { isBlank , makeIconClass } from "@/util/util" ;
7
8
import clsx from "clsx" ;
8
- import { atom , useAtomValue } from "jotai" ;
9
- import React , { useCallback , useEffect , useLayoutEffect , useRef , useState } from "react" ;
9
+ import { atom , useAtom , useAtomValue } from "jotai" ;
10
+ import React , { useEffect , useLayoutEffect , useRef } from "react" ;
10
11
11
12
function sortByDisplayOrder ( wmap : { [ key : string ] : WidgetConfigType } | null | undefined ) : WidgetConfigType [ ] {
12
13
if ( ! wmap ) return [ ] ;
@@ -15,13 +16,34 @@ function sortByDisplayOrder(wmap: { [key: string]: WidgetConfigType } | null | u
15
16
return wlist ;
16
17
}
17
18
19
+ type GridLayoutType = { columns : number ; tileWidth : number ; tileHeight : number ; showLabel : boolean } ;
20
+
18
21
export class LauncherViewModel implements ViewModel {
22
+ blockId : string ;
19
23
viewType = "launcher" ;
20
24
viewIcon = atom ( "shapes" ) ;
21
25
viewName = atom ( "Widget Launcher" ) ;
22
26
viewComponent = LauncherView ;
23
27
noHeader = atom ( true ) ;
24
28
inputRef = { current : null } as React . RefObject < HTMLInputElement > ;
29
+ searchTerm = atom ( "" ) ;
30
+ selectedIndex = atom ( 0 ) ;
31
+ containerSize = atom ( { width : 0 , height : 0 } ) ;
32
+ gridLayout : GridLayoutType = null ;
33
+
34
+ constructor ( blockId : string ) {
35
+ this . blockId = blockId ;
36
+ }
37
+
38
+ filteredWidgetsAtom = atom ( ( get ) => {
39
+ const searchTerm = get ( this . searchTerm ) ;
40
+ const widgets = sortByDisplayOrder ( get ( atoms . fullConfigAtom ) ?. widgets || { } ) ;
41
+ return widgets . filter (
42
+ ( widget ) =>
43
+ ! widget [ "display:hidden" ] &&
44
+ ( ! searchTerm || widget . label ?. toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) ) )
45
+ ) ;
46
+ } ) ;
25
47
26
48
giveFocus ( ) : boolean {
27
49
if ( this . inputRef . current ) {
@@ -30,26 +52,80 @@ export class LauncherViewModel implements ViewModel {
30
52
}
31
53
return false ;
32
54
}
55
+
56
+ keyDownHandler ( e : WaveKeyboardEvent ) : boolean {
57
+ if ( this . gridLayout == null ) {
58
+ return ;
59
+ }
60
+ const gridLayout = this . gridLayout ;
61
+ const filteredWidgets = globalStore . get ( this . filteredWidgetsAtom ) ;
62
+ const selectedIndex = globalStore . get ( this . selectedIndex ) ;
63
+ const rows = Math . ceil ( filteredWidgets . length / gridLayout . columns ) ;
64
+ const currentRow = Math . floor ( selectedIndex / gridLayout . columns ) ;
65
+ const currentCol = selectedIndex % gridLayout . columns ;
66
+ console . log ( "keydown" , e ) ;
67
+ if ( checkKeyPressed ( e , "ArrowUp" ) ) {
68
+ if ( currentRow > 0 ) {
69
+ const newIndex = selectedIndex - gridLayout . columns ;
70
+ if ( newIndex >= 0 ) {
71
+ globalStore . set ( this . selectedIndex , newIndex ) ;
72
+ }
73
+ }
74
+ return true ;
75
+ }
76
+ if ( checkKeyPressed ( e , "ArrowDown" ) ) {
77
+ if ( currentRow < rows - 1 ) {
78
+ const newIndex = selectedIndex + gridLayout . columns ;
79
+ if ( newIndex < filteredWidgets . length ) {
80
+ globalStore . set ( this . selectedIndex , newIndex ) ;
81
+ }
82
+ }
83
+ return true ;
84
+ }
85
+ if ( checkKeyPressed ( e , "ArrowLeft" ) ) {
86
+ if ( currentCol > 0 ) {
87
+ globalStore . set ( this . selectedIndex , selectedIndex - 1 ) ;
88
+ }
89
+ return true ;
90
+ }
91
+ if ( checkKeyPressed ( e , "ArrowRight" ) ) {
92
+ if ( currentCol < gridLayout . columns - 1 && selectedIndex + 1 < filteredWidgets . length ) {
93
+ globalStore . set ( this . selectedIndex , selectedIndex + 1 ) ;
94
+ }
95
+ return true ;
96
+ }
97
+ if ( checkKeyPressed ( e , "Enter" ) ) {
98
+ if ( filteredWidgets [ selectedIndex ] ) {
99
+ this . handleWidgetSelect ( filteredWidgets [ selectedIndex ] ) ;
100
+ }
101
+ return true ;
102
+ }
103
+ if ( checkKeyPressed ( e , "Escape" ) ) {
104
+ globalStore . set ( this . searchTerm , "" ) ;
105
+ globalStore . set ( this . selectedIndex , 0 ) ;
106
+ return true ;
107
+ }
108
+ return false ;
109
+ }
110
+
111
+ async handleWidgetSelect ( widget : WidgetConfigType ) {
112
+ try {
113
+ await replaceBlock ( this . blockId , widget . blockdef ) ;
114
+ } catch ( error ) {
115
+ console . error ( "Error replacing block:" , error ) ;
116
+ }
117
+ }
33
118
}
34
119
35
120
const LauncherView : React . FC < ViewComponentProps < LauncherViewModel > > = ( { blockId, model } ) => {
36
- const fullConfig = useAtomValue ( atoms . fullConfigAtom ) ;
37
- const widgetMap = fullConfig ?. widgets || { } ;
38
- const widgets = sortByDisplayOrder ( widgetMap ) ;
39
-
40
121
// Search and selection state
41
- const [ searchTerm , setSearchTerm ] = useState ( "" ) ;
42
- const [ selectedIndex , setSelectedIndex ] = useState ( 0 ) ;
43
-
44
- // Filter widgets based on search term
45
- const filteredWidgets = widgets . filter (
46
- ( widget ) =>
47
- ! widget [ "display:hidden" ] && ( ! searchTerm || widget . label ?. toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) ) )
48
- ) ;
122
+ const [ searchTerm , setSearchTerm ] = useAtom ( model . searchTerm ) ;
123
+ const [ selectedIndex , setSelectedIndex ] = useAtom ( model . selectedIndex ) ;
124
+ const filteredWidgets = useAtomValue ( model . filteredWidgetsAtom ) ;
49
125
50
126
// Container measurement
51
127
const containerRef = useRef < HTMLDivElement > ( null ) ;
52
- const [ containerSize , setContainerSize ] = useState ( { width : 0 , height : 0 } ) ;
128
+ const [ containerSize , setContainerSize ] = useAtom ( model . containerSize ) ;
53
129
54
130
useLayoutEffect ( ( ) => {
55
131
if ( ! containerRef . current ) return ;
@@ -79,7 +155,7 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
79
155
const availableHeight = containerSize . height - ( showLogo ? logoWidth + MARGIN_BOTTOM : 0 ) ;
80
156
81
157
// Determine optimal grid layout
82
- const gridLayout = React . useMemo ( ( ) => {
158
+ const gridLayout : GridLayoutType = React . useMemo ( ( ) => {
83
159
if ( containerSize . width === 0 || availableHeight <= 0 || filteredWidgets . length === 0 ) {
84
160
return { columns : 1 , tileWidth : 90 , tileHeight : 90 , showLabel : true } ;
85
161
}
@@ -103,73 +179,11 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
103
179
}
104
180
return { columns : bestColumns , tileWidth : bestTileWidth , tileHeight : bestTileHeight , showLabel } ;
105
181
} , [ containerSize , availableHeight , filteredWidgets . length ] ) ;
182
+ model . gridLayout = gridLayout ;
106
183
107
184
const finalTileWidth = Math . min ( gridLayout . tileWidth , MAX_TILE_SIZE ) ;
108
185
const finalTileHeight = gridLayout . showLabel ? Math . min ( gridLayout . tileHeight , MAX_TILE_SIZE ) : finalTileWidth ;
109
186
110
- // Handle widget selection and launch
111
- const handleWidgetSelect = async ( widget : WidgetConfigType ) => {
112
- try {
113
- await replaceBlock ( blockId , widget . blockdef ) ;
114
- } catch ( error ) {
115
- console . error ( "Error replacing block:" , error ) ;
116
- }
117
- } ;
118
-
119
- // Keyboard navigation
120
- const handleKeyDown = useCallback (
121
- ( e : KeyboardEvent ) => {
122
- const rows = Math . ceil ( filteredWidgets . length / gridLayout . columns ) ;
123
- const currentRow = Math . floor ( selectedIndex / gridLayout . columns ) ;
124
- const currentCol = selectedIndex % gridLayout . columns ;
125
-
126
- switch ( e . key ) {
127
- case "ArrowUp" :
128
- e . preventDefault ( ) ;
129
- if ( currentRow > 0 ) {
130
- const newIndex = selectedIndex - gridLayout . columns ;
131
- if ( newIndex >= 0 ) setSelectedIndex ( newIndex ) ;
132
- }
133
- break ;
134
- case "ArrowDown" :
135
- e . preventDefault ( ) ;
136
- if ( currentRow < rows - 1 ) {
137
- const newIndex = selectedIndex + gridLayout . columns ;
138
- if ( newIndex < filteredWidgets . length ) setSelectedIndex ( newIndex ) ;
139
- }
140
- break ;
141
- case "ArrowLeft" :
142
- e . preventDefault ( ) ;
143
- if ( currentCol > 0 ) setSelectedIndex ( selectedIndex - 1 ) ;
144
- break ;
145
- case "ArrowRight" :
146
- e . preventDefault ( ) ;
147
- if ( currentCol < gridLayout . columns - 1 && selectedIndex + 1 < filteredWidgets . length ) {
148
- setSelectedIndex ( selectedIndex + 1 ) ;
149
- }
150
- break ;
151
- case "Enter" :
152
- e . preventDefault ( ) ;
153
- if ( filteredWidgets [ selectedIndex ] ) {
154
- handleWidgetSelect ( filteredWidgets [ selectedIndex ] ) ;
155
- }
156
- break ;
157
- case "Escape" :
158
- e . preventDefault ( ) ;
159
- setSearchTerm ( "" ) ;
160
- setSelectedIndex ( 0 ) ;
161
- break ;
162
- }
163
- } ,
164
- [ selectedIndex , gridLayout . columns , filteredWidgets . length , handleWidgetSelect ]
165
- ) ;
166
-
167
- // Set up keyboard listeners
168
- useEffect ( ( ) => {
169
- window . addEventListener ( "keydown" , handleKeyDown ) ;
170
- return ( ) => window . removeEventListener ( "keydown" , handleKeyDown ) ;
171
- } , [ handleKeyDown ] ) ;
172
-
173
187
// Reset selection when search term changes
174
188
useEffect ( ( ) => {
175
189
setSelectedIndex ( 0 ) ;
@@ -182,6 +196,7 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
182
196
ref = { model . inputRef }
183
197
type = "text"
184
198
value = { searchTerm }
199
+ onKeyDown = { keydownWrapper ( model . keyDownHandler . bind ( model ) ) }
185
200
onChange = { ( e ) => setSearchTerm ( e . target . value ) }
186
201
className = "sr-only"
187
202
aria-label = "Search widgets"
@@ -204,7 +219,7 @@ const LauncherView: React.FC<ViewComponentProps<LauncherViewModel>> = ({ blockId
204
219
{ filteredWidgets . map ( ( widget , index ) => (
205
220
< div
206
221
key = { index }
207
- onClick = { ( ) => handleWidgetSelect ( widget ) }
222
+ onClick = { ( ) => model . handleWidgetSelect ( widget ) }
208
223
title = { widget . description || widget . label }
209
224
className = { clsx (
210
225
"flex flex-col items-center justify-center cursor-pointer rounded-md p-2 text-center" ,
0 commit comments