@tooee/commands 0.1.19 → 0.1.21
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 +33 -0
- package/dist/command-store.d.ts +147 -0
- package/dist/command-store.d.ts.map +1 -0
- package/dist/command-store.js +367 -0
- package/dist/command-store.js.map +1 -0
- package/dist/context.d.ts +52 -3
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +244 -153
- package/dist/context.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/match.d.ts.map +1 -1
- package/dist/match.js +2 -0
- package/dist/match.js.map +1 -1
- package/dist/mode.d.ts +7 -1
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +12 -3
- package/dist/mode.js.map +1 -1
- package/dist/parse.d.ts.map +1 -1
- package/dist/parse.js +17 -5
- package/dist/parse.js.map +1 -1
- package/dist/sequence.d.ts +17 -2
- package/dist/sequence.d.ts.map +1 -1
- package/dist/sequence.js +66 -45
- package/dist/sequence.js.map +1 -1
- package/dist/types.d.ts +51 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/use-actions.d.ts.map +1 -1
- package/dist/use-actions.js +6 -3
- package/dist/use-actions.js.map +1 -1
- package/dist/use-command.d.ts.map +1 -1
- package/dist/use-command.js +12 -4
- package/dist/use-command.js.map +1 -1
- package/package.json +5 -1
- package/src/command-store.ts +554 -0
- package/src/context.tsx +353 -165
- package/src/index.ts +27 -1
- package/src/match.ts +1 -0
- package/src/mode.tsx +31 -3
- package/src/parse.ts +20 -5
- package/src/sequence.ts +80 -52
- package/src/types.ts +56 -3
- package/src/use-actions.ts +5 -3
- package/src/use-command.ts +13 -4
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type {
|
|
2
|
+
ActiveCommandSurface,
|
|
2
3
|
Command,
|
|
3
4
|
CommandContext,
|
|
4
5
|
CommandContextBase,
|
|
@@ -6,6 +7,8 @@ export type {
|
|
|
6
7
|
CommandHandler,
|
|
7
8
|
CommandSequenceCandidate,
|
|
8
9
|
CommandSequenceState,
|
|
10
|
+
CommandSurfaceEntry,
|
|
11
|
+
CommandSurfaceRole,
|
|
9
12
|
CommandWhen,
|
|
10
13
|
ParsedHotkey,
|
|
11
14
|
ParsedStep,
|
|
@@ -24,13 +27,36 @@ export type {
|
|
|
24
27
|
} from "./sequence.js"
|
|
25
28
|
export {
|
|
26
29
|
CommandProvider,
|
|
30
|
+
CommandSurfaceProvider,
|
|
31
|
+
useActiveCommandSurface,
|
|
27
32
|
useCommandContext,
|
|
28
33
|
useCommandGroup,
|
|
29
34
|
useCommandRegistry,
|
|
30
35
|
useCommandSequenceState,
|
|
36
|
+
useCommandStore,
|
|
31
37
|
useProvideCommandContext,
|
|
38
|
+
useSurfaceCommands,
|
|
32
39
|
} from "./context.js"
|
|
33
|
-
export
|
|
40
|
+
export {
|
|
41
|
+
ROOT_SURFACE_ID,
|
|
42
|
+
createCommandStore,
|
|
43
|
+
selectActiveModalSurface,
|
|
44
|
+
selectGroups,
|
|
45
|
+
selectSequence,
|
|
46
|
+
selectSurfaceCommandMap,
|
|
47
|
+
selectSurfaceCommands,
|
|
48
|
+
} from "./command-store.js"
|
|
49
|
+
export type {
|
|
50
|
+
CommandStore,
|
|
51
|
+
CommandStoreConfig,
|
|
52
|
+
CommandStoreContext,
|
|
53
|
+
CommandStoreInstance,
|
|
54
|
+
ContextGetter,
|
|
55
|
+
CreateCommandStoreOptions,
|
|
56
|
+
KeyDispatchResult,
|
|
57
|
+
SurfaceRecord,
|
|
58
|
+
} from "./command-store.js"
|
|
59
|
+
export type { CommandProviderProps, CommandSurfaceProviderProps } from "./context.js"
|
|
34
60
|
export { useCommand } from "./use-command.js"
|
|
35
61
|
export type { UseCommandOptions } from "./use-command.js"
|
|
36
62
|
export { useActions } from "./use-actions.js"
|
package/src/match.ts
CHANGED
|
@@ -10,5 +10,6 @@ export function matchStep(event: KeyEvent, step: ParsedStep): boolean {
|
|
|
10
10
|
if (event.meta !== step.meta) return false
|
|
11
11
|
if (event.shift !== step.shift) return false
|
|
12
12
|
if (event.option !== step.option) return false
|
|
13
|
+
if ((event.super ?? false) !== (step.super ?? false)) return false
|
|
13
14
|
return true
|
|
14
15
|
}
|
package/src/mode.tsx
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react"
|
|
2
10
|
|
|
3
11
|
export type Mode = "cursor" | "insert" | "select"
|
|
4
12
|
|
|
@@ -12,11 +20,31 @@ const ModeContext = createContext<ModeContextValue | null>(null)
|
|
|
12
20
|
export interface ModeProviderProps {
|
|
13
21
|
children: ReactNode
|
|
14
22
|
initialMode?: Mode
|
|
23
|
+
/**
|
|
24
|
+
* Internal: called synchronously whenever `setMode` changes the mode (not
|
|
25
|
+
* for same-value calls). Used by the command dispatcher to treat mode
|
|
26
|
+
* changes as transitions (sequence reset) instead of post-render repairs.
|
|
27
|
+
*/
|
|
28
|
+
onModeChange?: (mode: Mode) => void
|
|
15
29
|
}
|
|
16
30
|
|
|
17
|
-
export function ModeProvider({
|
|
31
|
+
export function ModeProvider({
|
|
32
|
+
children,
|
|
33
|
+
initialMode = "cursor",
|
|
34
|
+
onModeChange,
|
|
35
|
+
}: ModeProviderProps) {
|
|
18
36
|
const [mode, setModeState] = useState<Mode>(initialMode)
|
|
19
|
-
const
|
|
37
|
+
const modeRef = useRef(mode)
|
|
38
|
+
const onModeChangeRef = useRef(onModeChange)
|
|
39
|
+
onModeChangeRef.current = onModeChange
|
|
40
|
+
|
|
41
|
+
const setMode = useCallback((m: Mode) => {
|
|
42
|
+
if (m !== modeRef.current) {
|
|
43
|
+
modeRef.current = m
|
|
44
|
+
onModeChangeRef.current?.(m)
|
|
45
|
+
}
|
|
46
|
+
setModeState(m)
|
|
47
|
+
}, [])
|
|
20
48
|
|
|
21
49
|
const value = useMemo<ModeContextValue>(() => ({ mode, setMode }), [mode, setMode])
|
|
22
50
|
|
package/src/parse.ts
CHANGED
|
@@ -15,6 +15,14 @@ function normalizeKey(key: string): string {
|
|
|
15
15
|
return KEY_ALIASES[lower] ?? lower
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
const warned = new Set<string>()
|
|
19
|
+
|
|
20
|
+
function warnOnce(message: string): void {
|
|
21
|
+
if (warned.has(message)) return
|
|
22
|
+
warned.add(message)
|
|
23
|
+
console.warn(message)
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
function parseStep(step: string): ParsedStep {
|
|
19
27
|
const parts = step.toLowerCase().split("+")
|
|
20
28
|
let key = ""
|
|
@@ -22,6 +30,7 @@ function parseStep(step: string): ParsedStep {
|
|
|
22
30
|
let meta = false
|
|
23
31
|
let shift = false
|
|
24
32
|
let option = false
|
|
33
|
+
let superKey = false
|
|
25
34
|
|
|
26
35
|
for (const part of parts) {
|
|
27
36
|
const trimmed = part.trim()
|
|
@@ -34,13 +43,13 @@ function parseStep(step: string): ParsedStep {
|
|
|
34
43
|
} else if (trimmed === "option") {
|
|
35
44
|
option = true
|
|
36
45
|
} else if (trimmed === "super") {
|
|
37
|
-
|
|
46
|
+
superKey = true
|
|
38
47
|
} else {
|
|
39
48
|
key = normalizeKey(trimmed)
|
|
40
49
|
}
|
|
41
50
|
}
|
|
42
51
|
|
|
43
|
-
return { key, ctrl, meta, shift, option }
|
|
52
|
+
return { key, ctrl, meta, shift, option, super: superKey }
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
/**
|
|
@@ -57,9 +66,15 @@ export function parseHotkey(hotkey: string, leaderKey?: string): ParsedHotkey {
|
|
|
57
66
|
// Handle leader prefix
|
|
58
67
|
const leaderMatch = trimmed.match(/^<leader>(.+)$/)
|
|
59
68
|
if (leaderMatch) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
if (!leaderKey) {
|
|
70
|
+
// No leader configured: the hotkey must not spring to life on some
|
|
71
|
+
// invented default. Zero steps = unmatchable; the dispatcher skips it.
|
|
72
|
+
warnOnce(
|
|
73
|
+
`[tooee/commands] Hotkey "${trimmed}" uses <leader> but no leader key is configured; the hotkey is disabled.`,
|
|
74
|
+
)
|
|
75
|
+
return { steps: [] }
|
|
76
|
+
}
|
|
77
|
+
const leaderStep = parseStep(leaderKey)
|
|
63
78
|
const followStep = parseStep(leaderMatch[1]!)
|
|
64
79
|
return { steps: [leaderStep, followStep] }
|
|
65
80
|
}
|
package/src/sequence.ts
CHANGED
|
@@ -19,6 +19,82 @@ export interface SequenceFeedResult {
|
|
|
19
19
|
pending: SequencePendingMatch | null
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// --- Pure matching helpers ---------------------------------------------------
|
|
23
|
+
// These are the sequence-matching primitives, extracted so a store (or any
|
|
24
|
+
// non-React dispatcher) can run them against its own buffer without
|
|
25
|
+
// instantiating SequenceTracker. SequenceTracker delegates to them.
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True when the tail of `buffer` fully matches every step of `hotkey`.
|
|
29
|
+
* Zero-step hotkeys (e.g. a disabled leaderless `<leader>` binding) never
|
|
30
|
+
* match anything.
|
|
31
|
+
*/
|
|
32
|
+
export function matchesBuffer(buffer: readonly KeyEvent[], hotkey: ParsedHotkey): boolean {
|
|
33
|
+
const { steps } = hotkey
|
|
34
|
+
if (steps.length === 0) return false
|
|
35
|
+
if (buffer.length < steps.length) return false
|
|
36
|
+
|
|
37
|
+
const start = buffer.length - steps.length
|
|
38
|
+
for (let i = 0; i < steps.length; i++) {
|
|
39
|
+
if (!matchStep(buffer[start + i]!, steps[i]!)) return false
|
|
40
|
+
}
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Find the longest buffer tail that is a proper prefix of at least one hotkey.
|
|
46
|
+
* Returns the prefix length and the indexes of the hotkeys it could still
|
|
47
|
+
* complete, or null when nothing is pending.
|
|
48
|
+
*/
|
|
49
|
+
export function findPendingMatch(
|
|
50
|
+
buffer: readonly KeyEvent[],
|
|
51
|
+
hotkeys: readonly ParsedHotkey[],
|
|
52
|
+
): SequencePendingMatch | null {
|
|
53
|
+
const maxPrefixLength = Math.min(
|
|
54
|
+
buffer.length,
|
|
55
|
+
Math.max(0, ...hotkeys.map((h) => h.steps.length - 1)),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
for (let prefixLength = maxPrefixLength; prefixLength > 0; prefixLength--) {
|
|
59
|
+
const start = buffer.length - prefixLength
|
|
60
|
+
const indexes: number[] = []
|
|
61
|
+
|
|
62
|
+
for (let hotkeyIndex = 0; hotkeyIndex < hotkeys.length; hotkeyIndex++) {
|
|
63
|
+
const hotkey = hotkeys[hotkeyIndex]!
|
|
64
|
+
if (hotkey.steps.length <= prefixLength) continue
|
|
65
|
+
|
|
66
|
+
let matches = true
|
|
67
|
+
for (let i = 0; i < prefixLength; i++) {
|
|
68
|
+
if (!matchStep(buffer[start + i]!, hotkey.steps[i]!)) {
|
|
69
|
+
matches = false
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (matches) indexes.push(hotkeyIndex)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (indexes.length > 0) return { prefixLength, indexes }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Drop buffer entries older than the longest hotkey could ever consume.
|
|
85
|
+
* Returns the same array when nothing needs pruning.
|
|
86
|
+
*/
|
|
87
|
+
export function pruneBuffer(
|
|
88
|
+
buffer: readonly KeyEvent[],
|
|
89
|
+
hotkeys: readonly ParsedHotkey[],
|
|
90
|
+
): readonly KeyEvent[] {
|
|
91
|
+
const maxLen = Math.max(0, ...hotkeys.map((h) => h.steps.length))
|
|
92
|
+
if (maxLen > 0 && buffer.length > maxLen) {
|
|
93
|
+
return buffer.slice(buffer.length - maxLen)
|
|
94
|
+
}
|
|
95
|
+
return buffer
|
|
96
|
+
}
|
|
97
|
+
|
|
22
98
|
export class SequenceTracker {
|
|
23
99
|
private buffer: KeyEvent[] = []
|
|
24
100
|
private timer: ReturnType<typeof setTimeout> | null = null
|
|
@@ -46,68 +122,20 @@ export class SequenceTracker {
|
|
|
46
122
|
this.resetTimer()
|
|
47
123
|
|
|
48
124
|
for (let i = 0; i < hotkeys.length; i++) {
|
|
49
|
-
|
|
50
|
-
if (this.matchesBuffer(hotkey)) {
|
|
125
|
+
if (matchesBuffer(this.buffer, hotkeys[i]!)) {
|
|
51
126
|
this.reset()
|
|
52
127
|
return { matchedIndex: i, pending: null }
|
|
53
128
|
}
|
|
54
129
|
}
|
|
55
130
|
|
|
56
131
|
// Prune buffer if no hotkey could possibly match
|
|
57
|
-
|
|
58
|
-
if (maxLen > 0) {
|
|
59
|
-
while (this.buffer.length > maxLen) {
|
|
60
|
-
this.buffer.shift()
|
|
61
|
-
}
|
|
62
|
-
}
|
|
132
|
+
this.buffer = [...pruneBuffer(this.buffer, hotkeys)]
|
|
63
133
|
|
|
64
|
-
return { matchedIndex: -1, pending: this.
|
|
134
|
+
return { matchedIndex: -1, pending: findPendingMatch(this.buffer, hotkeys) }
|
|
65
135
|
}
|
|
66
136
|
|
|
67
137
|
getPendingMatch(hotkeys: ParsedHotkey[]): SequencePendingMatch | null {
|
|
68
|
-
return this.
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
private matchesBuffer(hotkey: ParsedHotkey): boolean {
|
|
72
|
-
const { steps } = hotkey
|
|
73
|
-
if (this.buffer.length < steps.length) return false
|
|
74
|
-
|
|
75
|
-
const start = this.buffer.length - steps.length
|
|
76
|
-
for (let i = 0; i < steps.length; i++) {
|
|
77
|
-
if (!matchStep(this.buffer[start + i]!, steps[i]!)) return false
|
|
78
|
-
}
|
|
79
|
-
return true
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
private findPendingMatch(hotkeys: ParsedHotkey[]): SequencePendingMatch | null {
|
|
83
|
-
const maxPrefixLength = Math.min(
|
|
84
|
-
this.buffer.length,
|
|
85
|
-
Math.max(0, ...hotkeys.map((h) => h.steps.length - 1)),
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
for (let prefixLength = maxPrefixLength; prefixLength > 0; prefixLength--) {
|
|
89
|
-
const start = this.buffer.length - prefixLength
|
|
90
|
-
const indexes: number[] = []
|
|
91
|
-
|
|
92
|
-
for (let hotkeyIndex = 0; hotkeyIndex < hotkeys.length; hotkeyIndex++) {
|
|
93
|
-
const hotkey = hotkeys[hotkeyIndex]!
|
|
94
|
-
if (hotkey.steps.length <= prefixLength) continue
|
|
95
|
-
|
|
96
|
-
let matches = true
|
|
97
|
-
for (let i = 0; i < prefixLength; i++) {
|
|
98
|
-
if (!matchStep(this.buffer[start + i]!, hotkey.steps[i]!)) {
|
|
99
|
-
matches = false
|
|
100
|
-
break
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (matches) indexes.push(hotkeyIndex)
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (indexes.length > 0) return { prefixLength, indexes }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return null
|
|
138
|
+
return findPendingMatch(this.buffer, hotkeys)
|
|
111
139
|
}
|
|
112
140
|
|
|
113
141
|
reset(): void {
|
package/src/types.ts
CHANGED
|
@@ -7,9 +7,21 @@ export interface CommandContextBase {
|
|
|
7
7
|
exit: () => void
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
/**
|
|
11
|
+
* The context handed to command handlers. Domain packages contribute typed
|
|
12
|
+
* fields via module augmentation (the mechanism the shell already uses for
|
|
13
|
+
* `overlay` and `toast`):
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* declare module "@tooee/commands" {
|
|
17
|
+
* interface CommandContext {
|
|
18
|
+
* myDomain: MyDomainContext
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
24
|
+
export interface CommandContext extends CommandContextBase {}
|
|
13
25
|
|
|
14
26
|
export type CommandHandler = (ctx: CommandContext) => void | Promise<void>
|
|
15
27
|
export type CommandWhen = (ctx: CommandContext) => boolean
|
|
@@ -37,6 +49,8 @@ export interface ParsedStep {
|
|
|
37
49
|
meta: boolean
|
|
38
50
|
shift: boolean
|
|
39
51
|
option: boolean
|
|
52
|
+
/** Super/Windows/Cmd-as-super modifier. Optional for back-compat. */
|
|
53
|
+
super?: boolean
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
export interface CommandRegistry {
|
|
@@ -45,6 +59,45 @@ export interface CommandRegistry {
|
|
|
45
59
|
invoke: (id: string) => void
|
|
46
60
|
}
|
|
47
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Interaction role of a command surface.
|
|
64
|
+
* - `modal`: owns keyboard input while topmost and suspends every lower surface
|
|
65
|
+
* (including the root app) — even for keys it does not handle.
|
|
66
|
+
* - `passive`: never becomes the keyboard owner; purely visual.
|
|
67
|
+
*/
|
|
68
|
+
export type CommandSurfaceRole = "modal" | "passive"
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A command surface is an interaction surface that can own keyboard input.
|
|
72
|
+
* The root app is the implicit base surface; modal overlays push surfaces on
|
|
73
|
+
* top of it. Each surface carries its own command registry and local mode so
|
|
74
|
+
* the same command definitions can be rooted in a standalone app or pushed by
|
|
75
|
+
* an overlay without leaking mode/command state into the parent.
|
|
76
|
+
*/
|
|
77
|
+
export interface CommandSurfaceEntry {
|
|
78
|
+
id: string
|
|
79
|
+
role: CommandSurfaceRole
|
|
80
|
+
/** Nesting depth (root children are depth 0; the first overlay surface is 1). */
|
|
81
|
+
depth: number
|
|
82
|
+
/** Monotonic registration order, used to break depth ties. */
|
|
83
|
+
order: number
|
|
84
|
+
registry: CommandRegistry
|
|
85
|
+
/** Reads this surface's current local mode. */
|
|
86
|
+
getMode: () => Mode
|
|
87
|
+
/** Builds the command context handed to this surface's command handlers. */
|
|
88
|
+
buildCtx: () => CommandContext
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ActiveCommandSurface {
|
|
92
|
+
id: string
|
|
93
|
+
role: CommandSurfaceRole
|
|
94
|
+
/**
|
|
95
|
+
* Commands registered on this surface (reactive). Enables surface-aware
|
|
96
|
+
* which-key/help/palette consumers.
|
|
97
|
+
*/
|
|
98
|
+
commands: readonly Command[]
|
|
99
|
+
}
|
|
100
|
+
|
|
48
101
|
export interface CommandGroup {
|
|
49
102
|
id: string
|
|
50
103
|
title: string
|
package/src/use-actions.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef } from "react"
|
|
2
|
-
import {
|
|
2
|
+
import { useSurfaceRegistry } from "./context.js"
|
|
3
3
|
import type { Command, CommandHandler, CommandWhen } from "./types.js"
|
|
4
4
|
import type { Mode } from "./mode.js"
|
|
5
5
|
|
|
@@ -17,7 +17,7 @@ export interface ActionDefinition {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function useActions(actions: ActionDefinition[] | undefined): void {
|
|
20
|
-
const
|
|
20
|
+
const registry = useSurfaceRegistry()
|
|
21
21
|
const actionsRef = useRef(actions)
|
|
22
22
|
actionsRef.current = actions
|
|
23
23
|
|
|
@@ -26,7 +26,9 @@ export function useActions(actions: ActionDefinition[] | undefined): void {
|
|
|
26
26
|
actions
|
|
27
27
|
?.map(
|
|
28
28
|
(a) =>
|
|
29
|
-
|
|
29
|
+
// modes and when-presence are frozen into the registration, so
|
|
30
|
+
// they must participate in the re-registration key.
|
|
31
|
+
`${a.id}:${a.title}:${a.hotkey ?? ""}:${a.category ?? ""}:${a.group ?? ""}:${a.icon ?? ""}:${a.hidden ?? false}:${a.modes?.join("|") ?? ""}:${a.when ? 1 : 0}`,
|
|
30
32
|
)
|
|
31
33
|
.join(",") ?? "",
|
|
32
34
|
[actions],
|
package/src/use-command.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react"
|
|
2
2
|
import type { Command, CommandHandler, CommandWhen } from "./types.js"
|
|
3
3
|
import type { Mode } from "./mode.js"
|
|
4
|
-
import {
|
|
4
|
+
import { useSurfaceRegistry } from "./context.js"
|
|
5
5
|
|
|
6
6
|
export interface UseCommandOptions {
|
|
7
7
|
id: string
|
|
@@ -17,17 +17,26 @@ export interface UseCommandOptions {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export function useCommand(options: UseCommandOptions): void {
|
|
20
|
-
const
|
|
20
|
+
const registry = useSurfaceRegistry()
|
|
21
21
|
const optionsRef = useRef(options)
|
|
22
22
|
optionsRef.current = options
|
|
23
23
|
|
|
24
|
+
// Key on the modes CONTENT, not the array identity: callers pass inline
|
|
25
|
+
// array literals, and with a subscribable registry an identity-keyed effect
|
|
26
|
+
// would re-register on every render — re-notifying the subscriber that
|
|
27
|
+
// caused the render (an infinite update loop for components that both
|
|
28
|
+
// register and observe commands, e.g. the command palette provider).
|
|
29
|
+
const modesKey = options.modes?.join("|") ?? ""
|
|
30
|
+
|
|
24
31
|
useEffect(() => {
|
|
25
32
|
const command: Command = {
|
|
26
33
|
id: options.id,
|
|
27
34
|
title: options.title,
|
|
28
35
|
handler: (...args: Parameters<Command["handler"]>) => optionsRef.current.handler(...args),
|
|
29
36
|
defaultHotkey: options.hotkey,
|
|
30
|
-
|
|
37
|
+
// Read through the ref: the effect is keyed on modesKey (content), and
|
|
38
|
+
// the ref holds the same-render options when the effect runs.
|
|
39
|
+
modes: optionsRef.current.modes,
|
|
31
40
|
category: options.category,
|
|
32
41
|
group: options.group,
|
|
33
42
|
icon: options.icon,
|
|
@@ -39,7 +48,7 @@ export function useCommand(options: UseCommandOptions): void {
|
|
|
39
48
|
options.id,
|
|
40
49
|
options.title,
|
|
41
50
|
options.hotkey,
|
|
42
|
-
|
|
51
|
+
modesKey,
|
|
43
52
|
options.category,
|
|
44
53
|
options.group,
|
|
45
54
|
options.icon,
|