@forgeax/interface 0.5.0 → 0.7.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.
Files changed (47) hide show
  1. package/README.md +25 -0
  2. package/dist/package/browser/ApplicationShell.js +3 -2
  3. package/dist/package/browser/appHostBootstrap.js +10 -2
  4. package/dist/package/browser/application.d.ts +0 -8
  5. package/dist/package/browser/application.js +0 -9
  6. package/dist/package/browser/components/MenuBar/MenuBar.d.ts +3 -2
  7. package/dist/package/browser/components/MenuBar/MenuBar.js +9 -33
  8. package/dist/package/browser/components/MenuBar/menubar-surface.d.ts +3 -2
  9. package/dist/package/browser/components/MenuBar/menubar-surface.js +16 -13
  10. package/dist/package/browser/core/app-shell/host.js +7 -2
  11. package/dist/package/browser/core/app-shell/types.d.ts +5 -1
  12. package/dist/package/browser/core/extensions/builtin-commands.js +1 -0
  13. package/dist/package/browser/core/extensions/builtin-menus.js +23 -3
  14. package/dist/package/browser/i18n/locales/en.json +0 -10
  15. package/dist/package/browser/i18n/locales/zh.json +0 -10
  16. package/dist/package/browser/lib/application-menu-projection.d.ts +5 -0
  17. package/dist/package/browser/lib/application-menu-projection.js +53 -0
  18. package/dist/package/browser/lib/global-shortcuts.d.ts +5 -74
  19. package/dist/package/browser/lib/global-shortcuts.js +29 -228
  20. package/dist/package/browser/lib/menu-registry.d.ts +0 -49
  21. package/dist/package/browser/lib/menu-registry.js +3 -197
  22. package/dist/package/browser/lib/menu-registry.test-utils.d.ts +11 -0
  23. package/dist/package/browser/lib/menu-registry.test-utils.js +28 -0
  24. package/dist/package/browser/lib/native-menu-bridge.d.ts +17 -3
  25. package/dist/package/browser/lib/native-menu-bridge.js +121 -81
  26. package/dist/package/node/ApplicationShell.js +3 -2
  27. package/dist/package/node/appHostBootstrap.js +10 -2
  28. package/dist/package/node/application.js +0 -9
  29. package/dist/package/node/components/MenuBar/MenuBar.js +9 -33
  30. package/dist/package/node/components/MenuBar/menubar-surface.js +16 -13
  31. package/dist/package/node/core/app-shell/host.js +7 -2
  32. package/dist/package/node/core/extensions/builtin-commands.js +1 -0
  33. package/dist/package/node/core/extensions/builtin-menus.js +23 -3
  34. package/dist/package/node/i18n/locales/en.json +0 -10
  35. package/dist/package/node/i18n/locales/zh.json +0 -10
  36. package/dist/package/node/lib/application-menu-projection.js +53 -0
  37. package/dist/package/node/lib/global-shortcuts.js +29 -228
  38. package/dist/package/node/lib/menu-registry.js +3 -197
  39. package/dist/package/node/lib/menu-registry.test-utils.js +28 -0
  40. package/dist/package/node/lib/native-menu-bridge.js +121 -81
  41. package/package.json +2 -2
  42. package/dist/package/browser/core/extensions/editor-commands.d.ts +0 -2
  43. package/dist/package/browser/core/extensions/editor-commands.js +0 -182
  44. package/dist/package/browser/lib/repair-builtin-menus.d.ts +0 -6
  45. package/dist/package/browser/lib/repair-builtin-menus.js +0 -18
  46. package/dist/package/node/core/extensions/editor-commands.js +0 -182
  47. package/dist/package/node/lib/repair-builtin-menus.js +0 -18
@@ -10,7 +10,7 @@
10
10
  * Ctrl+/ focus chat composer
11
11
  * Ctrl+Shift+H open Settings → Changelog (was Ctrl+H before 2026-08-04 —
12
12
  * UE-parity editor hide claimed Ctrl+H for "show all hidden")
13
- * Esc stop viewport Play; otherwise close current overlay
13
+ * Esc close current overlay (higher-priority owners may claim it)
14
14
  * (Settings → Dashboard → Fullscreen)
15
15
  *
16
16
  * IME 安全:
@@ -21,75 +21,15 @@
21
21
  *
22
22
  * 注册:在 App 顶层调用 `useGlobalShortcuts()` 一次。
23
23
  */
24
+ import type { ApplicationShortcut, ApplicationShortcutRegistry } from '@forgeax/app-shell/application';
24
25
  import type { ContextualKeybindingsApi } from '../core/contextual-keybindings';
25
- export interface ShortcutDef {
26
- /** 显示给用户的字符串,e.g. "Ctrl+Shift+F"。Mac 上 UI 会自动替换 Ctrl → ⌘/⌃。 */
27
- combo: string;
28
- /** 触发条件:keydown event → boolean。 */
29
- match: (e: KeyboardEvent) => boolean;
30
- /** 一行描述,显示在 Settings 表里。 */
31
- label: string;
32
- /** 分组(Layout / Mode / Overlay / Focus / general / edit)。 */
33
- group: 'layout' | 'mode' | 'overlay' | 'focus' | 'general' | 'edit';
34
- /** 触发动作。返回 true 表示 preventDefault。 */
35
- run: (e?: KeyboardEvent) => boolean | void;
36
- /** Esc 这种允许在 input 里触发(其他必须 target 不是 editable 才触发)。 */
37
- allowInInput?: boolean;
38
- }
26
+ export type ShortcutDef = ApplicationShortcut;
39
27
  export declare function isTypingTarget(e: KeyboardEvent): boolean;
40
28
  /** Preview canvases own their edit-domain keyboard input locally. */
41
29
  export declare function isKeyboardOwnedSurface(e: KeyboardEvent): boolean;
42
30
  export declare function shouldSkipGlobalShortcut(e: KeyboardEvent, shortcut: Pick<ShortcutDef, 'group'>): boolean;
43
31
  export declare function isComposing(e: KeyboardEvent): boolean;
44
32
  export declare function isEditorSurfaceActive(): boolean;
45
- export interface RouterSelectedAsset {
46
- guid: string;
47
- kind: string;
48
- name: string;
49
- packPath: string;
50
- payload: Record<string, unknown>;
51
- }
52
- export interface KeyboardRouterDeps {
53
- /** Dispatch an editor op through the one gateway door. */
54
- dispatch: (op: {
55
- kind: string;
56
- [k: string]: unknown;
57
- }, origin?: string) => void;
58
- /** Current entity-selection handles (for Ctrl+D / H routing). */
59
- getEntitySelection: () => number[];
60
- /** Current asset-selection list (for Ctrl+D routing). */
61
- getAssetSelection: () => RouterSelectedAsset[];
62
- /** Derive of "who was selected last" — retained for not-yet-migrated Ctrl+D. */
63
- getLastSelectionDomain: () => 'entity' | 'asset' | 'folder' | null;
64
- /** True under ▶ Play. */
65
- isPlayMode: () => boolean;
66
- /** Current viewport display axis (for G / Shift+G Game View toggle, AC-Cb4). */
67
- getDisplay: () => 'scene' | 'game';
68
- /** Current input owner. */
69
- getInputTarget: () => 'editor' | 'game';
70
- /** Legacy command-registry support; no longer bound to global Delete. */
71
- deleteEntities: (ids: number[]) => void;
72
- /** Entity: duplicate the given handles. */
73
- duplicateEntities: (ids: number[]) => void;
74
- /** Entity: hide the given handles (H — UE parity; one transaction, one undo). */
75
- hideEntities: (ids: number[]) => void;
76
- /** Entity: show every hidden entity (Ctrl+H — UE parity). */
77
- showAllHidden: () => void;
78
- /** Entity: hide everything not selected (Shift+H — isolate selection). */
79
- hideUnselected: () => void;
80
- /** Legacy command-registry support; no longer bound to global Mod+A. */
81
- selectAllEntities: () => void;
82
- /** Asset: duplicate the given asset (guid, packPath). */
83
- duplicateAsset: (guid: string, packPath: string) => void;
84
- /** Editor history actions, injected so the interface stays editor-agnostic. */
85
- undo: () => void;
86
- redo: () => void;
87
- save: () => void;
88
- /** Recreate the active Play/runtime surface after a terminal viewport failure. */
89
- restartPreview?: () => void;
90
- /** Viewport W/E/R/F handling, including fly-mode/input ownership policy. */
91
- handleViewportKeyDown: (event: KeyboardEvent) => void;
92
- }
93
33
  /**
94
34
  * Register a short-lived keydown owner for an interaction that is not a
95
35
  * discoverable editor command (for example, Escape while a dock drag is
@@ -99,16 +39,7 @@ export interface KeyboardRouterDeps {
99
39
  export type GlobalKeydownHandler = (event: KeyboardEvent) => boolean;
100
40
  export declare function registerGlobalKeydownHandler(handler: GlobalKeydownHandler): () => void;
101
41
  export declare function dispatchGlobalKeydownHandlers(event: KeyboardEvent): boolean;
102
- /** Inject the editor-side callbacks the router needs. Called once at host boot
103
- * (forgeax-editor standalone/main.tsx) BEFORE the App mounts (useGlobalShortcuts
104
- * reads this at effect time, which is after mount, so registration first is safe). */
105
- export declare function registerKeyboardRouterDeps(deps: KeyboardRouterDeps | null): void;
106
- /** Read the currently injected router deps. Returns null until the host has
107
- * called registerKeyboardRouterDeps() at boot. Command-bus wrappers
108
- * (editor-commands extension) resolve deps lazily via this getter so they
109
- * stay editor-agnostic and boot-order-safe. */
110
- export declare function getKeyboardRouterDeps(): KeyboardRouterDeps | null;
111
- export declare function buildShortcuts(): ShortcutDef[];
42
+ export declare function buildShortcuts(contributions?: ApplicationShortcutRegistry): ShortcutDef[];
112
43
  /**
113
44
  * Mount once at App root. Returns nothing — purely an effect.
114
45
  *
@@ -117,5 +48,5 @@ export declare function buildShortcuts(): ShortcutDef[];
117
48
  * Ctrl+Shift+1/2/3 (which Chrome maps to tab switching only WITHOUT Shift,
118
49
  * so we're safe, but we preventDefault anyway).
119
50
  */
120
- export declare function useGlobalShortcuts(keybindings?: ContextualKeybindingsApi): void;
51
+ export declare function useGlobalShortcuts(keybindings: ContextualKeybindingsApi, contributions: ApplicationShortcutRegistry): void;
121
52
  export declare function prettyCombo(combo: string, platform?: 'mac' | 'windows'): string;
@@ -10,7 +10,7 @@
10
10
  * Ctrl+/ focus chat composer
11
11
  * Ctrl+Shift+H open Settings → Changelog (was Ctrl+H before 2026-08-04 —
12
12
  * UE-parity editor hide claimed Ctrl+H for "show all hidden")
13
- * Esc stop viewport Play; otherwise close current overlay
13
+ * Esc close current overlay (higher-priority owners may claim it)
14
14
  * (Settings → Dashboard → Fullscreen)
15
15
  *
16
16
  * IME 安全:
@@ -77,16 +77,9 @@ function mod(e) {
77
77
  }
78
78
  // Is the scene editor the surface the user is currently looking at?
79
79
  //
80
- // The remaining legacy `edit` group (Ctrl+D / viewport W-E-R-F …) is injected
81
- // globally at boot and still dispatches against module-level editor selection /
82
- // viewport state. This coarse surface gate lets those keys escape whenever the editor is
83
- // not the foreground surface. Two facts, both interface-local (no editor
84
- // import, no focus/DOM resolver — that larger redesign is ADR-0029 scope; this
85
- // is its Phase 0 short-term mitigation):
86
- // 1. an overlay (Dashboard / Settings) is covering the shell, or
87
- // 2. the editor Page anchor is not currently visible.
88
- // Read live at event time (cached snapshot — cheap) so switching tab / opening
89
- // an overlay takes effect immediately.
80
+ // Contributions classified as edit use the product shell's existing surface
81
+ // identity and visibility policy. Read at event time so page/overlay changes
82
+ // take effect without rebuilding the host or listener.
90
83
  export function isEditorSurfaceActive() {
91
84
  if (useShellStore.getState().activeOverlay)
92
85
  return false;
@@ -95,7 +88,6 @@ export function isEditorSurfaceActive() {
95
88
  : document.querySelector('[data-surface-anchor="edit"]');
96
89
  return Boolean(anchor && anchor.getClientRects().length > 0);
97
90
  }
98
- let routerDeps = null;
99
91
  const transientKeydownHandlers = new Set();
100
92
  export function registerGlobalKeydownHandler(handler) {
101
93
  transientKeydownHandlers.add(handler);
@@ -108,204 +100,9 @@ export function dispatchGlobalKeydownHandlers(event) {
108
100
  }
109
101
  return false;
110
102
  }
111
- /** Inject the editor-side callbacks the router needs. Called once at host boot
112
- * (forgeax-editor standalone/main.tsx) BEFORE the App mounts (useGlobalShortcuts
113
- * reads this at effect time, which is after mount, so registration first is safe). */
114
- export function registerKeyboardRouterDeps(deps) {
115
- routerDeps = deps;
116
- }
117
- /** Read the currently injected router deps. Returns null until the host has
118
- * called registerKeyboardRouterDeps() at boot. Command-bus wrappers
119
- * (editor-commands extension) resolve deps lazily via this getter so they
120
- * stay editor-agnostic and boot-order-safe. */
121
- export function getKeyboardRouterDeps() {
122
- return routerDeps;
123
- }
124
- // Build the edit-domain shortcut list from injected deps. Pure dispatcher: every
125
- // branch routes through a dep callback (which the editor maps onto gateway ops),
126
- // so this file stays editor-agnostic. Three-layer guards (IME / typing-target /
127
- // play-mode) are enforced by the host's onKey wrapper (isComposing / isTypingTarget)
128
- // plus the per-op play-mode checks below.
129
- function editShortcuts(deps) {
130
- const routeCtrlD = () => {
131
- const domain = deps.getLastSelectionDomain() ?? 'entity';
132
- if (domain === 'asset') {
133
- for (const a of deps.getAssetSelection())
134
- deps.duplicateAsset(a.guid, a.packPath);
135
- return true;
136
- }
137
- const ids = deps.getEntitySelection();
138
- if (ids.length > 0) {
139
- deps.duplicateEntities(ids);
140
- return true;
141
- }
142
- return false;
143
- };
144
- // UE-parity editor hide (docs 2026-08-04-editor-hide-ue-parity-plan M2):
145
- // H hides the selection, Ctrl+H shows every hidden entity, Shift+H hides
146
- // the unselected (isolate). Entity-domain only; under Play the game keeps
147
- // its keys (same guard as entity Delete).
148
- const routeH = () => {
149
- if (deps.isPlayMode())
150
- return false;
151
- const ids = deps.getEntitySelection();
152
- if (ids.length === 0)
153
- return false;
154
- deps.hideEntities(ids);
155
- return true;
156
- };
157
- const routeShiftH = () => {
158
- if (deps.isPlayMode())
159
- return false;
160
- if (deps.getEntitySelection().length === 0)
161
- return false;
162
- deps.hideUnselected();
163
- return true;
164
- };
165
- const routeCtrlH = () => {
166
- if (deps.isPlayMode())
167
- return false;
168
- deps.showAllHidden();
169
- return true;
170
- };
171
- // Viewport Game View toggle. Plain G is editor-only while not playing so a
172
- // running game retains its gameplay binding; Shift+G remains the explicit
173
- // Play shortcut and is also available in Edit Viewport.
174
- const routeShiftG = () => {
175
- deps.dispatch({ kind: 'setDisplay', display: deps.getDisplay() === 'game' ? 'scene' : 'game' }, 'human');
176
- return true;
177
- };
178
- const routeEditorOwnedPlayKey = (e) => {
179
- if (!e)
180
- return true;
181
- deps.handleViewportKeyDown(e);
182
- return true;
183
- };
184
- const routeViewportInput = (e) => {
185
- if (!e)
186
- return false;
187
- if (deps.getInputTarget() === 'game')
188
- return false;
189
- deps.handleViewportKeyDown(e);
190
- return true;
191
- };
192
- return [
193
- {
194
- combo: 'Ctrl+D',
195
- group: 'edit',
196
- label: t('shortcuts.duplicateSelection'),
197
- match: (e) => mod(e) && !e.shiftKey && !e.altKey
198
- && (e.code === 'KeyD' || safeKeyLower(e) === 'd'),
199
- run: routeCtrlD,
200
- },
201
- {
202
- combo: 'H',
203
- group: 'edit',
204
- label: t('shortcuts.hideSelected'),
205
- match: (e) => !mod(e) && !e.shiftKey && !e.altKey && e.code === 'KeyH',
206
- run: routeH,
207
- },
208
- {
209
- combo: 'Shift+H',
210
- group: 'edit',
211
- label: t('shortcuts.hideUnselected'),
212
- match: (e) => !mod(e) && e.shiftKey && !e.altKey && e.code === 'KeyH',
213
- run: routeShiftH,
214
- },
215
- {
216
- combo: 'Ctrl+H',
217
- group: 'edit',
218
- label: t('shortcuts.showAllHidden'),
219
- match: (e) => mod(e) && !e.shiftKey && !e.altKey && e.code === 'KeyH',
220
- run: routeCtrlH,
221
- },
222
- {
223
- combo: 'Shift+G',
224
- group: 'edit',
225
- label: t('shortcuts.toggleViewportGameView'),
226
- match: (e) => !mod(e) && e.shiftKey && !e.altKey
227
- && (e.key === 'g' || e.key === 'G'),
228
- run: routeShiftG,
229
- },
230
- {
231
- combo: 'G',
232
- group: 'edit',
233
- label: t('shortcuts.toggleViewportGameView'),
234
- match: (e) => !mod(e) && !e.shiftKey && !e.altKey
235
- && !deps.isPlayMode() && deps.getInputTarget() !== 'game'
236
- && (e.key === 'g' || e.key === 'G'),
237
- run: routeShiftG,
238
- },
239
- {
240
- combo: 'Ctrl+Z',
241
- group: 'edit',
242
- label: t('shortcuts.undo'),
243
- allowInInput: true,
244
- match: (e) => mod(e) && !e.altKey && !e.shiftKey && (e.code === 'KeyZ' || safeKeyLower(e) === 'z'),
245
- run: () => { deps.undo(); return true; },
246
- },
247
- {
248
- combo: 'Ctrl+Shift+Z',
249
- group: 'edit',
250
- label: t('shortcuts.redo'),
251
- allowInInput: true,
252
- match: (e) => mod(e) && !e.altKey && e.shiftKey && (e.code === 'KeyZ' || safeKeyLower(e) === 'z'),
253
- run: () => { deps.redo(); return true; },
254
- },
255
- {
256
- combo: 'Ctrl+Y',
257
- group: 'edit',
258
- label: t('shortcuts.redo'),
259
- allowInInput: true,
260
- match: (e) => mod(e) && !e.altKey && !e.shiftKey && (e.code === 'KeyY' || safeKeyLower(e) === 'y'),
261
- run: () => { deps.redo(); return true; },
262
- },
263
- {
264
- combo: 'Ctrl+S',
265
- group: 'edit',
266
- label: t('shortcuts.save'),
267
- // Must fire while typing in Input Map / MI fields (⌘/Ctrl+S is a document
268
- // command, not a text-editing key). Without this, focus inside `.im-editor`
269
- // makes isTypingTarget true and silently drops save.
270
- allowInInput: true,
271
- match: (e) => mod(e) && !e.altKey && !e.shiftKey && (e.code === 'KeyS' || safeKeyLower(e) === 's'),
272
- run: () => { deps.save(); return true; },
273
- },
274
- {
275
- combo: 'Viewport camera and fly input',
276
- group: 'edit',
277
- label: t('shortcuts.viewportNavigation'),
278
- match: (e) => {
279
- if (deps.getInputTarget() === 'game')
280
- return false;
281
- const key = safeKeyLower(e);
282
- if (!key)
283
- return false;
284
- // UE-style view presets (Alt+G/H/J/K) route to the viewport handler,
285
- // which owns the camera. All other Alt combos stay excluded here.
286
- if (e.altKey) {
287
- return !mod(e) && !e.shiftKey && ['g', 'h', 'j', 'k'].includes(key);
288
- }
289
- const plainCameraKey = !mod(e)
290
- && (['w', 'e', 'r', 'f', 'a', 's', 'd', 'q', 'v', 'z', 'c', 'escape', 'shift'].includes(key)
291
- || /^[1-9]$/.test(key));
292
- const bookmarkKey = mod(e) && !e.shiftKey && /^[1-9]$/.test(key);
293
- return plainCameraKey || bookmarkKey;
294
- },
295
- run: routeViewportInput,
296
- },
297
- {
298
- combo: 'Play editor input shield',
299
- group: 'edit',
300
- label: t('shortcuts.shieldGameInput'),
301
- match: () => deps.isPlayMode() && deps.getInputTarget() !== 'game',
302
- run: routeEditorOwnedPlayKey,
303
- },
304
- ];
305
- }
306
- // Build the shortcut registry. Each match() / run() is plain JS so we can
307
- // drive them from a Settings table later (or a Command Palette).
308
- export function buildShortcuts() {
103
+ // Build the Settings list in product order, not event-routing priority order.
104
+ // Exact duplicate descriptions share a row, but remain separate runtime owners.
105
+ export function buildShortcuts(contributions) {
309
106
  const store = useShellStore.getState;
310
107
  const shortcuts = [
311
108
  // ── Layout (collapse / fullscreen) ──
@@ -394,13 +191,6 @@ export function buildShortcuts() {
394
191
  allowInInput: true,
395
192
  match: (e) => e.key === 'Escape' && !mod(e) && !e.shiftKey && !e.altKey,
396
193
  run: () => {
397
- // Escape is a Play-only viewport shortcut: stop the transient play
398
- // session and return to edit mode. The listener stays in the single
399
- // global router; outside Play this branch is inactive.
400
- if (routerDeps?.isPlayMode()) {
401
- routerDeps.dispatch({ kind: 'stop' }, 'human');
402
- return true;
403
- }
404
194
  const s = store();
405
195
  // Browser fullscreen exits automatically on Esc — but be defensive
406
196
  // in case some browser swallows the event before reaching the native
@@ -460,18 +250,29 @@ export function buildShortcuts() {
460
250
  },
461
251
  },
462
252
  ];
463
- // Inject the host editor's remaining edit-domain shortcuts when deps were
464
- // registered at boot. Focus-owned F2/Delete/Mod+A live in host.keybindings.
465
- if (routerDeps)
466
- shortcuts.push(...editShortcuts(routerDeps));
467
253
  shortcuts.push({
254
+ priority: -20,
468
255
  combo: 'Ctrl+K',
469
256
  group: 'general',
470
257
  label: t('shortcuts.toggleCommandPalette'),
471
258
  match: (e) => mod(e) && !e.altKey && !e.shiftKey && (e.code === 'KeyK' || safeKeyLower(e) === 'k'),
472
259
  run: () => { toggleCommandPalette(); return true; },
473
260
  });
474
- return shortcuts;
261
+ const shell = shortcuts.map((shortcut) => ({ priority: 10, ...shortcut }));
262
+ const rowKey = ({ combo, group, label }) => JSON.stringify([combo, group, label]);
263
+ const seen = new Set(shell.map(rowKey));
264
+ const contributedRows = [];
265
+ for (const shortcut of contributions?.snapshot() ?? []) {
266
+ const key = rowKey(shortcut);
267
+ if (seen.has(key))
268
+ continue;
269
+ seen.add(key);
270
+ contributedRows.push(shortcut);
271
+ }
272
+ return [...shell.slice(0, -1), ...contributedRows, ...shell.slice(-1)];
273
+ }
274
+ function orderShortcuts(shell, contributions) {
275
+ return [...shell, ...(contributions?.snapshot() ?? [])].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
475
276
  }
476
277
  /**
477
278
  * Mount once at App root. Returns nothing — purely an effect.
@@ -481,9 +282,9 @@ export function buildShortcuts() {
481
282
  * Ctrl+Shift+1/2/3 (which Chrome maps to tab switching only WITHOUT Shift,
482
283
  * so we're safe, but we preventDefault anyway).
483
284
  */
484
- export function useGlobalShortcuts(keybindings) {
285
+ export function useGlobalShortcuts(keybindings, contributions) {
485
286
  useEffect(() => {
486
- const shortcuts = buildShortcuts();
287
+ const shell = buildShortcuts();
487
288
  const onKey = (e) => {
488
289
  // 0. IME composing — bail. Never intercept Chinese pinyin chord.
489
290
  if (isComposing(e))
@@ -499,11 +300,11 @@ export function useGlobalShortcuts(keybindings) {
499
300
  // 2. The contextual resolver gets first refusal inside the ONE capture
500
301
  // listener. A handled or disabled-but-claimed binding must not fall into
501
302
  // legacy application shortcuts; passthrough/unclaimed continues below.
502
- const contextual = keybindings?.handle(e);
303
+ const contextual = keybindings.handle(e);
503
304
  if (contextual?.status === 'handled' || contextual?.status === 'claimed-disabled')
504
305
  return;
505
- // 3. Find first matching legacy shortcut.
506
- for (const s of shortcuts) {
306
+ // 3. Read live host contributions after contextual first refusal.
307
+ for (const s of orderShortcuts(shell, contributions)) {
507
308
  // A focused preview owns camera/fly keys locally. Keep non-edit global
508
309
  // shortcuts available, but never route preview input to the main editor.
509
310
  if (shouldSkipGlobalShortcut(e, s))
@@ -532,7 +333,7 @@ export function useGlobalShortcuts(keybindings) {
532
333
  onKeyDown: onKey,
533
334
  });
534
335
  return disposeKeydownObservation;
535
- }, [keybindings]);
336
+ }, [keybindings, contributions]);
536
337
  }
537
338
  // macOS pretty-printing for shortcut combos shown in Settings.
538
339
  // Returns the canonical UI string. When platform is omitted, auto-detects macOS.
@@ -1,25 +1,3 @@
1
- /** menu-registry —— 顶部菜单栏的单一真值源 (SSOT for the menu bar).
2
- *
3
- * 一张注册表服务两个消费者 (压缩公理:消掉「Web 菜单和原生菜单是不是同一套?」):
4
- * 1. Web 端渲染器 (React) —— 通过 `snapshotMenu(...)` / `snapshotAllMenus()` + `onMenuChange`
5
- * 走 useSyncExternalStore 拿到当前菜单结构;
6
- * 2. Tauri 原生菜单桥接 —— 通过 `serializeMenusForNative(t)` 拿到已翻译、去函数、
7
- * JSON 可序列化的原生菜单契约,一次性 build native menu / 每次注册表变化时 rebuild.
8
- *
9
- * 设计与 action-registry.ts 对齐 (模块级 Map 页级单例 + change listener):
10
- * - 菜单项按 `menu`(顶层大类) + `group`(段落分组) + `order`(段内序) 三级组织;
11
- * - 同 id 重复注册 = **替换** (幂等,HMR / StrictMode 双挂载安全,cleanup
12
- * 走身份检查避免误删);
13
- * - 键位串遵循全仓 canonical combo 格式 (见 lib/global-shortcuts.ts `prettyCombo`),
14
- * 例:'Ctrl+S' / 'Ctrl+Shift+Z' / 'Ctrl+,'。Web 显示走 prettyCombo,原生菜单走
15
- * `serializeMenusForNative` 内部的 accelerator 转换 (Ctrl→CmdOrCtrl,保留 Shift/Alt)。
16
- *
17
- * 分隔符策略 (评审):**注册者不显式发 separator sentinel** —— 只填 `group`,
18
- * 渲染器按 `snapshotMenu` 返回结果中相邻两项的 `group` 差异自行绘制分隔线;
19
- * 原生序列化则在 `serializeMenusForNative` 中把段边界翻成 `separatorBefore=true`。
20
- * 这样避免维护「三种分隔来源」(注册者手插 + 渲染器 + 原生桥)—— 一处派生 (§2 Derive)。
21
- */
22
- /** 菜单顶层大类。品牌菜单 (brand) 在 macOS 原生菜单里通常对应 app 菜单,由 T5 决定放置策略。 */
23
1
  export type MenuId = 'brand' | 'file' | 'edit' | 'window' | 'build' | 'select' | 'help' | 'publish';
24
2
  export interface MenuItemDef {
25
3
  /** 稳定唯一 id,例:'file.save' / 'window.toggle_chat'。重复注册会抛错。 */
@@ -64,23 +42,6 @@ export interface MenuItemDef {
64
42
  * 原生序列化不含动态子项 (native 菜单结构在 build 时固化)。 */
65
43
  dynamicChildren?: () => MenuItemDef[];
66
44
  }
67
- /** 注册一项菜单 —— 同 id 重复注册 = 替换 (幂等,HMR / StrictMode 双挂载安全)。
68
- * 返回 cleanup 函数;cleanup 幂等 (身份检查:仅当当前项仍是本次注册的 def
69
- * 才 delete,避免 A 注册→A 注销 后误删掉 B 的 same-id 覆盖)。
70
- *
71
- * 设计变更 (T7 集成):初版为 throw-on-duplicate,与 action-registry 的
72
- * 「替换即幂等」策略不一致 —— React StrictMode 会双执行 App.tsx 的
73
- * bootstrapAppHost useEffect,两次 setup 并发跑到同一个模块级 Map 时必抛
74
- * 「duplicate menu item id」把整个菜单栏打空。改为 last-writer-wins 后
75
- * 两次 setup 都能顺利完成,先释放的 cleanup 通过身份检查不会误删,
76
- * 与 action-registry 的语义对齐 (§SSOT / 兄弟注册表一致性)。 */
77
- export declare function registerMenuItem(def: MenuItemDef): () => void;
78
- /** 注册表变更订阅 —— 供 useSyncExternalStore / native bridge rebuild 消费。 */
79
- export declare function onMenuChange(listener: () => void): () => void;
80
- /** 取单个菜单的排序后条目 —— 渲染器直接消费。 */
81
- export declare function snapshotMenu(menu: MenuId): MenuItemDef[];
82
- /** 取所有菜单的排序后条目,按 MenuId 组织。 */
83
- export declare function snapshotAllMenus(): Record<MenuId, MenuItemDef[]>;
84
45
  export interface NativeMenuItem {
85
46
  /** 对应 MenuItemDef.id。 */
86
47
  id: string;
@@ -101,13 +62,3 @@ export interface NativeMenu {
101
62
  menu: MenuId;
102
63
  items: NativeMenuItem[];
103
64
  }
104
- /** 序列化全部菜单为原生契约 —— T5 (Tauri 桥) 的唯一入口。契约要点:
105
- * - `when === false` 的项被 drop;
106
- * - `enabled` 缺省时默认 = `!!commandId` (无命令 = 禁用);
107
- * - `labelKey` 走 `translate` 翻译,函数不出墙;
108
- * - `keybinding` 转 native accelerator (Ctrl→CmdOrCtrl,Shift/Alt 保留);
109
- * - 段边界翻成 `separatorBefore=true` (每个菜单首项除外);
110
- * - 输出**完全 JSON 可序列化** (JSON.stringify 无损 roundtrip,无函数残留)。 */
111
- export declare function serializeMenusForNative(translate: (key: string) => string): NativeMenu[];
112
- /** 清空注册表 —— 仅供单测 beforeEach 使用。 */
113
- export declare function __resetMenuRegistryForTest(): void;