@forgeax/interface 0.6.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/dist/package/browser/ApplicationShell.js +2 -1
- package/dist/package/browser/appHostBootstrap.js +10 -0
- 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 +5 -3
- package/dist/package/browser/core/app-shell/types.d.ts +3 -1
- package/dist/package/browser/core/extensions/builtin-menus.js +23 -3
- 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/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 +2 -1
- package/dist/package/node/appHostBootstrap.js +10 -0
- 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 +5 -3
- package/dist/package/node/core/extensions/builtin-menus.js +23 -3
- package/dist/package/node/lib/application-menu-projection.js +53 -0
- 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/lib/repair-builtin-menus.d.ts +0 -6
- package/dist/package/browser/lib/repair-builtin-menus.js +0 -18
- package/dist/package/node/lib/repair-builtin-menus.js +0 -18
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { useEffect, useRef } from 'react';
|
|
19
19
|
import { useSurface } from '../../lib/surface.js';
|
|
20
|
-
import {
|
|
20
|
+
import { projectApplicationMenus } from '../../lib/application-menu-projection.js';
|
|
21
21
|
import { warmRecentGames } from '../../lib/recent-games.js';
|
|
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,9 +13,9 @@ import { derivePanelRenderers } from './derive-panel-renderers.js';
|
|
|
13
13
|
import { DEFAULT_PANEL_RENDERERS } from '../../components/DockShell/panelRenderers.js';
|
|
14
14
|
import { installPageNavigation } from '../page-navigation.js';
|
|
15
15
|
import { createContextualKeybindings } from '../contextual-keybindings.js';
|
|
16
|
-
import { createApplicationShortcutRegistry } from '@forgeax/app-shell/application';
|
|
16
|
+
import { createApplicationShortcutRegistry, createApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
17
17
|
const BUILT_IN_CAPS = [
|
|
18
|
-
'commands', 'keybindings', 'shortcuts', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
|
|
18
|
+
'commands', 'keybindings', 'shortcuts', 'menus', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
|
|
19
19
|
'activities', 'resourceEditors',
|
|
20
20
|
];
|
|
21
21
|
export function createAppHost(deps = {}) {
|
|
@@ -25,6 +25,7 @@ export function createAppHost(deps = {}) {
|
|
|
25
25
|
caps.add(c);
|
|
26
26
|
const commands = createCommandsRegistry();
|
|
27
27
|
const shortcuts = createApplicationShortcutRegistry();
|
|
28
|
+
const menus = createApplicationMenuRegistry();
|
|
28
29
|
const keybindings = createContextualKeybindings(commands, {
|
|
29
30
|
onCommandError(error, commandId) {
|
|
30
31
|
log.error(`[app-shell] keybinding command "${commandId}" failed`, error);
|
|
@@ -64,7 +65,7 @@ export function createAppHost(deps = {}) {
|
|
|
64
65
|
const extensionFields = {};
|
|
65
66
|
let activeSetup = null;
|
|
66
67
|
const base = {
|
|
67
|
-
commands, keybindings, shortcuts, bus, storage, contextKeys,
|
|
68
|
+
commands, keybindings, shortcuts, menus, bus, storage, contextKeys,
|
|
68
69
|
get panels() { return panelsSnapshot(); },
|
|
69
70
|
panelActions,
|
|
70
71
|
panelControls,
|
|
@@ -184,6 +185,7 @@ export function createAppHost(deps = {}) {
|
|
|
184
185
|
await pageSession.dispose();
|
|
185
186
|
keybindings.dispose();
|
|
186
187
|
shortcuts.dispose();
|
|
188
|
+
menus.dispose();
|
|
187
189
|
bus.destroy();
|
|
188
190
|
},
|
|
189
191
|
};
|
|
@@ -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/index.js';
|
|
18
18
|
import { isDockPanelVisible } from '@forgeax/app-shell/dock';
|
|
19
19
|
import { useShellStore } from '../../store.js';
|
|
20
20
|
import { getRecentGames } from '../../lib/recent-games.js';
|
|
@@ -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
|
|
@@ -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
|
+
}
|
|
@@ -1,197 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
// ─── 注册表本体 (模块级 Map,页级单例) ────────────────────────────────────────
|
|
23
|
-
const items = new Map();
|
|
24
|
-
const changeListeners = new Set();
|
|
25
|
-
function notifyChange() {
|
|
26
|
-
for (const cb of changeListeners) {
|
|
27
|
-
try {
|
|
28
|
-
cb();
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
/* listener 异常不传染 */
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
/** 注册一项菜单 —— 同 id 重复注册 = 替换 (幂等,HMR / StrictMode 双挂载安全)。
|
|
36
|
-
* 返回 cleanup 函数;cleanup 幂等 (身份检查:仅当当前项仍是本次注册的 def
|
|
37
|
-
* 才 delete,避免 A 注册→A 注销 后误删掉 B 的 same-id 覆盖)。
|
|
38
|
-
*
|
|
39
|
-
* 设计变更 (T7 集成):初版为 throw-on-duplicate,与 action-registry 的
|
|
40
|
-
* 「替换即幂等」策略不一致 —— React StrictMode 会双执行 App.tsx 的
|
|
41
|
-
* bootstrapAppHost useEffect,两次 setup 并发跑到同一个模块级 Map 时必抛
|
|
42
|
-
* 「duplicate menu item id」把整个菜单栏打空。改为 last-writer-wins 后
|
|
43
|
-
* 两次 setup 都能顺利完成,先释放的 cleanup 通过身份检查不会误删,
|
|
44
|
-
* 与 action-registry 的语义对齐 (§SSOT / 兄弟注册表一致性)。 */
|
|
45
|
-
export function registerMenuItem(def) {
|
|
46
|
-
items.set(def.id, def);
|
|
47
|
-
notifyChange();
|
|
48
|
-
return () => {
|
|
49
|
-
if (items.get(def.id) === def) {
|
|
50
|
-
items.delete(def.id);
|
|
51
|
-
notifyChange();
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
/** 注册表变更订阅 —— 供 useSyncExternalStore / native bridge rebuild 消费。 */
|
|
56
|
-
export function onMenuChange(listener) {
|
|
57
|
-
changeListeners.add(listener);
|
|
58
|
-
return () => {
|
|
59
|
-
changeListeners.delete(listener);
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
// ─── 派生视图:排序与快照 (SSOT 是 items Map;所有视图都是纯 derive) ─────────
|
|
63
|
-
/** 排序规则:先按 group 的**首次出现顺序**分段,段内按 order 升序 (相同 order 用
|
|
64
|
-
* 插入顺序破平局,稳定排序)。JS `Array.prototype.sort` 从 ES2019 起要求稳定,
|
|
65
|
-
* Bun/V8/JSC 均已合规,直接用即可。 */
|
|
66
|
-
function sortMenuItems(list) {
|
|
67
|
-
const groupFirstIndex = new Map();
|
|
68
|
-
list.forEach((item, idx) => {
|
|
69
|
-
if (!groupFirstIndex.has(item.group))
|
|
70
|
-
groupFirstIndex.set(item.group, idx);
|
|
71
|
-
});
|
|
72
|
-
const decorated = list.map((item, idx) => ({ item, idx }));
|
|
73
|
-
decorated.sort((a, b) => {
|
|
74
|
-
const ga = groupFirstIndex.get(a.item.group);
|
|
75
|
-
const gb = groupFirstIndex.get(b.item.group);
|
|
76
|
-
if (ga !== gb)
|
|
77
|
-
return ga - gb;
|
|
78
|
-
if (a.item.order !== b.item.order)
|
|
79
|
-
return a.item.order - b.item.order;
|
|
80
|
-
return a.idx - b.idx; // 稳定破平局:插入顺序
|
|
81
|
-
});
|
|
82
|
-
return decorated.map((d) => d.item);
|
|
83
|
-
}
|
|
84
|
-
/** 取单个菜单的排序后条目 —— 渲染器直接消费。 */
|
|
85
|
-
export function snapshotMenu(menu) {
|
|
86
|
-
const list = [];
|
|
87
|
-
for (const def of items.values()) {
|
|
88
|
-
if (def.menu === menu)
|
|
89
|
-
list.push(def);
|
|
90
|
-
}
|
|
91
|
-
return sortMenuItems(list);
|
|
92
|
-
}
|
|
93
|
-
const ALL_MENUS = ['brand', 'file', 'edit', 'window', 'build', 'select', 'help', 'publish'];
|
|
94
|
-
/** 取所有菜单的排序后条目,按 MenuId 组织。 */
|
|
95
|
-
export function snapshotAllMenus() {
|
|
96
|
-
const out = {};
|
|
97
|
-
for (const m of ALL_MENUS)
|
|
98
|
-
out[m] = snapshotMenu(m);
|
|
99
|
-
return out;
|
|
100
|
-
}
|
|
101
|
-
/** 键位串 → 原生 accelerator 转换。canonical 形如 'Ctrl+Shift+S';原生形 'CmdOrCtrl+Shift+S'
|
|
102
|
-
* (Tauri/Electron 通用惯例:Ctrl 走 CmdOrCtrl,让 macOS 走 ⌘、其它平台走 Ctrl)。 */
|
|
103
|
-
function toNativeAccelerator(combo) {
|
|
104
|
-
return combo
|
|
105
|
-
.split('+')
|
|
106
|
-
.map((seg) => (seg === 'Ctrl' ? 'CmdOrCtrl' : seg))
|
|
107
|
-
.join('+');
|
|
108
|
-
}
|
|
109
|
-
/** 递归序列化一项 (含 children) —— 返回值必须完全无函数,可直接 JSON.stringify。 */
|
|
110
|
-
function serializeItem(def, translate) {
|
|
111
|
-
if (def.when && !def.when())
|
|
112
|
-
return null; // 隐藏项直接 drop
|
|
113
|
-
// Static `children` OR a `dynamicChildren` derivation — both serialize the
|
|
114
|
-
// same way. dynamicChildren is evaluated at serialize time (native menu is
|
|
115
|
-
// rebuilt on every registry change, so it re-derives with the cache current
|
|
116
|
-
// at that moment). Web + native thus share one derivation (§Derive / §SSOT).
|
|
117
|
-
const childDefs = def.children && def.children.length > 0
|
|
118
|
-
? def.children
|
|
119
|
-
: def.dynamicChildren
|
|
120
|
-
? def.dynamicChildren()
|
|
121
|
-
: null;
|
|
122
|
-
// enabled: explicit predicate wins; a submenu opener (has children) defaults
|
|
123
|
-
// enabled even without a command; else `!!commandId`. Mirrors the web
|
|
124
|
-
// renderer's resolveEnabled so both bars agree on which items are clickable.
|
|
125
|
-
const enabledResolved = def.enabled
|
|
126
|
-
? def.enabled()
|
|
127
|
-
: (childDefs && childDefs.length > 0)
|
|
128
|
-
? true
|
|
129
|
-
: !!def.commandId;
|
|
130
|
-
const out = {
|
|
131
|
-
id: def.id,
|
|
132
|
-
label: translate(def.labelKey),
|
|
133
|
-
enabled: enabledResolved,
|
|
134
|
-
};
|
|
135
|
-
if (def.keybinding)
|
|
136
|
-
out.accelerator = toNativeAccelerator(def.keybinding);
|
|
137
|
-
if (def.danger)
|
|
138
|
-
out.danger = true;
|
|
139
|
-
if (childDefs && childDefs.length > 0) {
|
|
140
|
-
const kids = [];
|
|
141
|
-
for (const child of childDefs) {
|
|
142
|
-
const ser = serializeItem(child, translate);
|
|
143
|
-
if (ser)
|
|
144
|
-
kids.push(ser);
|
|
145
|
-
}
|
|
146
|
-
if (kids.length > 0)
|
|
147
|
-
out.children = kids;
|
|
148
|
-
}
|
|
149
|
-
return out;
|
|
150
|
-
}
|
|
151
|
-
/** 序列化全部菜单为原生契约 —— T5 (Tauri 桥) 的唯一入口。契约要点:
|
|
152
|
-
* - `when === false` 的项被 drop;
|
|
153
|
-
* - `enabled` 缺省时默认 = `!!commandId` (无命令 = 禁用);
|
|
154
|
-
* - `labelKey` 走 `translate` 翻译,函数不出墙;
|
|
155
|
-
* - `keybinding` 转 native accelerator (Ctrl→CmdOrCtrl,Shift/Alt 保留);
|
|
156
|
-
* - 段边界翻成 `separatorBefore=true` (每个菜单首项除外);
|
|
157
|
-
* - 输出**完全 JSON 可序列化** (JSON.stringify 无损 roundtrip,无函数残留)。 */
|
|
158
|
-
export function serializeMenusForNative(translate) {
|
|
159
|
-
const out = [];
|
|
160
|
-
for (const menu of ALL_MENUS) {
|
|
161
|
-
const sorted = snapshotMenu(menu);
|
|
162
|
-
const serialized = [];
|
|
163
|
-
let prevGroup = null;
|
|
164
|
-
for (const def of sorted) {
|
|
165
|
-
const ser = serializeItem(def, translate);
|
|
166
|
-
if (!ser) {
|
|
167
|
-
// when=false 的项被 drop;下一项若换 group 依然按已见 group 判段边界。
|
|
168
|
-
prevGroup = def.group;
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
if (prevGroup !== null && def.group !== prevGroup)
|
|
172
|
-
ser.separatorBefore = true;
|
|
173
|
-
serialized.push(ser);
|
|
174
|
-
prevGroup = def.group;
|
|
175
|
-
}
|
|
176
|
-
out.push({ menu, items: serialized });
|
|
177
|
-
}
|
|
178
|
-
return out;
|
|
179
|
-
}
|
|
180
|
-
// ─── Vite HMR: preserve registrations across hot module swap ───────────────
|
|
181
|
-
if (import.meta.hot) {
|
|
182
|
-
import.meta.hot.dispose((data) => {
|
|
183
|
-
data.menuItems = [...items.entries()];
|
|
184
|
-
});
|
|
185
|
-
const saved = import.meta.hot.data.menuItems;
|
|
186
|
-
if (saved) {
|
|
187
|
-
for (const [id, def] of saved)
|
|
188
|
-
items.set(id, def);
|
|
189
|
-
notifyChange();
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
// ─── 测试辅助 (生产代码不要调) ─────────────────────────────────────────────
|
|
193
|
-
/** 清空注册表 —— 仅供单测 beforeEach 使用。 */
|
|
194
|
-
export function __resetMenuRegistryForTest() {
|
|
195
|
-
items.clear();
|
|
196
|
-
changeListeners.clear();
|
|
197
|
-
}
|
|
1
|
+
// Interface-owned built-in menu descriptors and native wire types.
|
|
2
|
+
// Runtime registration, ordering, subscriptions and disposal belong to host.menus.
|
|
3
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Test data adapter only. Production readers receive their host registry.
|
|
2
|
+
import { createApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
3
|
+
import { projectApplicationMenus, serializeApplicationMenusForNative } from './application-menu-projection.js';
|
|
4
|
+
let menus = createApplicationMenuRegistry();
|
|
5
|
+
let translate = (key) => key;
|
|
6
|
+
export const getTestMenus = () => menus;
|
|
7
|
+
export const setTestMenuTranslator = (next) => { translate = next; };
|
|
8
|
+
function contribution(def) {
|
|
9
|
+
return {
|
|
10
|
+
...def, groupOrder: 0,
|
|
11
|
+
get label() { return translate(def.labelKey); },
|
|
12
|
+
children: def.children?.map(contribution),
|
|
13
|
+
dynamicChildren: def.dynamicChildren ? () => def.dynamicChildren().map(contribution) : undefined,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export const registerMenuItem = (def) => menus.register(contribution(def));
|
|
17
|
+
export const onMenuChange = (listener) => menus.subscribe(listener);
|
|
18
|
+
export const snapshotMenu = (menu) => menus.snapshot(menu);
|
|
19
|
+
export const snapshotAllMenus = () => projectApplicationMenus(menus.snapshot());
|
|
20
|
+
export function serializeMenusForNative(t) {
|
|
21
|
+
translate = t;
|
|
22
|
+
return serializeApplicationMenusForNative(menus.snapshot());
|
|
23
|
+
}
|
|
24
|
+
export function __resetMenuRegistryForTest() {
|
|
25
|
+
menus.dispose();
|
|
26
|
+
menus = createApplicationMenuRegistry();
|
|
27
|
+
translate = key => key;
|
|
28
|
+
}
|