@forgeax/interface 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +25 -0
  2. package/dist/package/browser/ApplicationShell.js +3 -2
  3. package/dist/package/browser/appHostBootstrap.js +10 -2
  4. package/dist/package/browser/application.d.ts +0 -8
  5. package/dist/package/browser/application.js +0 -9
  6. package/dist/package/browser/components/MenuBar/MenuBar.d.ts +3 -2
  7. package/dist/package/browser/components/MenuBar/MenuBar.js +9 -33
  8. package/dist/package/browser/components/MenuBar/menubar-surface.d.ts +3 -2
  9. package/dist/package/browser/components/MenuBar/menubar-surface.js +16 -13
  10. package/dist/package/browser/core/app-shell/host.js +7 -2
  11. package/dist/package/browser/core/app-shell/types.d.ts +5 -1
  12. package/dist/package/browser/core/extensions/builtin-commands.js +1 -0
  13. package/dist/package/browser/core/extensions/builtin-menus.js +23 -3
  14. package/dist/package/browser/i18n/locales/en.json +0 -10
  15. package/dist/package/browser/i18n/locales/zh.json +0 -10
  16. package/dist/package/browser/lib/application-menu-projection.d.ts +5 -0
  17. package/dist/package/browser/lib/application-menu-projection.js +53 -0
  18. package/dist/package/browser/lib/global-shortcuts.d.ts +5 -74
  19. package/dist/package/browser/lib/global-shortcuts.js +29 -228
  20. package/dist/package/browser/lib/menu-registry.d.ts +0 -49
  21. package/dist/package/browser/lib/menu-registry.js +3 -197
  22. package/dist/package/browser/lib/menu-registry.test-utils.d.ts +11 -0
  23. package/dist/package/browser/lib/menu-registry.test-utils.js +28 -0
  24. package/dist/package/browser/lib/native-menu-bridge.d.ts +17 -3
  25. package/dist/package/browser/lib/native-menu-bridge.js +121 -81
  26. package/dist/package/node/ApplicationShell.js +3 -2
  27. package/dist/package/node/appHostBootstrap.js +10 -2
  28. package/dist/package/node/application.js +0 -9
  29. package/dist/package/node/components/MenuBar/MenuBar.js +9 -33
  30. package/dist/package/node/components/MenuBar/menubar-surface.js +16 -13
  31. package/dist/package/node/core/app-shell/host.js +7 -2
  32. package/dist/package/node/core/extensions/builtin-commands.js +1 -0
  33. package/dist/package/node/core/extensions/builtin-menus.js +23 -3
  34. package/dist/package/node/i18n/locales/en.json +0 -10
  35. package/dist/package/node/i18n/locales/zh.json +0 -10
  36. package/dist/package/node/lib/application-menu-projection.js +53 -0
  37. package/dist/package/node/lib/global-shortcuts.js +29 -228
  38. package/dist/package/node/lib/menu-registry.js +3 -197
  39. package/dist/package/node/lib/menu-registry.test-utils.js +28 -0
  40. package/dist/package/node/lib/native-menu-bridge.js +121 -81
  41. package/package.json +2 -2
  42. package/dist/package/browser/core/extensions/editor-commands.d.ts +0 -2
  43. package/dist/package/browser/core/extensions/editor-commands.js +0 -182
  44. package/dist/package/browser/lib/repair-builtin-menus.d.ts +0 -6
  45. package/dist/package/browser/lib/repair-builtin-menus.js +0 -18
  46. package/dist/package/node/core/extensions/editor-commands.js +0 -182
  47. package/dist/package/node/lib/repair-builtin-menus.js +0 -18
@@ -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/index.js';
23
23
  import { useHost } from '../../core/app-shell/index.js';
24
24
  import { useBrand } from '../../brand/BrandProvider.js';
25
25
  import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from '../ui/dropdown-menu.js';
26
- import { onMenuChange, snapshotAllMenus, } from '../../lib/menu-registry.js';
26
+ import { projectApplicationMenus } from '../../lib/application-menu-projection.js';
27
27
  import { prettyCombo } from '../../lib/global-shortcuts.js';
28
28
  import { useMenubarSurface } from './menubar-surface.js';
29
29
  import { openMenuFromTriggerPointerDown } from './menubar-open-state.js';
30
30
  import { isTauri } from '../../lib/platform/runtime.js';
31
31
  import { captureFocusedTextEditTarget } from '../../lib/text-edit-actions.js';
32
32
  import { getRecentGamesRevision, subscribeRecentGames, warmRecentGames, } from '../../lib/recent-games.js';
33
- import { repairBuiltinMenusIfEmpty } from '../../lib/repair-builtin-menus.js';
34
33
 
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
- // ── snapshot cache (stable identity across getSnapshot calls) ────────────
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 = t(item.labelKey);
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,
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { useEffect, useRef } from 'react';
19
19
  import { useSurface } from '../../lib/surface.js';
20
- import { snapshotAllMenus } from '../../lib/menu-registry.js';
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: t(item.labelKey),
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(snapshotAllMenus())) {
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.labelKey === target.label || t(kid.labelKey) === target.label))) ?? null;
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 && t(def.labelKey) === target.label)
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(snapshotAllMenus())) {
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(menusRevision, t, execute,
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.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, 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
  };
@@ -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 { registerMenuItem } from '../../lib/menu-registry.js';
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(registerMenuItem(def));
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,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
+ }