@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
package/README.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
forgeax 前端工程(Studio 三栏 UI)。
|
|
4
4
|
|
|
5
|
+
## Application shortcut ownership (0.6.0)
|
|
6
|
+
|
|
7
|
+
> [!IMPORTANT]
|
|
8
|
+
> `configureInterfaceKeyboardRouter`, `KeyboardRouterDeps` and the private
|
|
9
|
+
> `editor-commands` extension have been removed. Products must install their
|
|
10
|
+
> Editor-owned keyboard extension through application bootstrap overrides.
|
|
11
|
+
> Interface consumes exact `@forgeax/app-shell@0.87.1`; no module-global Editor
|
|
12
|
+
> callback setter or second keyboard listener remains.
|
|
13
|
+
|
|
14
|
+
| Owner | Responsibility |
|
|
15
|
+
| :-- | :-- |
|
|
16
|
+
| App Shell | Per-host `shortcuts` registration, live snapshots and stale-callback fencing |
|
|
17
|
+
| Interface | One capture listener, contextual first refusal, shell bindings, typing/preview/surface guards and focused `text.selectAll` |
|
|
18
|
+
| Editor | Editor shortcuts, Play Escape, viewport callbacks and the twelve `editor.*` commands |
|
|
19
|
+
|
|
20
|
+
`useGlobalShortcuts(host.keybindings, host.shortcuts)` requires both host APIs.
|
|
21
|
+
Shortcut settings read `buildShortcuts(host.shortcuts)` so contributions added
|
|
22
|
+
or removed after boot are not hidden behind an earlier snapshot. A matched
|
|
23
|
+
shortcut returning `false` still terminates routing while yielding browser
|
|
24
|
+
default behavior. Shell bindings retain priority 10, Play-only Escape uses 20,
|
|
25
|
+
ordinary contributions use 0, and the palette fallback uses -20.
|
|
26
|
+
|
|
27
|
+
The normal/detached product shells, startup and global CSS remain Interface
|
|
28
|
+
entrypoints; this release does not complete their migration.
|
|
29
|
+
|
|
5
30
|
## Public action registry migration (0.5.0)
|
|
6
31
|
|
|
7
32
|
> [!IMPORTANT]
|
|
@@ -25,7 +25,7 @@ import { isTauri } from './lib/platform/runtime';
|
|
|
25
25
|
import './App.css';
|
|
26
26
|
export { ApplicationDetachedShell } from './ApplicationDetachedShell';
|
|
27
27
|
function KeyboardRouter({ runtime }) {
|
|
28
|
-
useGlobalShortcuts(runtime.host.keybindings);
|
|
28
|
+
useGlobalShortcuts(runtime.host.keybindings, runtime.host.shortcuts);
|
|
29
29
|
return null;
|
|
30
30
|
}
|
|
31
31
|
export function isApplicationOnboardingEnabled(onboarding) {
|
|
@@ -46,7 +46,8 @@ export function ApplicationShell({ runtime, onboarding }) {
|
|
|
46
46
|
useEffect(() => {
|
|
47
47
|
if (!isTauri())
|
|
48
48
|
return;
|
|
49
|
-
|
|
49
|
+
return initNativeMenuBridge({
|
|
50
|
+
menus: runtime.host.menus,
|
|
50
51
|
execute: (id, args) => runtime.host.commands.execute(id, args),
|
|
51
52
|
translate: t,
|
|
52
53
|
});
|
|
@@ -21,7 +21,6 @@ import { foundationStorageExtension } from './core/extensions/foundation-storage
|
|
|
21
21
|
import { builtinCommandsExtension } from './core/extensions/builtin-commands';
|
|
22
22
|
import { hostCommandsExtension } from './core/extensions/host-commands';
|
|
23
23
|
import { builtinMenusExtension } from './core/extensions/builtin-menus';
|
|
24
|
-
import { editorCommandsExtension } from './core/extensions/editor-commands';
|
|
25
24
|
import { panelsViewportExtension } from './core/extensions/panels-viewport';
|
|
26
25
|
import { panelsChatExtension } from './core/extensions/panels-chat';
|
|
27
26
|
import { chromeStatusBarExtension } from './core/extensions/chrome-statusbar';
|
|
@@ -62,9 +61,14 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
62
61
|
...m,
|
|
63
62
|
async setup(ctx) {
|
|
64
63
|
control.beginSetup(m);
|
|
64
|
+
const menuCleanups = [];
|
|
65
65
|
try {
|
|
66
66
|
const cleanups = [];
|
|
67
67
|
let pageCleanup;
|
|
68
|
+
for (const menu of m.contributes?.menus ?? []) {
|
|
69
|
+
menuCleanups.push(host.menus.register(menu));
|
|
70
|
+
}
|
|
71
|
+
cleanups.push(...menuCleanups);
|
|
68
72
|
if (m.contributes?.panels) {
|
|
69
73
|
cleanups.push(control.contributePanels(m.id, m.contributes.panels));
|
|
70
74
|
}
|
|
@@ -95,6 +99,11 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
95
99
|
await c();
|
|
96
100
|
};
|
|
97
101
|
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
for (const cleanup of menuCleanups.slice().reverse())
|
|
104
|
+
await cleanup();
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
98
107
|
finally {
|
|
99
108
|
control.endSetup();
|
|
100
109
|
}
|
|
@@ -108,7 +117,6 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
108
117
|
builtinCommandsExtension,
|
|
109
118
|
hostCommandsExtension,
|
|
110
119
|
builtinMenusExtension,
|
|
111
|
-
editorCommandsExtension,
|
|
112
120
|
panelsViewportExtension,
|
|
113
121
|
panelsChatExtension,
|
|
114
122
|
chromeStatusBarExtension,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { AppHost as ApplicationHost } from '@forgeax/app-shell/application';
|
|
2
2
|
import { type AppHostBootstrapOverrides, type AppHostBootstrapResult } from './appHostBootstrap';
|
|
3
3
|
import { type Locale } from './i18n';
|
|
4
|
-
import { type KeyboardRouterDeps } from './lib/global-shortcuts';
|
|
5
4
|
export type InterfaceApplicationRuntime = AppHostBootstrapResult & {
|
|
6
5
|
readonly host: AppHostBootstrapResult['host'] & ApplicationHost;
|
|
7
6
|
};
|
|
@@ -16,12 +15,6 @@ export interface InterfaceApplicationStartupOptions {
|
|
|
16
15
|
* are observed. Product errors still propagate.
|
|
17
16
|
*/
|
|
18
17
|
export declare function installApplicationOverlayRedirect(overlayId: string, onRedirect: () => void): () => void;
|
|
19
|
-
/**
|
|
20
|
-
* Configures product-owned editor callbacks consumed by Interface's single
|
|
21
|
-
* keyboard router. Product assemblies use this public application boundary;
|
|
22
|
-
* the router implementation remains private to Interface.
|
|
23
|
-
*/
|
|
24
|
-
export declare function configureInterfaceKeyboardRouter(deps: KeyboardRouterDeps | null): void;
|
|
25
18
|
/**
|
|
26
19
|
* Starts the shared Interface application runtime without selecting a product
|
|
27
20
|
* entry or product extensions. IDE and compatibility callers supply their own
|
|
@@ -29,5 +22,4 @@ export declare function configureInterfaceKeyboardRouter(deps: KeyboardRouterDep
|
|
|
29
22
|
*/
|
|
30
23
|
export declare function startInterfaceApplication(overrides?: AppHostBootstrapOverrides, options?: InterfaceApplicationStartupOptions): Promise<InterfaceApplicationRuntime>;
|
|
31
24
|
export type { AppHostBootstrapOverrides } from './appHostBootstrap';
|
|
32
|
-
export type { KeyboardRouterDeps } from './lib/global-shortcuts';
|
|
33
25
|
export { configureStudioDomainClients, type StudioDomainClients, } from './store-parts/domain-clients';
|
|
@@ -2,7 +2,6 @@ import { bootstrapAppHost, } from './appHostBootstrap';
|
|
|
2
2
|
import { bootStageAppMounted } from './boot/driver';
|
|
3
3
|
import { changeLanguage, initI18n } from './i18n';
|
|
4
4
|
import { useShellStore } from './store';
|
|
5
|
-
import { registerKeyboardRouterDeps, } from './lib/global-shortcuts';
|
|
6
5
|
/**
|
|
7
6
|
* Lets a product replace one shell overlay with its own destination. Interface
|
|
8
7
|
* owns observation, clearing and synchronous reentry; the product owns routing.
|
|
@@ -24,14 +23,6 @@ export function installApplicationOverlayRedirect(overlayId, onRedirect) {
|
|
|
24
23
|
}
|
|
25
24
|
});
|
|
26
25
|
}
|
|
27
|
-
/**
|
|
28
|
-
* Configures product-owned editor callbacks consumed by Interface's single
|
|
29
|
-
* keyboard router. Product assemblies use this public application boundary;
|
|
30
|
-
* the router implementation remains private to Interface.
|
|
31
|
-
*/
|
|
32
|
-
export function configureInterfaceKeyboardRouter(deps) {
|
|
33
|
-
registerKeyboardRouterDeps(deps);
|
|
34
|
-
}
|
|
35
26
|
/**
|
|
36
27
|
* Starts the shared Interface application runtime without selecting a product
|
|
37
28
|
* entry or product extensions. IDE and compatibility callers supply their own
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type TFunction } from '../../i18n';
|
|
2
|
-
import {
|
|
2
|
+
import type { MenuId } from '../../lib/menu-registry';
|
|
3
|
+
import type { ApplicationMenuItem as MenuItemDef } from '@forgeax/app-shell/application';
|
|
3
4
|
import './MenuBar.css';
|
|
4
5
|
/** 第三个参数 itemId 是**记账用**的稳定菜单项 id(如 file.save)。可选,所以
|
|
5
6
|
* 只传两参的既有调用点照常编译。为什么需要它:人机同账要求两侧记录形状一致 ——
|
|
@@ -9,7 +10,7 @@ import './MenuBar.css';
|
|
|
9
10
|
type Execute = (id: string, args?: unknown, itemId?: string) => void;
|
|
10
11
|
interface TopMenuProps {
|
|
11
12
|
menu: MenuId;
|
|
12
|
-
items: MenuItemDef[];
|
|
13
|
+
items: readonly MenuItemDef[];
|
|
13
14
|
t: TFunction;
|
|
14
15
|
execute: Execute;
|
|
15
16
|
/** Controlled open flag — the whole bar shares one "which menu is open"
|
|
@@ -17,20 +17,19 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
17
17
|
* time; the underlying accessor is identical). This keeps keyboard shortcuts,
|
|
18
18
|
* command palette, and menu clicks on one entry.
|
|
19
19
|
*/
|
|
20
|
-
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
|
20
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
|
21
21
|
import { BookOpen, BoxSelect, Check, Clipboard, Clock, Code, Copy, FilePlus, FlipHorizontal2, Focus, FolderOpen, FolderSearch, Gamepad2, Github, Globe, GraduationCap, Hash, Info, Keyboard, LayoutGrid, Maximize, MessageCircle, Newspaper, Package, Pencil, Play, Redo2, RefreshCw, RotateCcw, Save, SaveAll, Scale, Scan, Scissors, ScrollText, Search, Settings, Shapes, Sparkles, Square, SquareDashed, Store, Trash2, Undo2, Upload, X, } from 'lucide-react';
|
|
22
22
|
import { useTranslation } from '../../i18n';
|
|
23
23
|
import { useHost } from '../../core/app-shell';
|
|
24
24
|
import { useBrand } from '../../brand/BrandProvider';
|
|
25
25
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from '../ui/dropdown-menu';
|
|
26
|
-
import {
|
|
26
|
+
import { projectApplicationMenus } from '../../lib/application-menu-projection';
|
|
27
27
|
import { prettyCombo } from '../../lib/global-shortcuts';
|
|
28
28
|
import { useMenubarSurface } from './menubar-surface';
|
|
29
29
|
import { openMenuFromTriggerPointerDown } from './menubar-open-state';
|
|
30
30
|
import { isTauri } from '../../lib/platform/runtime';
|
|
31
31
|
import { captureFocusedTextEditTarget } from '../../lib/text-edit-actions';
|
|
32
32
|
import { getRecentGamesRevision, subscribeRecentGames, warmRecentGames, } from '../../lib/recent-games';
|
|
33
|
-
import { repairBuiltinMenusIfEmpty } from '../../lib/repair-builtin-menus';
|
|
34
33
|
import './MenuBar.css';
|
|
35
34
|
// Icon name → lucide component. Mirrors ContextMenu.tsx's MENU_ICONS pattern
|
|
36
35
|
// (single-file lookup so an unknown name gracefully renders no icon rather
|
|
@@ -83,30 +82,7 @@ const MENU_ICONS = {
|
|
|
83
82
|
upload: Upload,
|
|
84
83
|
x: X,
|
|
85
84
|
};
|
|
86
|
-
|
|
87
|
-
// snapshotAllMenus() computes a fresh object each call; useSyncExternalStore
|
|
88
|
-
// requires that repeated getSnapshot() reads return the SAME identity until
|
|
89
|
-
// the store actually changes, else React re-renders on every read (potential
|
|
90
|
-
// infinite loop). Module-level cache + a single change-listener keeps identity
|
|
91
|
-
// stable between registrations. Registry lives as long as this module, so a
|
|
92
|
-
// single unbound listener at import time is fine (no cleanup handle needed).
|
|
93
|
-
let cachedSnapshot = snapshotAllMenus();
|
|
94
|
-
onMenuChange(() => { cachedSnapshot = snapshotAllMenus(); });
|
|
95
|
-
function getSnapshot() {
|
|
96
|
-
return cachedSnapshot;
|
|
97
|
-
}
|
|
98
|
-
// SSR/server snapshot — stable identity across calls. This app is a pure
|
|
99
|
-
// client build, so this branch never actually fires; providing it satisfies
|
|
100
|
-
// the useSyncExternalStore contract and future-proofs against SSR.
|
|
101
|
-
const EMPTY_SNAPSHOT = Object.freeze({
|
|
102
|
-
brand: [], file: [], edit: [], window: [], build: [], select: [], help: [], publish: [],
|
|
103
|
-
});
|
|
104
|
-
function getServerSnapshot() {
|
|
105
|
-
return EMPTY_SNAPSHOT;
|
|
106
|
-
}
|
|
107
|
-
function subscribe(cb) {
|
|
108
|
-
return onMenuChange(cb);
|
|
109
|
-
}
|
|
85
|
+
const EMPTY_MENU_ITEMS = Object.freeze([]);
|
|
110
86
|
// Top-level dropdowns rendered on web, in order. `publish` is excluded (TopBar
|
|
111
87
|
// owns the Publish CTA at the far right); `brand` renders separately at the
|
|
112
88
|
// far left.
|
|
@@ -130,7 +106,7 @@ function MenuItemRow({ item, t, execute }) {
|
|
|
130
106
|
if (item.when && !item.when())
|
|
131
107
|
return null;
|
|
132
108
|
const enabled = resolveEnabled(item);
|
|
133
|
-
const label =
|
|
109
|
+
const label = item.label;
|
|
134
110
|
const combo = item.keybinding ? prettyCombo(item.keybinding) : '';
|
|
135
111
|
const checked = item.checked ? item.checked() : false;
|
|
136
112
|
const hasStaticChildren = !!item.children && item.children.length > 0;
|
|
@@ -209,12 +185,12 @@ export function TopMenu({ menu, items, t, execute, open, onOpenChange, onTrigger
|
|
|
209
185
|
}
|
|
210
186
|
// ── Public component ─────────────────────────────────────────────────────
|
|
211
187
|
export function MenuBar() {
|
|
212
|
-
// StrictMode dispose / Vite HMR can leave the registry empty while chrome
|
|
213
|
-
// stays mounted — repair synchronously so the first render sees menus.
|
|
214
|
-
repairBuiltinMenusIfEmpty();
|
|
215
188
|
const { t, i18n } = useTranslation();
|
|
216
|
-
const menus = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
217
189
|
const host = useHost();
|
|
190
|
+
const subscribe = useCallback((listener) => host.menus.subscribe(listener), [host]);
|
|
191
|
+
const getSnapshot = useCallback(() => host.menus.snapshot(), [host]);
|
|
192
|
+
const items = useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_MENU_ITEMS);
|
|
193
|
+
const menus = useMemo(() => projectApplicationMenus(items), [items]);
|
|
218
194
|
// Same command bus TopBar uses. Fire-and-forget: menu clicks never await
|
|
219
195
|
// the command result — that would block dropdown-close animation.
|
|
220
196
|
// 纯执行,不记账。AI 经 host.menubar.invoke 走的就是这一个 —— 它那一路已经由
|
|
@@ -245,7 +221,7 @@ export function MenuBar() {
|
|
|
245
221
|
// 传 i18n.language 而不是 t:`t` 是模块级恒定标识(i18n/index.ts 刻意如此,
|
|
246
222
|
// 免得列进依赖数组造成无限重渲染),用它当依赖等于永不重投影 —— 切语言后 DOM
|
|
247
223
|
// 变了、snapshot 还是旧语言,AI 按 label 导航与用户所见错位(2026-08-06 探测)。
|
|
248
|
-
useMenubarSurface(menus, t, executeForSurface, i18n.language);
|
|
224
|
+
useMenubarSurface(host.menus, t, executeForSurface, i18n.language);
|
|
249
225
|
// One shared "which top-level menu is open" value for the whole bar. This is
|
|
250
226
|
// what makes it behave like a native/UE menu bar rather than N unrelated
|
|
251
227
|
// dropdowns: only one panel is open at a time, and once ANY panel is open,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TFunction } from '../../i18n';
|
|
2
|
+
import type { ApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
2
3
|
export interface MenuTreeNode {
|
|
3
4
|
id: string;
|
|
4
5
|
label: string;
|
|
@@ -20,10 +21,10 @@ export interface MenuTreeNode {
|
|
|
20
21
|
/** AI 路径的执行器**必须返回 promise**:run 会 await 它,命令失败 → throw →
|
|
21
22
|
* ack ok=false。人路径的 fire-and-forget 版本(不卡下拉动画)不在此签名内。 */
|
|
22
23
|
type Execute = (commandId: string, args?: unknown) => Promise<unknown>;
|
|
23
|
-
export declare function projectMenuTree(t: TFunction): Record<string, MenuTreeNode[]>;
|
|
24
|
+
export declare function projectMenuTree(t: TFunction, registry: ApplicationMenuRegistry): Record<string, MenuTreeNode[]>;
|
|
24
25
|
/** MenuBar 挂载时注册 host.menubar。menusRevision 传注册表快照对象,引用一变
|
|
25
26
|
* (注册表变更触发的重渲染)就重新投影 PUT。 */
|
|
26
|
-
export declare function useMenubarSurface(
|
|
27
|
+
export declare function useMenubarSurface(registry: ApplicationMenuRegistry, t: TFunction, execute: Execute,
|
|
27
28
|
/** 当前语言。**必须**由调用方传:`t` 的标识是模块级恒定的(见 i18n/index.ts
|
|
28
29
|
* 的注释:列进依赖会造成无限重渲染),拿它当依赖 = 永不重投影。 */
|
|
29
30
|
language?: string): void;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { useEffect, useRef } from 'react';
|
|
19
19
|
import { useSurface } from '../../lib/surface';
|
|
20
|
-
import {
|
|
20
|
+
import { projectApplicationMenus } from '../../lib/application-menu-projection';
|
|
21
21
|
import { warmRecentGames } from '../../lib/recent-games';
|
|
22
22
|
const MENUBAR_SCHEMA = {
|
|
23
23
|
type: 'object',
|
|
@@ -42,7 +42,7 @@ function projectItem(item, t) {
|
|
|
42
42
|
.filter((node) => node !== null);
|
|
43
43
|
return {
|
|
44
44
|
id: item.id,
|
|
45
|
-
label:
|
|
45
|
+
label: item.label,
|
|
46
46
|
kind: isSub ? 'submenu' : item.commandId ? 'command' : 'placeholder',
|
|
47
47
|
...(item.commandId ? { commandId: item.commandId } : {}),
|
|
48
48
|
...(item.args !== undefined ? { args: item.args } : {}),
|
|
@@ -53,9 +53,9 @@ function projectItem(item, t) {
|
|
|
53
53
|
...(children && children.length ? { children } : {}),
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
-
export function projectMenuTree(t) {
|
|
56
|
+
export function projectMenuTree(t, registry) {
|
|
57
57
|
const out = {};
|
|
58
|
-
for (const [menu, defs] of Object.entries(
|
|
58
|
+
for (const [menu, defs] of Object.entries(projectApplicationMenus(registry.snapshot()))) {
|
|
59
59
|
out[menu] = defs
|
|
60
60
|
.map((def) => projectItem(def, t))
|
|
61
61
|
.filter((node) => node !== null);
|
|
@@ -64,7 +64,7 @@ export function projectMenuTree(t) {
|
|
|
64
64
|
}
|
|
65
65
|
/** 在注册表里解析一个可执行菜单项:先静态(含 children 递归),再对动态子菜单
|
|
66
66
|
* 求值按 id 或 label 匹配(动态项的 labelKey 就是显示名,如游戏名)。 */
|
|
67
|
-
function resolveInvokable(target, t) {
|
|
67
|
+
function resolveInvokable(target, t, registry) {
|
|
68
68
|
/** 与 projectItem 同一条谓词:`when()` 为假 = 该项在界面上根本不存在。
|
|
69
69
|
* 2026-08-06 探测:此前只有投影过滤 when(),解析器完全不看 —— 一个在 snapshot
|
|
70
70
|
* 和 DOM 里都不存在的项仍可被 invoke 执行。人点不到的东西 AI 不能点,否则
|
|
@@ -89,7 +89,7 @@ function resolveInvokable(target, t) {
|
|
|
89
89
|
return null;
|
|
90
90
|
}
|
|
91
91
|
return kids.filter(visible).find((kid) => (target.itemId && kid.id === target.itemId)
|
|
92
|
-
|| (target.label && (kid.
|
|
92
|
+
|| (target.label && (kid.label === target.label || kid.label === target.label))) ?? null;
|
|
93
93
|
};
|
|
94
94
|
const walk = (defs) => {
|
|
95
95
|
for (const def of defs) {
|
|
@@ -99,7 +99,7 @@ function resolveInvokable(target, t) {
|
|
|
99
99
|
continue;
|
|
100
100
|
if (target.itemId && def.id === target.itemId)
|
|
101
101
|
return def;
|
|
102
|
-
if (target.label && !target.itemId &&
|
|
102
|
+
if (target.label && !target.itemId && def.label === target.label)
|
|
103
103
|
return def;
|
|
104
104
|
if (def.children) {
|
|
105
105
|
const hit = walk(def.children);
|
|
@@ -114,7 +114,7 @@ function resolveInvokable(target, t) {
|
|
|
114
114
|
}
|
|
115
115
|
return null;
|
|
116
116
|
};
|
|
117
|
-
for (const defs of Object.values(
|
|
117
|
+
for (const defs of Object.values(projectApplicationMenus(registry.snapshot()))) {
|
|
118
118
|
const hit = walk(defs);
|
|
119
119
|
if (hit)
|
|
120
120
|
return hit;
|
|
@@ -123,10 +123,13 @@ function resolveInvokable(target, t) {
|
|
|
123
123
|
}
|
|
124
124
|
/** MenuBar 挂载时注册 host.menubar。menusRevision 传注册表快照对象,引用一变
|
|
125
125
|
* (注册表变更触发的重渲染)就重新投影 PUT。 */
|
|
126
|
-
export function useMenubarSurface(
|
|
126
|
+
export function useMenubarSurface(registry, t, execute,
|
|
127
127
|
/** 当前语言。**必须**由调用方传:`t` 的标识是模块级恒定的(见 i18n/index.ts
|
|
128
128
|
* 的注释:列进依赖会造成无限重渲染),拿它当依赖 = 永不重投影。 */
|
|
129
129
|
language) {
|
|
130
|
+
const menusRevision = registry.snapshot();
|
|
131
|
+
const registryRef = useRef(registry);
|
|
132
|
+
registryRef.current = registry;
|
|
130
133
|
const executeRef = useRef(execute);
|
|
131
134
|
executeRef.current = execute;
|
|
132
135
|
const tRef = useRef(t);
|
|
@@ -135,7 +138,7 @@ language) {
|
|
|
135
138
|
id: 'host.menubar',
|
|
136
139
|
layer: 'host',
|
|
137
140
|
schema: MENUBAR_SCHEMA,
|
|
138
|
-
initialSnapshot: { menus: projectMenuTree(t) },
|
|
141
|
+
initialSnapshot: { menus: projectMenuTree(t, registry) },
|
|
139
142
|
actions: {
|
|
140
143
|
invoke: {
|
|
141
144
|
id: 'invoke',
|
|
@@ -149,14 +152,14 @@ language) {
|
|
|
149
152
|
},
|
|
150
153
|
run: async (raw) => {
|
|
151
154
|
const target = (raw ?? {});
|
|
152
|
-
let def = resolveInvokable(target, tRef.current);
|
|
155
|
+
let def = resolveInvokable(target, tRef.current, registryRef.current);
|
|
153
156
|
if (!def) {
|
|
154
157
|
// 冷缓存(2026-08-06 外审 B6②):动态子项(最近游戏)只在人点开 File 下拉
|
|
155
158
|
// 或 Tauri 原生菜单构建时被预热;web 模式下人没点过 File,dynamicChildren()
|
|
156
159
|
// 恒空,AI 按 id/label 解析动态项必然 not found —— 桌面能用、web 不能用。
|
|
157
160
|
// AI 先到时主动预热一次再重解析,与人"点开就看见"对齐。
|
|
158
161
|
await warmRecentGames();
|
|
159
|
-
def = resolveInvokable(target, tRef.current);
|
|
162
|
+
def = resolveInvokable(target, tRef.current, registryRef.current);
|
|
160
163
|
}
|
|
161
164
|
if (!def)
|
|
162
165
|
throw new Error(`menu item not found: ${JSON.stringify(target)}`);
|
|
@@ -195,7 +198,7 @@ language) {
|
|
|
195
198
|
},
|
|
196
199
|
});
|
|
197
200
|
useEffect(() => {
|
|
198
|
-
surface.setSnapshot({ menus: projectMenuTree(tRef.current) });
|
|
201
|
+
surface.setSnapshot({ menus: projectMenuTree(tRef.current, registry) });
|
|
199
202
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
200
203
|
}, [menusRevision, language]);
|
|
201
204
|
}
|
|
@@ -13,8 +13,9 @@ import { derivePanelRenderers } from './derive-panel-renderers';
|
|
|
13
13
|
import { DEFAULT_PANEL_RENDERERS } from '../../components/DockShell/panelRenderers';
|
|
14
14
|
import { installPageNavigation } from '../page-navigation';
|
|
15
15
|
import { createContextualKeybindings } from '../contextual-keybindings';
|
|
16
|
+
import { createApplicationShortcutRegistry, createApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
16
17
|
const BUILT_IN_CAPS = [
|
|
17
|
-
'commands', 'keybindings', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
|
|
18
|
+
'commands', 'keybindings', 'shortcuts', 'menus', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
|
|
18
19
|
'activities', 'resourceEditors',
|
|
19
20
|
];
|
|
20
21
|
export function createAppHost(deps = {}) {
|
|
@@ -23,6 +24,8 @@ export function createAppHost(deps = {}) {
|
|
|
23
24
|
for (const c of BUILT_IN_CAPS)
|
|
24
25
|
caps.add(c);
|
|
25
26
|
const commands = createCommandsRegistry();
|
|
27
|
+
const shortcuts = createApplicationShortcutRegistry();
|
|
28
|
+
const menus = createApplicationMenuRegistry();
|
|
26
29
|
const keybindings = createContextualKeybindings(commands, {
|
|
27
30
|
onCommandError(error, commandId) {
|
|
28
31
|
log.error(`[app-shell] keybinding command "${commandId}" failed`, error);
|
|
@@ -62,7 +65,7 @@ export function createAppHost(deps = {}) {
|
|
|
62
65
|
const extensionFields = {};
|
|
63
66
|
let activeSetup = null;
|
|
64
67
|
const base = {
|
|
65
|
-
commands, keybindings, bus, storage, contextKeys,
|
|
68
|
+
commands, keybindings, shortcuts, menus, bus, storage, contextKeys,
|
|
66
69
|
get panels() { return panelsSnapshot(); },
|
|
67
70
|
panelActions,
|
|
68
71
|
panelControls,
|
|
@@ -181,6 +184,8 @@ export function createAppHost(deps = {}) {
|
|
|
181
184
|
removePageNavigation();
|
|
182
185
|
await pageSession.dispose();
|
|
183
186
|
keybindings.dispose();
|
|
187
|
+
shortcuts.dispose();
|
|
188
|
+
menus.dispose();
|
|
184
189
|
bus.destroy();
|
|
185
190
|
},
|
|
186
191
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ApplicationShortcutRegistry, ApplicationMenuRegistry, ApplicationMenuItem } from '@forgeax/app-shell/application';
|
|
1
2
|
import type { ExtensionManifest, Cleanup } from '../extension-foundation';
|
|
2
3
|
import type { EventBus } from '../extension-foundation/bus';
|
|
3
4
|
import type { CommandsRegistry, CommandDescriptor } from '../extension-foundation/commands';
|
|
@@ -103,7 +104,7 @@ export interface AppBusEventMap extends Record<string, unknown> {
|
|
|
103
104
|
provider: string;
|
|
104
105
|
};
|
|
105
106
|
}
|
|
106
|
-
export type HostCapability = 'commands' | 'keybindings' | 'bus' | 'storage' | 'panels' | 'panelActions' | 'panelControls' | 'contextKeys' | 'pages' | 'activities' | 'resourceEditors' | 'session' | 'observability' | 'editor' | (string & {});
|
|
107
|
+
export type HostCapability = 'commands' | 'keybindings' | 'shortcuts' | 'bus' | 'storage' | 'panels' | 'panelActions' | 'panelControls' | 'contextKeys' | 'pages' | 'activities' | 'resourceEditors' | 'session' | 'observability' | 'editor' | (string & {});
|
|
107
108
|
export interface AppLogger {
|
|
108
109
|
debug(message: string, ...rest: unknown[]): void;
|
|
109
110
|
info(message: string, ...rest: unknown[]): void;
|
|
@@ -113,6 +114,8 @@ export interface AppLogger {
|
|
|
113
114
|
export interface AppHostBase {
|
|
114
115
|
readonly commands: CommandsRegistry;
|
|
115
116
|
readonly keybindings: ContextualKeybindingsApi;
|
|
117
|
+
readonly shortcuts: ApplicationShortcutRegistry;
|
|
118
|
+
readonly menus: ApplicationMenuRegistry;
|
|
116
119
|
readonly bus: EventBus<AppBusEventMap>;
|
|
117
120
|
readonly storage: StorageApi;
|
|
118
121
|
readonly contextKeys: ContextKeysApi;
|
|
@@ -158,6 +161,7 @@ export interface AppExtensionContext {
|
|
|
158
161
|
* before setup() runs and removed with the extension's cleanup; a pure-UI
|
|
159
162
|
* extension is just data (no setup at all). */
|
|
160
163
|
export interface AppExtensionContributes {
|
|
164
|
+
readonly menus?: readonly ApplicationMenuItem[];
|
|
161
165
|
readonly panels?: Partial<PanelRenderers>;
|
|
162
166
|
readonly panelActions?: readonly PanelActionContribution[];
|
|
163
167
|
readonly panelControls?: readonly PanelControlContribution[];
|
|
@@ -38,6 +38,7 @@ export const builtinCommandsExtension = {
|
|
|
38
38
|
registerTextEditCommand('text.cut', '剪切输入框选区', 'cut');
|
|
39
39
|
registerTextEditCommand('text.copy', '复制输入框选区', 'copy');
|
|
40
40
|
registerTextEditCommand('text.paste', '粘贴到输入框', 'paste');
|
|
41
|
+
registerTextEditCommand('text.selectAll', 'Select all focused text', 'selectAll');
|
|
41
42
|
cleanups.push(registerCommand({
|
|
42
43
|
id: 'app.panel.open',
|
|
43
44
|
title: 'Open (or focus) a dock panel by id',
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Mirrors the style of `builtin-commands.ts`: a single `AppExtension` whose
|
|
15
15
|
// `setup(ctx)` calls the registrar for every item, collects the cleanups, and
|
|
16
16
|
// returns a reverse-order cleanup runner.
|
|
17
|
-
import {
|
|
17
|
+
import { t } from '../../i18n';
|
|
18
18
|
import { isDockPanelVisible } from '@forgeax/app-shell/dock';
|
|
19
19
|
import { useShellStore } from '../../store';
|
|
20
20
|
import { getRecentGames } from '../../lib/recent-games';
|
|
@@ -28,10 +28,30 @@ const isVisible = (id) => () => isDockPanelVisible(id);
|
|
|
28
28
|
// mounted, `chatpanelCollapsed` flips its collapsed state — invert for
|
|
29
29
|
// "checked = visible".
|
|
30
30
|
const isChatpanelOpen = () => !useShellStore.getState().chatpanelCollapsed;
|
|
31
|
+
// Product section order is explicit, independent of extension activation order.
|
|
32
|
+
const SECTION_ORDER = {
|
|
33
|
+
brand: { app: 10, about: 20 }, file: { game: 10, file: 20, io: 30, recent: 10 },
|
|
34
|
+
edit: { history: 10, clipboard: 20, find: 30, version: 40 },
|
|
35
|
+
window: { panels: 10, layout: 20 }, build: { run: 10, export: 20 },
|
|
36
|
+
select: { basic: 10, byCond: 20, view: 30 },
|
|
37
|
+
help: { docs: 10, resources: 20, app: 30, about: 40 },
|
|
38
|
+
};
|
|
39
|
+
function localizeMenu(def) {
|
|
40
|
+
const { labelKey, children, dynamicChildren, ...metadata } = def;
|
|
41
|
+
const groupOrder = SECTION_ORDER[def.menu]?.[def.group];
|
|
42
|
+
if (groupOrder === undefined)
|
|
43
|
+
throw new Error(`Missing menu section order: ${def.menu}/${def.group}`);
|
|
44
|
+
return {
|
|
45
|
+
...metadata, groupOrder,
|
|
46
|
+
get label() { return t(labelKey); },
|
|
47
|
+
...(children ? { children: children.map(localizeMenu) } : {}),
|
|
48
|
+
...(dynamicChildren ? { dynamicChildren: () => dynamicChildren().map(localizeMenu) } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
31
51
|
export const builtinMenusExtension = {
|
|
32
52
|
id: 'builtin-menus',
|
|
33
53
|
version: '1.0.0',
|
|
34
|
-
setup() {
|
|
54
|
+
setup({ host }) {
|
|
35
55
|
const cleanups = [];
|
|
36
56
|
// Every item to register — sorted by menu / group / order so the shape
|
|
37
57
|
// reads top-to-bottom the way it renders. The registrar itself re-sorts
|
|
@@ -240,7 +260,7 @@ export const builtinMenusExtension = {
|
|
|
240
260
|
commandId: 'app.open_url', args: { url: 'https://www.apache.org/licenses/LICENSE-2.0' } },
|
|
241
261
|
];
|
|
242
262
|
for (const def of items)
|
|
243
|
-
cleanups.push(
|
|
263
|
+
cleanups.push(host.menus.register(localizeMenu(def)));
|
|
244
264
|
return () => {
|
|
245
265
|
// Reverse order so first-registered is torn down last — same pattern
|
|
246
266
|
// as builtin-commands.ts; `.slice()` clones the array so a repeat unload
|
|
@@ -1288,22 +1288,12 @@
|
|
|
1288
1288
|
"toggleDashboard": "Toggle Dashboard overview panel",
|
|
1289
1289
|
"toggleSettings": "Open / close Settings overlay",
|
|
1290
1290
|
"openChangelog": "Settings → About (changelog)",
|
|
1291
|
-
"hideSelected": "Hide selected entities (UE parity · editor-only, never affects the game)",
|
|
1292
|
-
"hideUnselected": "Hide unselected entities (isolate selection)",
|
|
1293
|
-
"showAllHidden": "Show all hidden entities",
|
|
1294
1291
|
"closeOverlay": "Close current overlay · browser fullscreen → game fullscreen → Settings → Dashboard in order",
|
|
1295
1292
|
"modePreview": "Switch to Preview mode",
|
|
1296
1293
|
"modePage": "Switch to Page mode",
|
|
1297
1294
|
"switchPageN": "Switch to extension {{n}} (Blender parity — indexed into extension list)",
|
|
1298
1295
|
"openExtensions": "Settings → Extensions (was the Bus mode tab)",
|
|
1299
1296
|
"focusComposer": "Focus the right ChatPanel composer",
|
|
1300
|
-
"duplicateSelection": "Duplicate selection",
|
|
1301
|
-
"toggleViewportGameView": "Toggle viewport Game View",
|
|
1302
|
-
"undo": "Undo",
|
|
1303
|
-
"redo": "Redo",
|
|
1304
|
-
"save": "Save",
|
|
1305
|
-
"viewportNavigation": "Viewport navigation and gizmo mode",
|
|
1306
|
-
"shieldGameInput": "Shield game input while editor-owned",
|
|
1307
1297
|
"toggleCommandPalette": "Toggle command palette"
|
|
1308
1298
|
},
|
|
1309
1299
|
"analytics": {
|
|
@@ -1288,22 +1288,12 @@
|
|
|
1288
1288
|
"toggleDashboard": "切换 Dashboard 总览面板",
|
|
1289
1289
|
"toggleSettings": "打开 / 关闭 Settings 浮层",
|
|
1290
1290
|
"openChangelog": "Settings → 关于(更新日志)",
|
|
1291
|
-
"hideSelected": "隐藏选中实体(UE 对标 · 编辑器临时隐藏,不影响游戏)",
|
|
1292
|
-
"hideUnselected": "隐藏未选中实体(孤立选择)",
|
|
1293
|
-
"showAllHidden": "显示所有隐藏实体",
|
|
1294
1291
|
"closeOverlay": "关闭当前浮层 · 浏览器全屏 → 游戏全屏 → Settings → Dashboard 依次撤回",
|
|
1295
1292
|
"modePreview": "切到 Preview 模式",
|
|
1296
1293
|
"modePage": "切到 Page 模式",
|
|
1297
1294
|
"switchPageN": "切到第 {{n}} 个 extension(Blender 风格 · 按列表索引)",
|
|
1298
1295
|
"openExtensions": "Settings → Extensions(原 Bus mode tab)",
|
|
1299
1296
|
"focusComposer": "聚焦右侧 ChatPanel 输入框",
|
|
1300
|
-
"duplicateSelection": "复制选中项",
|
|
1301
|
-
"toggleViewportGameView": "切换视口 Game View",
|
|
1302
|
-
"undo": "撤销",
|
|
1303
|
-
"redo": "重做",
|
|
1304
|
-
"save": "保存",
|
|
1305
|
-
"viewportNavigation": "视口导航与 Gizmo 模式",
|
|
1306
|
-
"shieldGameInput": "编辑器接管时屏蔽游戏输入",
|
|
1307
1297
|
"toggleCommandPalette": "切换命令面板"
|
|
1308
1298
|
},
|
|
1309
1299
|
"analytics": {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ApplicationMenuItem } from '@forgeax/app-shell/application';
|
|
2
|
+
import type { MenuId, NativeMenu } from './menu-registry';
|
|
3
|
+
export declare function projectApplicationMenus(items: readonly ApplicationMenuItem[]): Record<MenuId, readonly ApplicationMenuItem[]>;
|
|
4
|
+
/** Pure native projection of the same immutable snapshot used by the web menu. */
|
|
5
|
+
export declare function serializeApplicationMenusForNative(items: readonly ApplicationMenuItem[]): NativeMenu[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Product menu locations; contributions and their lifetime belong to the host. */
|
|
2
|
+
const MENU_IDS = ['brand', 'file', 'edit', 'window', 'build', 'select', 'help', 'publish'];
|
|
3
|
+
export function projectApplicationMenus(items) {
|
|
4
|
+
const result = {
|
|
5
|
+
brand: [], file: [], edit: [], window: [], build: [], select: [], help: [], publish: [],
|
|
6
|
+
};
|
|
7
|
+
for (const menu of MENU_IDS)
|
|
8
|
+
result[menu] = items.filter(item => item.menu === menu);
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
function serializeItem(item) {
|
|
12
|
+
if (item.when && !item.when())
|
|
13
|
+
return null;
|
|
14
|
+
const children = item.children?.length ? item.children : item.dynamicChildren?.();
|
|
15
|
+
const result = {
|
|
16
|
+
id: item.id,
|
|
17
|
+
// Already resolved by the owner, including live locale getters. Never
|
|
18
|
+
// reinterpret a contributor's literal label as an Interface locale key.
|
|
19
|
+
label: item.label,
|
|
20
|
+
enabled: item.enabled ? item.enabled() : children?.length ? true : !!item.commandId,
|
|
21
|
+
};
|
|
22
|
+
if (item.keybinding) {
|
|
23
|
+
result.accelerator = item.keybinding.split('+')
|
|
24
|
+
.map(part => part === 'Ctrl' ? 'CmdOrCtrl' : part).join('+');
|
|
25
|
+
}
|
|
26
|
+
if (item.danger)
|
|
27
|
+
result.danger = true;
|
|
28
|
+
if (children?.length) {
|
|
29
|
+
const projected = children.map(serializeItem).filter((child) => child !== null);
|
|
30
|
+
if (projected.length)
|
|
31
|
+
result.children = projected;
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
/** Pure native projection of the same immutable snapshot used by the web menu. */
|
|
36
|
+
export function serializeApplicationMenusForNative(items) {
|
|
37
|
+
const menus = projectApplicationMenus(items);
|
|
38
|
+
return MENU_IDS.map(menu => {
|
|
39
|
+
const projected = [];
|
|
40
|
+
let previousGroup = null;
|
|
41
|
+
for (const item of menus[menu]) {
|
|
42
|
+
const native = serializeItem(item);
|
|
43
|
+
// Retain the existing bridge's hidden-row group boundary semantics.
|
|
44
|
+
if (native) {
|
|
45
|
+
if (previousGroup !== null && previousGroup !== item.group)
|
|
46
|
+
native.separatorBefore = true;
|
|
47
|
+
projected.push(native);
|
|
48
|
+
}
|
|
49
|
+
previousGroup = item.group;
|
|
50
|
+
}
|
|
51
|
+
return { menu, items: projected };
|
|
52
|
+
});
|
|
53
|
+
}
|