@tooee/shell 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/CommandPaletteOverlay.d.ts +7 -0
- package/dist/CommandPaletteOverlay.d.ts.map +1 -0
- package/dist/CommandPaletteOverlay.js +29 -0
- package/dist/CommandPaletteOverlay.js.map +1 -0
- package/dist/ThemePickerOverlay.d.ts +6 -0
- package/dist/ThemePickerOverlay.d.ts.map +1 -0
- package/dist/ThemePickerOverlay.js +20 -0
- package/dist/ThemePickerOverlay.js.map +1 -0
- package/dist/command-palette.d.ts +9 -0
- package/dist/command-palette.d.ts.map +1 -0
- package/dist/command-palette.js +51 -0
- package/dist/command-palette.js.map +1 -0
- package/dist/commands.d.ts +18 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/commands.js +49 -0
- package/dist/commands.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/launch.d.ts +6 -0
- package/dist/launch.d.ts.map +1 -0
- package/dist/launch.js +12 -0
- package/dist/launch.js.map +1 -0
- package/dist/modal.d.ts +34 -0
- package/dist/modal.d.ts.map +1 -0
- package/dist/modal.js +440 -0
- package/dist/modal.js.map +1 -0
- package/dist/overlay.d.ts +11 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +145 -0
- package/dist/overlay.js.map +1 -0
- package/dist/provider.d.ts +11 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +13 -0
- package/dist/provider.js.map +1 -0
- package/dist/search.d.ts +2 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +20 -0
- package/dist/search.js.map +1 -0
- package/dist/theme-picker.d.ts +16 -0
- package/dist/theme-picker.d.ts.map +1 -0
- package/dist/theme-picker.js +48 -0
- package/dist/theme-picker.js.map +1 -0
- package/package.json +49 -0
- package/src/CommandPaletteOverlay.tsx +44 -0
- package/src/ThemePickerOverlay.tsx +28 -0
- package/src/command-palette.ts +73 -0
- package/src/commands.ts +60 -0
- package/src/index.ts +11 -0
- package/src/launch.tsx +15 -0
- package/src/modal.ts +504 -0
- package/src/overlay.tsx +220 -0
- package/src/provider.tsx +46 -0
- package/src/search.ts +18 -0
- package/src/theme-picker.ts +84 -0
package/src/modal.ts
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from "react"
|
|
2
|
+
import { useCommand, useMode, useSetMode, type Mode } from "@tooee/commands"
|
|
3
|
+
import { copyToClipboard } from "@tooee/clipboard"
|
|
4
|
+
import { useTerminalDimensions } from "@opentui/react"
|
|
5
|
+
import { findMatchingLines } from "./search.js"
|
|
6
|
+
|
|
7
|
+
export interface Position {
|
|
8
|
+
line: number
|
|
9
|
+
col: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ModalNavigationState {
|
|
13
|
+
mode: Mode
|
|
14
|
+
setMode: (mode: Mode) => void
|
|
15
|
+
scrollOffset: number
|
|
16
|
+
cursor: Position | null
|
|
17
|
+
selection: { start: Position; end: Position } | null
|
|
18
|
+
toggledIndices: Set<number>
|
|
19
|
+
searchQuery: string
|
|
20
|
+
searchActive: boolean
|
|
21
|
+
setSearchQuery: (query: string) => void
|
|
22
|
+
matchingLines: number[]
|
|
23
|
+
currentMatchIndex: number
|
|
24
|
+
submitSearch: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ModalNavigationOptions {
|
|
28
|
+
totalLines: number
|
|
29
|
+
viewportHeight?: number
|
|
30
|
+
getText?: () => string | undefined
|
|
31
|
+
blockCount?: number
|
|
32
|
+
blockLineMap?: number[]
|
|
33
|
+
/** Offset to subtract from search line numbers to get block indices (for when getText has different line structure than visual) */
|
|
34
|
+
searchLineOffset?: number
|
|
35
|
+
multiSelect?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useModalNavigationCommands(opts: ModalNavigationOptions): ModalNavigationState {
|
|
39
|
+
const { height: terminalHeight } = useTerminalDimensions()
|
|
40
|
+
const {
|
|
41
|
+
totalLines,
|
|
42
|
+
viewportHeight = terminalHeight - 2,
|
|
43
|
+
getText,
|
|
44
|
+
blockCount,
|
|
45
|
+
blockLineMap,
|
|
46
|
+
searchLineOffset = 0,
|
|
47
|
+
multiSelect = false,
|
|
48
|
+
} = opts
|
|
49
|
+
const mode = useMode()
|
|
50
|
+
const setMode = useSetMode()
|
|
51
|
+
|
|
52
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
53
|
+
const [cursor, setCursor] = useState<Position | null>(null)
|
|
54
|
+
const [selectionAnchor, setSelectionAnchor] = useState<Position | null>(null)
|
|
55
|
+
const [toggledIndices, setToggledIndices] = useState<Set<number>>(new Set())
|
|
56
|
+
const [searchQuery, setSearchQuery] = useState("")
|
|
57
|
+
const [searchActive, setSearchActive] = useState(false)
|
|
58
|
+
const [matchingLines, setMatchingLines] = useState<number[]>([])
|
|
59
|
+
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
|
|
60
|
+
const preSearchModeRef = useRef<Mode>("cursor")
|
|
61
|
+
|
|
62
|
+
// Incremental search: recompute matches when query changes while search is active
|
|
63
|
+
const searchQueryRef = useRef(searchQuery)
|
|
64
|
+
searchQueryRef.current = searchQuery
|
|
65
|
+
const matchingLinesRef = useRef(matchingLines)
|
|
66
|
+
matchingLinesRef.current = matchingLines
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!searchActive) return
|
|
70
|
+
const text = getText?.()
|
|
71
|
+
if (!text || !searchQuery) {
|
|
72
|
+
setMatchingLines([])
|
|
73
|
+
setCurrentMatchIndex(0)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
const matches = findMatchingLines(text, searchQuery)
|
|
77
|
+
setMatchingLines(matches)
|
|
78
|
+
setCurrentMatchIndex(0)
|
|
79
|
+
if (matches.length > 0) {
|
|
80
|
+
// Auto-jump to first match
|
|
81
|
+
const line = matches[0]
|
|
82
|
+
setScrollOffset((offset) => {
|
|
83
|
+
if (line < offset || line >= offset + viewportHeight) {
|
|
84
|
+
return Math.max(0, Math.min(line, Math.max(0, totalLines - viewportHeight)))
|
|
85
|
+
}
|
|
86
|
+
return offset
|
|
87
|
+
})
|
|
88
|
+
if (mode === "cursor" || mode === "select") {
|
|
89
|
+
setCursor((c) => (c ? { line, col: 0 } : c))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}, [searchQuery, searchActive]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
93
|
+
|
|
94
|
+
const isBlockMode = blockCount != null
|
|
95
|
+
const cursorMax = isBlockMode ? Math.max(0, blockCount - 1) : Math.max(0, totalLines - 1)
|
|
96
|
+
const maxScroll = Math.max(0, totalLines - viewportHeight)
|
|
97
|
+
const maxLine = Math.max(0, totalLines - 1)
|
|
98
|
+
|
|
99
|
+
const clampScroll = useCallback(
|
|
100
|
+
(value: number) => Math.max(0, Math.min(value, maxScroll)),
|
|
101
|
+
[maxScroll],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const _clampLine = useCallback(
|
|
105
|
+
(value: number) => Math.max(0, Math.min(value, maxLine)),
|
|
106
|
+
[maxLine],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const clampCursor = useCallback(
|
|
110
|
+
(value: number) => Math.max(0, Math.min(value, cursorMax)),
|
|
111
|
+
[cursorMax],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
setToggledIndices((prev) => {
|
|
116
|
+
const filtered = Array.from(prev).filter((index) => index <= cursorMax)
|
|
117
|
+
if (filtered.length === prev.size) return prev
|
|
118
|
+
return new Set(filtered)
|
|
119
|
+
})
|
|
120
|
+
}, [cursorMax])
|
|
121
|
+
|
|
122
|
+
// When entering cursor mode, initialize cursor
|
|
123
|
+
const prevMode = useRef<Mode | null>(null)
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (mode === "cursor" && prevMode.current !== "cursor" && prevMode.current !== "select") {
|
|
126
|
+
// If there's an active search match, start cursor there; otherwise start at scroll position
|
|
127
|
+
const matches = matchingLinesRef.current
|
|
128
|
+
if (matches.length > 0) {
|
|
129
|
+
const matchLine = matches[currentMatchIndex] ?? matches[0]
|
|
130
|
+
// In block mode, convert search line to block index using offset
|
|
131
|
+
if (isBlockMode) {
|
|
132
|
+
const blockIndex = Math.max(0, matchLine - searchLineOffset)
|
|
133
|
+
setCursor({ line: Math.min(blockIndex, cursorMax), col: 0 })
|
|
134
|
+
} else {
|
|
135
|
+
setCursor({ line: matchLine, col: 0 })
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
// No search match - start at top (block 0) or scroll position
|
|
139
|
+
setCursor({ line: isBlockMode ? 0 : scrollOffset, col: 0 })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (mode === "select" && prevMode.current === "cursor" && cursor) {
|
|
143
|
+
setSelectionAnchor({ ...cursor })
|
|
144
|
+
}
|
|
145
|
+
prevMode.current = mode
|
|
146
|
+
}, [mode]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
147
|
+
|
|
148
|
+
// Scroll to keep cursor visible
|
|
149
|
+
const scrollToCursor = useCallback(
|
|
150
|
+
(cursorIndex: number) => {
|
|
151
|
+
const line = isBlockMode && blockLineMap ? (blockLineMap[cursorIndex] ?? 0) : cursorIndex
|
|
152
|
+
setScrollOffset((offset) => {
|
|
153
|
+
if (line < offset) return line
|
|
154
|
+
if (line >= offset + viewportHeight) return clampScroll(line - viewportHeight + 1)
|
|
155
|
+
return offset
|
|
156
|
+
})
|
|
157
|
+
},
|
|
158
|
+
[viewportHeight, clampScroll, isBlockMode, blockLineMap],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
// === CURSOR MODE ===
|
|
162
|
+
|
|
163
|
+
useCommand({
|
|
164
|
+
id: "cursor-down",
|
|
165
|
+
title: "Cursor down",
|
|
166
|
+
hotkey: "j",
|
|
167
|
+
modes: ["cursor"],
|
|
168
|
+
handler: () => {
|
|
169
|
+
setCursor((c) => {
|
|
170
|
+
if (!c) return c
|
|
171
|
+
const next = clampCursor(c.line + 1)
|
|
172
|
+
scrollToCursor(next)
|
|
173
|
+
return { line: next, col: 0 }
|
|
174
|
+
})
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
useCommand({
|
|
179
|
+
id: "cursor-toggle",
|
|
180
|
+
title: "Toggle selection",
|
|
181
|
+
hotkey: "tab",
|
|
182
|
+
modes: ["cursor"],
|
|
183
|
+
when: () => multiSelect,
|
|
184
|
+
handler: () => {
|
|
185
|
+
setToggledIndices((prev) => {
|
|
186
|
+
if (!cursor) return prev
|
|
187
|
+
const next = new Set(prev)
|
|
188
|
+
const idx = cursor.line
|
|
189
|
+
if (next.has(idx)) {
|
|
190
|
+
next.delete(idx)
|
|
191
|
+
} else {
|
|
192
|
+
next.add(idx)
|
|
193
|
+
}
|
|
194
|
+
return next
|
|
195
|
+
})
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
useCommand({
|
|
200
|
+
id: "cursor-toggle-up",
|
|
201
|
+
title: "Toggle and move up",
|
|
202
|
+
hotkey: "shift+tab",
|
|
203
|
+
modes: ["cursor"],
|
|
204
|
+
when: () => multiSelect,
|
|
205
|
+
handler: () => {
|
|
206
|
+
setToggledIndices((prev) => {
|
|
207
|
+
if (!cursor) return prev
|
|
208
|
+
const next = new Set(prev)
|
|
209
|
+
const idx = cursor.line
|
|
210
|
+
if (next.has(idx)) {
|
|
211
|
+
next.delete(idx)
|
|
212
|
+
} else {
|
|
213
|
+
next.add(idx)
|
|
214
|
+
}
|
|
215
|
+
return next
|
|
216
|
+
})
|
|
217
|
+
setCursor((c) => {
|
|
218
|
+
if (!c) return c
|
|
219
|
+
const next = clampCursor(c.line - 1)
|
|
220
|
+
scrollToCursor(next)
|
|
221
|
+
return { line: next, col: 0 }
|
|
222
|
+
})
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
useCommand({
|
|
227
|
+
id: "cursor-up",
|
|
228
|
+
title: "Cursor up",
|
|
229
|
+
hotkey: "k",
|
|
230
|
+
modes: ["cursor"],
|
|
231
|
+
handler: () => {
|
|
232
|
+
setCursor((c) => {
|
|
233
|
+
if (!c) return c
|
|
234
|
+
const next = clampCursor(c.line - 1)
|
|
235
|
+
scrollToCursor(next)
|
|
236
|
+
return { line: next, col: 0 }
|
|
237
|
+
})
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
useCommand({
|
|
242
|
+
id: "cursor-half-down",
|
|
243
|
+
title: "Cursor half page down",
|
|
244
|
+
hotkey: "ctrl+d",
|
|
245
|
+
modes: ["cursor"],
|
|
246
|
+
handler: () => {
|
|
247
|
+
setCursor((c) => {
|
|
248
|
+
if (!c) return c
|
|
249
|
+
const step = isBlockMode ? Math.floor(cursorMax / 4) || 1 : Math.floor(viewportHeight / 2)
|
|
250
|
+
const next = clampCursor(c.line + step)
|
|
251
|
+
scrollToCursor(next)
|
|
252
|
+
return { line: next, col: 0 }
|
|
253
|
+
})
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
useCommand({
|
|
258
|
+
id: "cursor-half-up",
|
|
259
|
+
title: "Cursor half page up",
|
|
260
|
+
hotkey: "ctrl+u",
|
|
261
|
+
modes: ["cursor"],
|
|
262
|
+
handler: () => {
|
|
263
|
+
setCursor((c) => {
|
|
264
|
+
if (!c) return c
|
|
265
|
+
const step = isBlockMode ? Math.floor(cursorMax / 4) || 1 : Math.floor(viewportHeight / 2)
|
|
266
|
+
const next = clampCursor(c.line - step)
|
|
267
|
+
scrollToCursor(next)
|
|
268
|
+
return { line: next, col: 0 }
|
|
269
|
+
})
|
|
270
|
+
},
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
useCommand({
|
|
274
|
+
id: "cursor-top",
|
|
275
|
+
title: "Cursor to top",
|
|
276
|
+
hotkey: "g g",
|
|
277
|
+
modes: ["cursor"],
|
|
278
|
+
handler: () => {
|
|
279
|
+
setCursor({ line: 0, col: 0 })
|
|
280
|
+
setScrollOffset(0)
|
|
281
|
+
},
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
useCommand({
|
|
285
|
+
id: "cursor-bottom",
|
|
286
|
+
title: "Cursor to bottom",
|
|
287
|
+
hotkey: "shift+g",
|
|
288
|
+
modes: ["cursor"],
|
|
289
|
+
handler: () => {
|
|
290
|
+
setCursor({ line: cursorMax, col: 0 })
|
|
291
|
+
if (isBlockMode && blockLineMap) {
|
|
292
|
+
const line = blockLineMap[cursorMax] ?? 0
|
|
293
|
+
setScrollOffset(clampScroll(line))
|
|
294
|
+
} else {
|
|
295
|
+
setScrollOffset(maxScroll)
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
useCommand({
|
|
301
|
+
id: "cursor-search-start",
|
|
302
|
+
title: "Search",
|
|
303
|
+
hotkey: "/",
|
|
304
|
+
modes: ["cursor"],
|
|
305
|
+
handler: () => {
|
|
306
|
+
preSearchModeRef.current = mode
|
|
307
|
+
setSearchActive(true)
|
|
308
|
+
setSearchQuery("")
|
|
309
|
+
setMode("insert")
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
useCommand({
|
|
314
|
+
id: "cursor-search-next",
|
|
315
|
+
title: "Next match",
|
|
316
|
+
hotkey: "n",
|
|
317
|
+
modes: ["cursor"],
|
|
318
|
+
when: () => !searchActive,
|
|
319
|
+
handler: () => {
|
|
320
|
+
const matches = matchingLinesRef.current
|
|
321
|
+
if (matches.length === 0) return
|
|
322
|
+
setCurrentMatchIndex((idx) => {
|
|
323
|
+
const next = (idx + 1) % matches.length
|
|
324
|
+
const line = matches[next]
|
|
325
|
+
setCursor({ line, col: 0 })
|
|
326
|
+
scrollToCursor(line)
|
|
327
|
+
return next
|
|
328
|
+
})
|
|
329
|
+
},
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
useCommand({
|
|
333
|
+
id: "cursor-search-prev",
|
|
334
|
+
title: "Previous match",
|
|
335
|
+
hotkey: "shift+n",
|
|
336
|
+
modes: ["cursor"],
|
|
337
|
+
when: () => !searchActive,
|
|
338
|
+
handler: () => {
|
|
339
|
+
const matches = matchingLinesRef.current
|
|
340
|
+
if (matches.length === 0) return
|
|
341
|
+
setCurrentMatchIndex((idx) => {
|
|
342
|
+
const next = (idx - 1 + matches.length) % matches.length
|
|
343
|
+
const line = matches[next]
|
|
344
|
+
setCursor({ line, col: 0 })
|
|
345
|
+
scrollToCursor(line)
|
|
346
|
+
return next
|
|
347
|
+
})
|
|
348
|
+
},
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
// === SELECT MODE ===
|
|
352
|
+
|
|
353
|
+
useCommand({
|
|
354
|
+
id: "select-down",
|
|
355
|
+
title: "Extend selection down",
|
|
356
|
+
hotkey: "j",
|
|
357
|
+
modes: ["select"],
|
|
358
|
+
handler: () => {
|
|
359
|
+
setCursor((c) => {
|
|
360
|
+
if (!c) return c
|
|
361
|
+
const next = clampCursor(c.line + 1)
|
|
362
|
+
scrollToCursor(next)
|
|
363
|
+
return { line: next, col: 0 }
|
|
364
|
+
})
|
|
365
|
+
},
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
useCommand({
|
|
369
|
+
id: "select-up",
|
|
370
|
+
title: "Extend selection up",
|
|
371
|
+
hotkey: "k",
|
|
372
|
+
modes: ["select"],
|
|
373
|
+
handler: () => {
|
|
374
|
+
setCursor((c) => {
|
|
375
|
+
if (!c) return c
|
|
376
|
+
const next = clampCursor(c.line - 1)
|
|
377
|
+
scrollToCursor(next)
|
|
378
|
+
return { line: next, col: 0 }
|
|
379
|
+
})
|
|
380
|
+
},
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
useCommand({
|
|
384
|
+
id: "select-toggle",
|
|
385
|
+
title: "Toggle selection",
|
|
386
|
+
hotkey: "tab",
|
|
387
|
+
modes: ["select"],
|
|
388
|
+
when: () => multiSelect,
|
|
389
|
+
handler: () => {
|
|
390
|
+
setToggledIndices((prev) => {
|
|
391
|
+
if (!cursor) return prev
|
|
392
|
+
const next = new Set(prev)
|
|
393
|
+
const idx = cursor.line
|
|
394
|
+
if (next.has(idx)) {
|
|
395
|
+
next.delete(idx)
|
|
396
|
+
} else {
|
|
397
|
+
next.add(idx)
|
|
398
|
+
}
|
|
399
|
+
return next
|
|
400
|
+
})
|
|
401
|
+
},
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
useCommand({
|
|
405
|
+
id: "select-copy",
|
|
406
|
+
title: "Copy selection",
|
|
407
|
+
hotkey: "y",
|
|
408
|
+
modes: ["select"],
|
|
409
|
+
handler: () => {
|
|
410
|
+
if (!getText || !selectionAnchor || !cursor) return
|
|
411
|
+
const text = getText()
|
|
412
|
+
if (!text) return
|
|
413
|
+
|
|
414
|
+
if (isBlockMode && blockLineMap) {
|
|
415
|
+
// Block-based copy: use blockLineMap to find line ranges
|
|
416
|
+
const startBlock = Math.min(selectionAnchor.line, cursor.line)
|
|
417
|
+
const endBlock = Math.max(selectionAnchor.line, cursor.line)
|
|
418
|
+
const startLine = blockLineMap[startBlock] ?? 0
|
|
419
|
+
const endLine =
|
|
420
|
+
endBlock + 1 < blockLineMap.length
|
|
421
|
+
? (blockLineMap[endBlock + 1] ?? text.split("\n").length)
|
|
422
|
+
: text.split("\n").length
|
|
423
|
+
const lines = text.split("\n")
|
|
424
|
+
const selected = lines.slice(startLine, endLine).join("\n")
|
|
425
|
+
if (selected) {
|
|
426
|
+
void copyToClipboard(selected)
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
const lines = text.split("\n")
|
|
430
|
+
const startLine = Math.min(selectionAnchor.line, cursor.line)
|
|
431
|
+
const endLine = Math.max(selectionAnchor.line, cursor.line)
|
|
432
|
+
const selected = lines.slice(startLine, endLine + 1).join("\n")
|
|
433
|
+
if (selected) {
|
|
434
|
+
void copyToClipboard(selected)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
setMode("cursor")
|
|
438
|
+
},
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
useCommand({
|
|
442
|
+
id: "select-cancel",
|
|
443
|
+
title: "Cancel selection",
|
|
444
|
+
hotkey: "escape",
|
|
445
|
+
modes: ["select"],
|
|
446
|
+
handler: () => setMode("cursor"),
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// === MODE TRANSITIONS ===
|
|
450
|
+
|
|
451
|
+
useCommand({
|
|
452
|
+
id: "enter-select",
|
|
453
|
+
title: "Enter select mode",
|
|
454
|
+
hotkey: "v",
|
|
455
|
+
modes: ["cursor"],
|
|
456
|
+
handler: () => setMode("select"),
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// === SEARCH CANCEL (any mode) ===
|
|
460
|
+
|
|
461
|
+
useCommand({
|
|
462
|
+
id: "search-cancel",
|
|
463
|
+
title: "Cancel search",
|
|
464
|
+
hotkey: "escape",
|
|
465
|
+
modes: ["cursor", "select", "insert"],
|
|
466
|
+
when: () => searchActive,
|
|
467
|
+
handler: () => {
|
|
468
|
+
setSearchActive(false)
|
|
469
|
+
setSearchQuery("")
|
|
470
|
+
setMatchingLines([])
|
|
471
|
+
setCurrentMatchIndex(0)
|
|
472
|
+
setMode(preSearchModeRef.current)
|
|
473
|
+
},
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
// Compute selection from anchor + cursor
|
|
477
|
+
const selection =
|
|
478
|
+
selectionAnchor && cursor && mode === "select"
|
|
479
|
+
? {
|
|
480
|
+
start: selectionAnchor.line <= cursor.line ? selectionAnchor : cursor,
|
|
481
|
+
end: selectionAnchor.line <= cursor.line ? cursor : selectionAnchor,
|
|
482
|
+
}
|
|
483
|
+
: null
|
|
484
|
+
|
|
485
|
+
const submitSearch = useCallback(() => {
|
|
486
|
+
setSearchActive(false)
|
|
487
|
+
setMode(preSearchModeRef.current)
|
|
488
|
+
}, [setMode])
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
mode,
|
|
492
|
+
setMode,
|
|
493
|
+
scrollOffset,
|
|
494
|
+
cursor,
|
|
495
|
+
selection,
|
|
496
|
+
toggledIndices,
|
|
497
|
+
searchQuery,
|
|
498
|
+
searchActive,
|
|
499
|
+
setSearchQuery,
|
|
500
|
+
matchingLines,
|
|
501
|
+
currentMatchIndex,
|
|
502
|
+
submitSearch,
|
|
503
|
+
}
|
|
504
|
+
}
|
package/src/overlay.tsx
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useRef } from "react"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import {
|
|
4
|
+
OverlayControllerContext,
|
|
5
|
+
OverlayStateContext,
|
|
6
|
+
type OverlayId,
|
|
7
|
+
type OverlayCloseReason,
|
|
8
|
+
type OverlayOpenOptions,
|
|
9
|
+
type OverlayRenderer,
|
|
10
|
+
type OverlayHandle,
|
|
11
|
+
type OverlayController,
|
|
12
|
+
} from "@tooee/overlays"
|
|
13
|
+
import { useMode, useSetMode, useProvideCommandContext, useCommand } from "@tooee/commands"
|
|
14
|
+
|
|
15
|
+
declare module "@tooee/commands" {
|
|
16
|
+
interface CommandContext {
|
|
17
|
+
overlay: OverlayController
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface OverlayEntry {
|
|
22
|
+
id: OverlayId
|
|
23
|
+
render: OverlayRenderer<any>
|
|
24
|
+
payload: any
|
|
25
|
+
options: OverlayOpenOptions
|
|
26
|
+
prevMode: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function OverlayProvider({ children }: { children: ReactNode }) {
|
|
30
|
+
const [stack, setStack] = useState<OverlayEntry[]>([])
|
|
31
|
+
const stackRef = useRef(stack)
|
|
32
|
+
stackRef.current = stack
|
|
33
|
+
|
|
34
|
+
const mode = useMode()
|
|
35
|
+
const setMode = useSetMode()
|
|
36
|
+
const modeRef = useRef(mode)
|
|
37
|
+
modeRef.current = mode
|
|
38
|
+
|
|
39
|
+
const removeEntry = useCallback(
|
|
40
|
+
(id: OverlayId, reason: OverlayCloseReason) => {
|
|
41
|
+
const current = stackRef.current
|
|
42
|
+
const idx = current.findIndex((e) => e.id === id)
|
|
43
|
+
if (idx === -1) return
|
|
44
|
+
|
|
45
|
+
const entry = current[idx]
|
|
46
|
+
entry.options.onClose?.(reason)
|
|
47
|
+
|
|
48
|
+
setStack((prev) => {
|
|
49
|
+
const i = prev.findIndex((e) => e.id === id)
|
|
50
|
+
if (i === -1) return prev
|
|
51
|
+
const next = [...prev]
|
|
52
|
+
next.splice(i, 1)
|
|
53
|
+
return next
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if (entry.options.restoreMode !== false) {
|
|
57
|
+
setMode(entry.prevMode as any)
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
[setMode],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const open = useCallback(
|
|
64
|
+
<TPayload,>(
|
|
65
|
+
id: OverlayId,
|
|
66
|
+
render: OverlayRenderer<TPayload>,
|
|
67
|
+
payload: TPayload,
|
|
68
|
+
options: OverlayOpenOptions = {},
|
|
69
|
+
): OverlayHandle<TPayload> => {
|
|
70
|
+
const prevMode = modeRef.current
|
|
71
|
+
const overlayMode = options.mode === undefined ? "insert" : options.mode
|
|
72
|
+
|
|
73
|
+
setStack((prev) => {
|
|
74
|
+
// Remove existing entry with same id if present
|
|
75
|
+
const filtered = prev.filter((e) => e.id !== id)
|
|
76
|
+
const entry: OverlayEntry = {
|
|
77
|
+
id,
|
|
78
|
+
render: render as OverlayRenderer<any>,
|
|
79
|
+
payload,
|
|
80
|
+
options,
|
|
81
|
+
prevMode,
|
|
82
|
+
}
|
|
83
|
+
return [...filtered, entry]
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
if (overlayMode !== null) {
|
|
87
|
+
setMode(overlayMode as any)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handle: OverlayHandle<TPayload> = {
|
|
91
|
+
id,
|
|
92
|
+
close: (reason: OverlayCloseReason = "close") => removeEntry(id, reason),
|
|
93
|
+
update: (next: TPayload | ((prev: TPayload) => TPayload)) => {
|
|
94
|
+
setStack((prev) => {
|
|
95
|
+
const idx = prev.findIndex((e) => e.id === id)
|
|
96
|
+
if (idx === -1) return prev
|
|
97
|
+
const entry = prev[idx]
|
|
98
|
+
const newPayload =
|
|
99
|
+
typeof next === "function" ? (next as (p: TPayload) => TPayload)(entry.payload) : next
|
|
100
|
+
const updated = [...prev]
|
|
101
|
+
updated[idx] = { ...entry, payload: newPayload }
|
|
102
|
+
return updated
|
|
103
|
+
})
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return handle
|
|
108
|
+
},
|
|
109
|
+
[setMode, removeEntry],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const update = useCallback(
|
|
113
|
+
<TPayload,>(id: OverlayId, next: TPayload | ((prev: TPayload) => TPayload)) => {
|
|
114
|
+
setStack((prev) => {
|
|
115
|
+
const idx = prev.findIndex((e) => e.id === id)
|
|
116
|
+
if (idx === -1) return prev
|
|
117
|
+
const entry = prev[idx]
|
|
118
|
+
const newPayload =
|
|
119
|
+
typeof next === "function" ? (next as (p: TPayload) => TPayload)(entry.payload) : next
|
|
120
|
+
const updated = [...prev]
|
|
121
|
+
updated[idx] = { ...entry, payload: newPayload }
|
|
122
|
+
return updated
|
|
123
|
+
})
|
|
124
|
+
},
|
|
125
|
+
[],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const show = useCallback(
|
|
129
|
+
(id: OverlayId, content: ReactNode, options?: OverlayOpenOptions) => {
|
|
130
|
+
// Back-compat: show() defaults to no mode change (unlike open() which defaults to "insert")
|
|
131
|
+
open(id, () => content, undefined, { mode: null, ...options })
|
|
132
|
+
},
|
|
133
|
+
[open],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const hide = useCallback(
|
|
137
|
+
(id: OverlayId) => {
|
|
138
|
+
removeEntry(id, "close")
|
|
139
|
+
},
|
|
140
|
+
[removeEntry],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const closeTop = useCallback(
|
|
144
|
+
(reason: OverlayCloseReason = "close") => {
|
|
145
|
+
const current = stackRef.current
|
|
146
|
+
if (current.length === 0) return
|
|
147
|
+
const top = current[current.length - 1]
|
|
148
|
+
removeEntry(top.id, reason)
|
|
149
|
+
},
|
|
150
|
+
[removeEntry],
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
const isOpen = useCallback((id: OverlayId) => {
|
|
154
|
+
return stackRef.current.some((e) => e.id === id)
|
|
155
|
+
}, [])
|
|
156
|
+
|
|
157
|
+
const topId = stack.length > 0 ? stack[stack.length - 1].id : null
|
|
158
|
+
|
|
159
|
+
const controller = useMemo<OverlayController>(
|
|
160
|
+
() => ({
|
|
161
|
+
open,
|
|
162
|
+
update,
|
|
163
|
+
show,
|
|
164
|
+
hide,
|
|
165
|
+
closeTop,
|
|
166
|
+
isOpen,
|
|
167
|
+
topId,
|
|
168
|
+
}),
|
|
169
|
+
[open, update, show, hide, closeTop, isOpen, topId],
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
useProvideCommandContext(() => ({
|
|
173
|
+
overlay: {
|
|
174
|
+
open: controller.open,
|
|
175
|
+
show: controller.show,
|
|
176
|
+
hide: controller.hide,
|
|
177
|
+
update: controller.update,
|
|
178
|
+
closeTop: controller.closeTop,
|
|
179
|
+
isOpen: controller.isOpen,
|
|
180
|
+
topId: controller.topId,
|
|
181
|
+
},
|
|
182
|
+
}))
|
|
183
|
+
|
|
184
|
+
useCommand({
|
|
185
|
+
id: "overlay.close-top",
|
|
186
|
+
title: "Close overlay",
|
|
187
|
+
hotkey: "Escape",
|
|
188
|
+
modes: ["insert"],
|
|
189
|
+
hidden: true,
|
|
190
|
+
when: () => topId !== null,
|
|
191
|
+
handler: () => closeTop("escape"),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// Render the topmost overlay
|
|
195
|
+
const topEntry = stack.length > 0 ? stack[stack.length - 1] : null
|
|
196
|
+
const current = topEntry
|
|
197
|
+
? topEntry.render({
|
|
198
|
+
id: topEntry.id,
|
|
199
|
+
payload: topEntry.payload,
|
|
200
|
+
isTop: true,
|
|
201
|
+
close: (reason: OverlayCloseReason = "close") => removeEntry(topEntry.id, reason),
|
|
202
|
+
update: (next: any) => update(topEntry.id, next),
|
|
203
|
+
})
|
|
204
|
+
: null
|
|
205
|
+
|
|
206
|
+
const state = useMemo(
|
|
207
|
+
() => ({
|
|
208
|
+
current,
|
|
209
|
+
hasOverlay: stack.length > 0,
|
|
210
|
+
stack: stack.map((e) => e.id),
|
|
211
|
+
}),
|
|
212
|
+
[current, stack],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<OverlayControllerContext value={controller}>
|
|
217
|
+
<OverlayStateContext value={state}>{children}</OverlayStateContext>
|
|
218
|
+
</OverlayControllerContext>
|
|
219
|
+
)
|
|
220
|
+
}
|