@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.
- package/README.md +25 -0
- package/dist/package/browser/ApplicationShell.js +3 -2
- package/dist/package/browser/appHostBootstrap.js +10 -2
- package/dist/package/browser/application.d.ts +0 -8
- package/dist/package/browser/application.js +0 -9
- package/dist/package/browser/components/MenuBar/MenuBar.d.ts +3 -2
- package/dist/package/browser/components/MenuBar/MenuBar.js +9 -33
- package/dist/package/browser/components/MenuBar/menubar-surface.d.ts +3 -2
- package/dist/package/browser/components/MenuBar/menubar-surface.js +16 -13
- package/dist/package/browser/core/app-shell/host.js +7 -2
- package/dist/package/browser/core/app-shell/types.d.ts +5 -1
- package/dist/package/browser/core/extensions/builtin-commands.js +1 -0
- package/dist/package/browser/core/extensions/builtin-menus.js +23 -3
- package/dist/package/browser/i18n/locales/en.json +0 -10
- package/dist/package/browser/i18n/locales/zh.json +0 -10
- package/dist/package/browser/lib/application-menu-projection.d.ts +5 -0
- package/dist/package/browser/lib/application-menu-projection.js +53 -0
- package/dist/package/browser/lib/global-shortcuts.d.ts +5 -74
- package/dist/package/browser/lib/global-shortcuts.js +29 -228
- package/dist/package/browser/lib/menu-registry.d.ts +0 -49
- package/dist/package/browser/lib/menu-registry.js +3 -197
- package/dist/package/browser/lib/menu-registry.test-utils.d.ts +11 -0
- package/dist/package/browser/lib/menu-registry.test-utils.js +28 -0
- package/dist/package/browser/lib/native-menu-bridge.d.ts +17 -3
- package/dist/package/browser/lib/native-menu-bridge.js +121 -81
- package/dist/package/node/ApplicationShell.js +3 -2
- package/dist/package/node/appHostBootstrap.js +10 -2
- package/dist/package/node/application.js +0 -9
- package/dist/package/node/components/MenuBar/MenuBar.js +9 -33
- package/dist/package/node/components/MenuBar/menubar-surface.js +16 -13
- package/dist/package/node/core/app-shell/host.js +7 -2
- package/dist/package/node/core/extensions/builtin-commands.js +1 -0
- package/dist/package/node/core/extensions/builtin-menus.js +23 -3
- package/dist/package/node/i18n/locales/en.json +0 -10
- package/dist/package/node/i18n/locales/zh.json +0 -10
- package/dist/package/node/lib/application-menu-projection.js +53 -0
- package/dist/package/node/lib/global-shortcuts.js +29 -228
- package/dist/package/node/lib/menu-registry.js +3 -197
- package/dist/package/node/lib/menu-registry.test-utils.js +28 -0
- package/dist/package/node/lib/native-menu-bridge.js +121 -81
- package/package.json +2 -2
- package/dist/package/browser/core/extensions/editor-commands.d.ts +0 -2
- package/dist/package/browser/core/extensions/editor-commands.js +0 -182
- package/dist/package/browser/lib/repair-builtin-menus.d.ts +0 -6
- package/dist/package/browser/lib/repair-builtin-menus.js +0 -18
- package/dist/package/node/core/extensions/editor-commands.js +0 -182
- 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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
303
|
+
const contextual = keybindings.handle(e);
|
|
503
304
|
if (contextual?.status === 'handled' || contextual?.status === 'claimed-disabled')
|
|
504
305
|
return;
|
|
505
|
-
// 3.
|
|
506
|
-
for (const s of
|
|
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;
|