@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
|
@@ -19,13 +19,14 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { isTauri } from './platform/runtime.js';
|
|
21
21
|
import { warmRecentGames } from './recent-games.js';
|
|
22
|
-
import {
|
|
22
|
+
import { subscribeLocale } from '../i18n/index.js';
|
|
23
|
+
import { projectApplicationMenus, serializeApplicationMenusForNative } from './application-menu-projection.js';
|
|
23
24
|
// ─── Menu id → command lookup (原生点击回吐用) ─────────────────────────────
|
|
24
25
|
/** 展平所有菜单项 (含 children),建 id → def 的索引,供 menu:invoke 回吐时 O(1)
|
|
25
26
|
* 查表。不缓存:每次点击都 fresh 一遍,让 when/enabled 变化立刻生效;菜单量级
|
|
26
27
|
* 几十项,遍历开销可忽略。 */
|
|
27
|
-
function findMenuItemById(id) {
|
|
28
|
-
const all =
|
|
28
|
+
function findMenuItemById(id, menus) {
|
|
29
|
+
const all = projectApplicationMenus(menus.snapshot());
|
|
29
30
|
for (const menuId of Object.keys(all)) {
|
|
30
31
|
const found = findInList(all[menuId], id);
|
|
31
32
|
if (found)
|
|
@@ -35,6 +36,8 @@ function findMenuItemById(id) {
|
|
|
35
36
|
}
|
|
36
37
|
function findInList(list, id) {
|
|
37
38
|
for (const item of list) {
|
|
39
|
+
if ((item.when && !item.when()) || (item.enabled && !item.enabled()))
|
|
40
|
+
continue;
|
|
38
41
|
if (item.id === id)
|
|
39
42
|
return item;
|
|
40
43
|
// Recurse into static children AND dynamic ones — a native click on a
|
|
@@ -67,85 +70,122 @@ export function fxTrace(line) {
|
|
|
67
70
|
console.debug('[fx-trace]', line);
|
|
68
71
|
traceSink?.(line);
|
|
69
72
|
}
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// current list. Web warms on File-dropdown open; native has no such hook, so
|
|
78
|
-
// we warm here before every rebuild. Failures leave the last cache intact.
|
|
79
|
-
trace('push: warmRecentGames…');
|
|
80
|
-
await warmRecentGames();
|
|
81
|
-
trace('push: warmRecentGames done, serializing…');
|
|
82
|
-
const raw = serializeMenusForNative(translate);
|
|
83
|
-
// 补顶层 title —— 与 MenuBar.tsx 的 `t('menubar.${menu}')` 保持一致。
|
|
84
|
-
const payload = raw.map((m) => ({
|
|
85
|
-
...m,
|
|
86
|
-
title: translate(`menubar.${m.menu}`),
|
|
87
|
-
}));
|
|
88
|
-
const fileIds = raw.find((m) => m.menu === 'file')?.items.map((i) => i.id) ?? [];
|
|
89
|
-
trace(`push: serialized menus=${payload.length} file.items=[${fileIds.join(',')}]`);
|
|
90
|
-
try {
|
|
91
|
-
await invoke('set_app_menu', { payload });
|
|
92
|
-
trace('push: set_app_menu resolved');
|
|
93
|
-
}
|
|
94
|
-
catch (err) {
|
|
95
|
-
trace(`push: set_app_menu REJECTED ${err?.message ?? String(err)}`);
|
|
96
|
-
console.warn('[native-menu-bridge] set_app_menu failed:', err?.message ?? err);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
// ─── Init (唯一对外入口) ────────────────────────────────────────────────
|
|
100
|
-
/** 幂等标记:StrictMode 双 invoke / boot 双路径都可能重入 init,原生只该被安装一次。
|
|
101
|
-
* 一旦装上:后续调用直接返回。 */
|
|
102
|
-
let installed = false;
|
|
103
|
-
/** Public entry —— App.tsx 在 boot 完成后调用一次。返回 Promise 让调用方能
|
|
104
|
-
* await (不必须);浏览器形态立刻 resolve()。 */
|
|
105
|
-
export async function initNativeMenuBridge(opts) {
|
|
106
|
-
if (!isTauri())
|
|
107
|
-
return; // web 形态 no-op
|
|
108
|
-
if (installed)
|
|
109
|
-
return;
|
|
110
|
-
installed = true;
|
|
111
|
-
const { translate } = opts;
|
|
112
|
-
// 懒加载 Tauri API —— 与 runtime.ts 的其它加载器同风格,避免把 chunk 塞进 web bundle。
|
|
113
|
-
const [{ invoke }, eventMod] = await Promise.all([
|
|
114
|
-
import('@tauri-apps/api/core'),
|
|
115
|
-
import('@tauri-apps/api/event'),
|
|
116
|
-
]);
|
|
117
|
-
traceSink = (line) => { void invoke('fx_trace', { line }).catch(() => { }); };
|
|
118
|
-
trace('init: tauri api loaded, starting first push…');
|
|
119
|
-
// 1. 原生点击回吐 —— Rust 侧 on_menu_event emit 'menu:invoke' { id },
|
|
120
|
-
// 我们查注册表拿到 commandId + args,派发到 command bus。
|
|
121
|
-
// 先注册监听器再做首次 push:首次 push 会预热 listGames,若接口卡住,
|
|
122
|
-
// 旧菜单仍能响应点击,不会出现"菜单显示但点击无反应"的窗口。
|
|
123
|
-
await eventMod.listen('menu:invoke', (ev) => {
|
|
124
|
-
const id = ev.payload?.id;
|
|
125
|
-
trace(`recv: menu:invoke id=${String(id)}`);
|
|
126
|
-
if (!id)
|
|
127
|
-
return;
|
|
128
|
-
const def = findMenuItemById(id);
|
|
129
|
-
if (!def) {
|
|
130
|
-
// 原生菜单栏比 web 快照晚一拍时可能出现;下次 push 会对齐。
|
|
131
|
-
trace(`recv: id=${id} NOT FOUND in registry — dropped`);
|
|
132
|
-
console.warn('[native-menu-bridge] menu:invoke for unknown id:', id);
|
|
133
|
-
return;
|
|
73
|
+
/** One mounted shell owns all native subscriptions, including asynchronous setup. */
|
|
74
|
+
export function installNativeMenuBridge(opts, deps) {
|
|
75
|
+
let disposed = false;
|
|
76
|
+
const cleanups = [];
|
|
77
|
+
const report = (error) => {
|
|
78
|
+
try {
|
|
79
|
+
deps.reportError(error);
|
|
134
80
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
81
|
+
catch { /* Reporting must not prevent cleanup. */ }
|
|
82
|
+
};
|
|
83
|
+
const safely = (cleanup) => {
|
|
84
|
+
try {
|
|
85
|
+
cleanup();
|
|
138
86
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
87
|
+
catch (error) {
|
|
88
|
+
report(error);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const retain = (cleanup) => {
|
|
92
|
+
if (disposed)
|
|
93
|
+
safely(cleanup);
|
|
94
|
+
else
|
|
95
|
+
cleanups.push(cleanup);
|
|
96
|
+
};
|
|
97
|
+
const dispose = () => {
|
|
98
|
+
if (disposed)
|
|
99
|
+
return;
|
|
100
|
+
disposed = true;
|
|
101
|
+
for (const cleanup of cleanups.splice(0).reverse())
|
|
102
|
+
safely(cleanup);
|
|
103
|
+
};
|
|
104
|
+
void (async () => {
|
|
105
|
+
const transport = await deps.loadTransport();
|
|
106
|
+
if (disposed)
|
|
107
|
+
return;
|
|
108
|
+
const sink = (line) => {
|
|
109
|
+
if (!disposed)
|
|
110
|
+
void transport.invoke('fx_trace', { line }).catch(() => { });
|
|
111
|
+
};
|
|
112
|
+
traceSink = sink;
|
|
113
|
+
retain(() => { if (traceSink === sink)
|
|
114
|
+
traceSink = null; });
|
|
115
|
+
// Subscribe before warming so existing native items remain responsive.
|
|
116
|
+
const off = await transport.listen(id => {
|
|
117
|
+
if (disposed || !id)
|
|
118
|
+
return;
|
|
119
|
+
try {
|
|
120
|
+
const item = findMenuItemById(id, opts.menus);
|
|
121
|
+
if (!item?.commandId)
|
|
122
|
+
return;
|
|
123
|
+
void Promise.resolve(opts.execute(item.commandId, item.args)).catch(report);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
report(error);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
retain(off);
|
|
130
|
+
if (disposed)
|
|
131
|
+
return;
|
|
132
|
+
// Serialize pushes: a slower old update must not overwrite a newer locale
|
|
133
|
+
// or host snapshot. Events arriving during warming are read in that push.
|
|
134
|
+
let dirty = false;
|
|
135
|
+
let running = false;
|
|
136
|
+
const refresh = () => {
|
|
137
|
+
if (disposed)
|
|
138
|
+
return;
|
|
139
|
+
dirty = true;
|
|
140
|
+
if (running)
|
|
141
|
+
return;
|
|
142
|
+
running = true;
|
|
143
|
+
void (async () => {
|
|
144
|
+
try {
|
|
145
|
+
while (!disposed && dirty) {
|
|
146
|
+
dirty = false;
|
|
147
|
+
try {
|
|
148
|
+
await deps.warmRecentGames();
|
|
149
|
+
if (disposed)
|
|
150
|
+
return;
|
|
151
|
+
dirty = false;
|
|
152
|
+
const payload = serializeApplicationMenusForNative(opts.menus.snapshot())
|
|
153
|
+
.map(menu => ({ ...menu, title: opts.translate(`menubar.${menu.menu}`) }));
|
|
154
|
+
await transport.invoke('set_app_menu', { payload });
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
report(error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
running = false;
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
};
|
|
166
|
+
retain(opts.menus.subscribe(refresh));
|
|
167
|
+
retain(deps.subscribeLocale(refresh));
|
|
168
|
+
refresh();
|
|
169
|
+
})().catch(error => { report(error); dispose(); });
|
|
170
|
+
return dispose;
|
|
171
|
+
}
|
|
172
|
+
/** Browser shells install nothing. React effect cleanup also fences late imports. */
|
|
173
|
+
export function initNativeMenuBridge(opts) {
|
|
174
|
+
if (!isTauri())
|
|
175
|
+
return () => { };
|
|
176
|
+
return installNativeMenuBridge(opts, {
|
|
177
|
+
async loadTransport() {
|
|
178
|
+
const [core, events] = await Promise.all([
|
|
179
|
+
import('@tauri-apps/api/core'),
|
|
180
|
+
import('@tauri-apps/api/event'),
|
|
181
|
+
]);
|
|
182
|
+
return {
|
|
183
|
+
invoke: (command, args) => core.invoke(command, args),
|
|
184
|
+
listen: listener => events.listen('menu:invoke', event => listener(event.payload?.id)),
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
warmRecentGames,
|
|
188
|
+
subscribeLocale: listener => subscribeLocale(listener),
|
|
189
|
+
reportError: error => console.warn('[native-menu-bridge]', error),
|
|
147
190
|
});
|
|
148
|
-
// 3. 首次推送 —— 让原生菜单栏与当前注册表对齐。
|
|
149
|
-
await pushMenusToNative(invoke, translate);
|
|
150
|
-
trace('init: first push returned');
|
|
151
191
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/interface",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ForgeaX product interface shell and application composition boundary.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"lint:dep": "depcruise -c .dependency-cruiser.cjs src"
|
|
93
93
|
},
|
|
94
94
|
"dependencies": {
|
|
95
|
-
"@forgeax/app-shell": "0.
|
|
95
|
+
"@forgeax/app-shell": "0.88.0",
|
|
96
96
|
"@forgeax/extension-host": "0.3.2",
|
|
97
97
|
"@forgeax/extension-platform": "0.5.0",
|
|
98
98
|
"@forgeax/toolkit": "0.1.2",
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
// packages/interface/src/core/extensions/editor-commands.ts
|
|
2
|
-
//
|
|
3
|
-
// Command-bus wrappers for editor-domain actions (Play/Stop/Save/Undo/Redo/
|
|
4
|
-
// select-all/delete/frame/toggle-display/deselect/…). These commands are the
|
|
5
|
-
// single entry point the top menu bar (builtin-menus.ts) and the keyboard
|
|
6
|
-
// router share so palette/menu/shortcut hit the same call path.
|
|
7
|
-
//
|
|
8
|
-
// The interface package is editor-agnostic (lint:agnostic forbids importing
|
|
9
|
-
// @forgeax/editor), so every command routes through the injected
|
|
10
|
-
// KeyboardRouterDeps — the host editor (studio/main.tsx) calls
|
|
11
|
-
// registerKeyboardRouterDeps() at boot BEFORE <App> mounts, and each execute
|
|
12
|
-
// resolves the deps LAZILY via getKeyboardRouterDeps(). Capture-at-setup
|
|
13
|
-
// would freeze deps to null since setup runs during bootstrapAppHost() which
|
|
14
|
-
// happens before main.tsx has finished mounting <App>; lazy resolution keeps
|
|
15
|
-
// this file boot-order-safe regardless of whether registration happened first.
|
|
16
|
-
//
|
|
17
|
-
// import + reveal: NOT registered. Content-browser import and OS file-reveal
|
|
18
|
-
// live outside KeyboardRouterDeps and can't be reached editor-agnostically
|
|
19
|
-
// from the interface foundation. The corresponding menu items in builtin-menus.ts carry no
|
|
20
|
-
// commandId and stay disabled — the correct signal for a not-yet-wired
|
|
21
|
-
// capability. Flip them on when a dep or command later lands.
|
|
22
|
-
//
|
|
23
|
-
// reloadPreview: registered as a best-effort event emit (`preview:reload` on
|
|
24
|
-
// ctx.bus). No listener consumes it yet — the Play iframe self-reload lives
|
|
25
|
-
// in @forgeax/editor/PlaySurface which the interface foundation cannot import. Emitting the event
|
|
26
|
-
// keeps the menu item live and gives a future HMR/preview owner a stable hook
|
|
27
|
-
// to subscribe to without touching this file.
|
|
28
|
-
import { APPLICATION_KEYBINDING_SCOPE } from '../contextual-keybindings';
|
|
29
|
-
import { getKeyboardRouterDeps } from '../../lib/global-shortcuts';
|
|
30
|
-
import { executeFocusedTextEditAction } from '../../lib/text-edit-actions';
|
|
31
|
-
/** Resolve the injected router deps or throw a clear error naming the missing
|
|
32
|
-
* wiring — makes a mis-boot fail loudly at command-invoke time instead of
|
|
33
|
-
* silently no-op'ing. */
|
|
34
|
-
function requireDeps() {
|
|
35
|
-
const deps = getKeyboardRouterDeps();
|
|
36
|
-
if (!deps)
|
|
37
|
-
throw new Error('editor-commands: editor deps not injected (host must call registerKeyboardRouterDeps before invoking editor.* commands)');
|
|
38
|
-
return deps;
|
|
39
|
-
}
|
|
40
|
-
export const editorCommandsExtension = {
|
|
41
|
-
id: 'editor-commands',
|
|
42
|
-
version: '1.0.0',
|
|
43
|
-
requires: ['commands', 'keybindings'],
|
|
44
|
-
setup(ctx) {
|
|
45
|
-
const { registerCommand, host } = ctx;
|
|
46
|
-
const cleanups = [];
|
|
47
|
-
cleanups.push(registerCommand({
|
|
48
|
-
id: 'editor.play',
|
|
49
|
-
title: '开始预览 (Play)',
|
|
50
|
-
execute: () => {
|
|
51
|
-
requireDeps().dispatch({ kind: 'play' }, 'human');
|
|
52
|
-
return { status: 'completed' };
|
|
53
|
-
},
|
|
54
|
-
}));
|
|
55
|
-
cleanups.push(registerCommand({
|
|
56
|
-
id: 'editor.stop',
|
|
57
|
-
title: '停止预览 (Stop)',
|
|
58
|
-
execute: () => {
|
|
59
|
-
requireDeps().dispatch({ kind: 'stop' }, 'human');
|
|
60
|
-
return { status: 'completed' };
|
|
61
|
-
},
|
|
62
|
-
}));
|
|
63
|
-
cleanups.push(registerCommand({
|
|
64
|
-
id: 'editor.toggleDisplay',
|
|
65
|
-
title: '切换 Scene / Game 视图',
|
|
66
|
-
execute: () => {
|
|
67
|
-
const deps = requireDeps();
|
|
68
|
-
const next = deps.getDisplay() === 'scene' ? 'game' : 'scene';
|
|
69
|
-
deps.dispatch({ kind: 'setDisplay', display: next }, 'human');
|
|
70
|
-
return { status: 'completed' };
|
|
71
|
-
},
|
|
72
|
-
}));
|
|
73
|
-
cleanups.push(registerCommand({
|
|
74
|
-
id: 'editor.undo',
|
|
75
|
-
title: '撤销',
|
|
76
|
-
execute: () => {
|
|
77
|
-
requireDeps().undo();
|
|
78
|
-
return { status: 'completed' };
|
|
79
|
-
},
|
|
80
|
-
}));
|
|
81
|
-
cleanups.push(registerCommand({
|
|
82
|
-
id: 'editor.redo',
|
|
83
|
-
title: '重做',
|
|
84
|
-
execute: () => {
|
|
85
|
-
requireDeps().redo();
|
|
86
|
-
return { status: 'completed' };
|
|
87
|
-
},
|
|
88
|
-
}));
|
|
89
|
-
cleanups.push(registerCommand({
|
|
90
|
-
id: 'editor.save',
|
|
91
|
-
title: '保存',
|
|
92
|
-
execute: () => {
|
|
93
|
-
requireDeps().save();
|
|
94
|
-
return { status: 'completed' };
|
|
95
|
-
},
|
|
96
|
-
}));
|
|
97
|
-
// Application-scoped Mod+S runs before legacy edit shortcuts and is not
|
|
98
|
-
// gated on the viewport surface anchor. Material / Texture / other asset
|
|
99
|
-
// document pages replace the viewport dock layout, so isEditorSurfaceActive()
|
|
100
|
-
// is false there — without this binding Ctrl+S falls through to the browser
|
|
101
|
-
// "save page" dialog instead of editor.save → material-first save diversion.
|
|
102
|
-
cleanups.push(host.keybindings.register({
|
|
103
|
-
commandId: 'editor.save',
|
|
104
|
-
keys: 'Mod+S',
|
|
105
|
-
scope: APPLICATION_KEYBINDING_SCOPE,
|
|
106
|
-
allowInEditable: true,
|
|
107
|
-
priority: 100,
|
|
108
|
-
}));
|
|
109
|
-
cleanups.push(registerCommand({
|
|
110
|
-
id: 'editor.selectAll',
|
|
111
|
-
title: '全选实体',
|
|
112
|
-
execute: async () => {
|
|
113
|
-
if (await executeFocusedTextEditAction('selectAll')) {
|
|
114
|
-
return { status: 'completed' };
|
|
115
|
-
}
|
|
116
|
-
requireDeps().selectAllEntities();
|
|
117
|
-
return { status: 'completed' };
|
|
118
|
-
},
|
|
119
|
-
}));
|
|
120
|
-
cleanups.push(registerCommand({
|
|
121
|
-
id: 'editor.deselect',
|
|
122
|
-
title: '清除选择',
|
|
123
|
-
execute: () => {
|
|
124
|
-
requireDeps().dispatch({ kind: 'setSelection', id: null }, 'human');
|
|
125
|
-
return { status: 'completed' };
|
|
126
|
-
},
|
|
127
|
-
}));
|
|
128
|
-
cleanups.push(registerCommand({
|
|
129
|
-
id: 'editor.frameSelected',
|
|
130
|
-
title: '聚焦所选',
|
|
131
|
-
execute: () => {
|
|
132
|
-
requireDeps().dispatch({ kind: 'requestFrame' }, 'human');
|
|
133
|
-
return { status: 'completed' };
|
|
134
|
-
},
|
|
135
|
-
}));
|
|
136
|
-
cleanups.push(registerCommand({
|
|
137
|
-
id: 'editor.delete',
|
|
138
|
-
title: '删除所选实体',
|
|
139
|
-
execute: () => {
|
|
140
|
-
const deps = requireDeps();
|
|
141
|
-
const ids = deps.getEntitySelection();
|
|
142
|
-
if (ids.length > 0)
|
|
143
|
-
deps.deleteEntities(ids);
|
|
144
|
-
return { status: 'completed' };
|
|
145
|
-
},
|
|
146
|
-
}));
|
|
147
|
-
// Partial: bus event only, no listener wired yet (see file header). A
|
|
148
|
-
// future preview/HMR owner in a standalone application subscribes to 'preview:reload'
|
|
149
|
-
// and forwards to the Play iframe self-reload. `ctx.bus.emit` is typed
|
|
150
|
-
// against AppBusEventMap which extends Record<string, unknown>, so an
|
|
151
|
-
// ad-hoc topic name is accepted with an `unknown` payload.
|
|
152
|
-
cleanups.push(registerCommand({
|
|
153
|
-
id: 'editor.reloadPreview',
|
|
154
|
-
title: '重载预览 (partial: 事件已发,尚无消费者)',
|
|
155
|
-
execute: () => {
|
|
156
|
-
// Not calling requireDeps() — reloadPreview is decoupled from the
|
|
157
|
-
// editor gateway; the current path is a bus-event handoff, and a
|
|
158
|
-
// future consumer may not even need the router deps.
|
|
159
|
-
ctx.bus.emit('preview:reload', {});
|
|
160
|
-
return { status: 'completed' };
|
|
161
|
-
},
|
|
162
|
-
}));
|
|
163
|
-
cleanups.push(registerCommand({
|
|
164
|
-
id: 'editor.restartPreview',
|
|
165
|
-
title: '重建预览运行时',
|
|
166
|
-
execute: () => {
|
|
167
|
-
const restart = requireDeps().restartPreview;
|
|
168
|
-
if (!restart)
|
|
169
|
-
throw new Error('editor.restartPreview: host did not provide restartPreview');
|
|
170
|
-
restart();
|
|
171
|
-
return { status: 'completed' };
|
|
172
|
-
},
|
|
173
|
-
}));
|
|
174
|
-
return () => {
|
|
175
|
-
// Reverse order so first-registered is torn down last — same pattern
|
|
176
|
-
// as builtin-commands.ts; `.slice()` clones the array so a repeat
|
|
177
|
-
// unload doesn't mutate the closed-over `cleanups`.
|
|
178
|
-
for (const c of cleanups.slice().reverse())
|
|
179
|
-
c();
|
|
180
|
-
};
|
|
181
|
-
},
|
|
182
|
-
};
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
/** Re-register built-in menus when the module-level registry was cleared
|
|
2
|
-
* (React StrictMode dispose race, Vite HMR module swap) but shell chrome
|
|
3
|
-
* is still mounted. Idempotent — no-op when File menu already populated. */
|
|
4
|
-
export declare function repairBuiltinMenusIfEmpty(): void;
|
|
5
|
-
/** Test-only: tear down ad-hoc repair registration. */
|
|
6
|
-
export declare function __resetBuiltinMenuRepairForTest(): void;
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { builtinMenusExtension } from '../core/extensions/builtin-menus';
|
|
2
|
-
import { snapshotMenu } from './menu-registry';
|
|
3
|
-
let adhocCleanup;
|
|
4
|
-
/** Re-register built-in menus when the module-level registry was cleared
|
|
5
|
-
* (React StrictMode dispose race, Vite HMR module swap) but shell chrome
|
|
6
|
-
* is still mounted. Idempotent — no-op when File menu already populated. */
|
|
7
|
-
export function repairBuiltinMenusIfEmpty() {
|
|
8
|
-
if (snapshotMenu('file').length > 0)
|
|
9
|
-
return;
|
|
10
|
-
adhocCleanup?.();
|
|
11
|
-
const result = builtinMenusExtension.setup?.({});
|
|
12
|
-
adhocCleanup = typeof result === 'function' ? result : undefined;
|
|
13
|
-
}
|
|
14
|
-
/** Test-only: tear down ad-hoc repair registration. */
|
|
15
|
-
export function __resetBuiltinMenuRepairForTest() {
|
|
16
|
-
adhocCleanup?.();
|
|
17
|
-
adhocCleanup = undefined;
|
|
18
|
-
}
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
// packages/interface/src/core/extensions/editor-commands.ts
|
|
2
|
-
//
|
|
3
|
-
// Command-bus wrappers for editor-domain actions (Play/Stop/Save/Undo/Redo/
|
|
4
|
-
// select-all/delete/frame/toggle-display/deselect/…). These commands are the
|
|
5
|
-
// single entry point the top menu bar (builtin-menus.ts) and the keyboard
|
|
6
|
-
// router share so palette/menu/shortcut hit the same call path.
|
|
7
|
-
//
|
|
8
|
-
// The interface package is editor-agnostic (lint:agnostic forbids importing
|
|
9
|
-
// @forgeax/editor), so every command routes through the injected
|
|
10
|
-
// KeyboardRouterDeps — the host editor (studio/main.tsx) calls
|
|
11
|
-
// registerKeyboardRouterDeps() at boot BEFORE <App> mounts, and each execute
|
|
12
|
-
// resolves the deps LAZILY via getKeyboardRouterDeps(). Capture-at-setup
|
|
13
|
-
// would freeze deps to null since setup runs during bootstrapAppHost() which
|
|
14
|
-
// happens before main.tsx has finished mounting <App>; lazy resolution keeps
|
|
15
|
-
// this file boot-order-safe regardless of whether registration happened first.
|
|
16
|
-
//
|
|
17
|
-
// import + reveal: NOT registered. Content-browser import and OS file-reveal
|
|
18
|
-
// live outside KeyboardRouterDeps and can't be reached editor-agnostically
|
|
19
|
-
// from the interface foundation. The corresponding menu items in builtin-menus.ts carry no
|
|
20
|
-
// commandId and stay disabled — the correct signal for a not-yet-wired
|
|
21
|
-
// capability. Flip them on when a dep or command later lands.
|
|
22
|
-
//
|
|
23
|
-
// reloadPreview: registered as a best-effort event emit (`preview:reload` on
|
|
24
|
-
// ctx.bus). No listener consumes it yet — the Play iframe self-reload lives
|
|
25
|
-
// in @forgeax/editor/PlaySurface which the interface foundation cannot import. Emitting the event
|
|
26
|
-
// keeps the menu item live and gives a future HMR/preview owner a stable hook
|
|
27
|
-
// to subscribe to without touching this file.
|
|
28
|
-
import { APPLICATION_KEYBINDING_SCOPE } from '../contextual-keybindings.js';
|
|
29
|
-
import { getKeyboardRouterDeps } from '../../lib/global-shortcuts.js';
|
|
30
|
-
import { executeFocusedTextEditAction } from '../../lib/text-edit-actions.js';
|
|
31
|
-
/** Resolve the injected router deps or throw a clear error naming the missing
|
|
32
|
-
* wiring — makes a mis-boot fail loudly at command-invoke time instead of
|
|
33
|
-
* silently no-op'ing. */
|
|
34
|
-
function requireDeps() {
|
|
35
|
-
const deps = getKeyboardRouterDeps();
|
|
36
|
-
if (!deps)
|
|
37
|
-
throw new Error('editor-commands: editor deps not injected (host must call registerKeyboardRouterDeps before invoking editor.* commands)');
|
|
38
|
-
return deps;
|
|
39
|
-
}
|
|
40
|
-
export const editorCommandsExtension = {
|
|
41
|
-
id: 'editor-commands',
|
|
42
|
-
version: '1.0.0',
|
|
43
|
-
requires: ['commands', 'keybindings'],
|
|
44
|
-
setup(ctx) {
|
|
45
|
-
const { registerCommand, host } = ctx;
|
|
46
|
-
const cleanups = [];
|
|
47
|
-
cleanups.push(registerCommand({
|
|
48
|
-
id: 'editor.play',
|
|
49
|
-
title: '开始预览 (Play)',
|
|
50
|
-
execute: () => {
|
|
51
|
-
requireDeps().dispatch({ kind: 'play' }, 'human');
|
|
52
|
-
return { status: 'completed' };
|
|
53
|
-
},
|
|
54
|
-
}));
|
|
55
|
-
cleanups.push(registerCommand({
|
|
56
|
-
id: 'editor.stop',
|
|
57
|
-
title: '停止预览 (Stop)',
|
|
58
|
-
execute: () => {
|
|
59
|
-
requireDeps().dispatch({ kind: 'stop' }, 'human');
|
|
60
|
-
return { status: 'completed' };
|
|
61
|
-
},
|
|
62
|
-
}));
|
|
63
|
-
cleanups.push(registerCommand({
|
|
64
|
-
id: 'editor.toggleDisplay',
|
|
65
|
-
title: '切换 Scene / Game 视图',
|
|
66
|
-
execute: () => {
|
|
67
|
-
const deps = requireDeps();
|
|
68
|
-
const next = deps.getDisplay() === 'scene' ? 'game' : 'scene';
|
|
69
|
-
deps.dispatch({ kind: 'setDisplay', display: next }, 'human');
|
|
70
|
-
return { status: 'completed' };
|
|
71
|
-
},
|
|
72
|
-
}));
|
|
73
|
-
cleanups.push(registerCommand({
|
|
74
|
-
id: 'editor.undo',
|
|
75
|
-
title: '撤销',
|
|
76
|
-
execute: () => {
|
|
77
|
-
requireDeps().undo();
|
|
78
|
-
return { status: 'completed' };
|
|
79
|
-
},
|
|
80
|
-
}));
|
|
81
|
-
cleanups.push(registerCommand({
|
|
82
|
-
id: 'editor.redo',
|
|
83
|
-
title: '重做',
|
|
84
|
-
execute: () => {
|
|
85
|
-
requireDeps().redo();
|
|
86
|
-
return { status: 'completed' };
|
|
87
|
-
},
|
|
88
|
-
}));
|
|
89
|
-
cleanups.push(registerCommand({
|
|
90
|
-
id: 'editor.save',
|
|
91
|
-
title: '保存',
|
|
92
|
-
execute: () => {
|
|
93
|
-
requireDeps().save();
|
|
94
|
-
return { status: 'completed' };
|
|
95
|
-
},
|
|
96
|
-
}));
|
|
97
|
-
// Application-scoped Mod+S runs before legacy edit shortcuts and is not
|
|
98
|
-
// gated on the viewport surface anchor. Material / Texture / other asset
|
|
99
|
-
// document pages replace the viewport dock layout, so isEditorSurfaceActive()
|
|
100
|
-
// is false there — without this binding Ctrl+S falls through to the browser
|
|
101
|
-
// "save page" dialog instead of editor.save → material-first save diversion.
|
|
102
|
-
cleanups.push(host.keybindings.register({
|
|
103
|
-
commandId: 'editor.save',
|
|
104
|
-
keys: 'Mod+S',
|
|
105
|
-
scope: APPLICATION_KEYBINDING_SCOPE,
|
|
106
|
-
allowInEditable: true,
|
|
107
|
-
priority: 100,
|
|
108
|
-
}));
|
|
109
|
-
cleanups.push(registerCommand({
|
|
110
|
-
id: 'editor.selectAll',
|
|
111
|
-
title: '全选实体',
|
|
112
|
-
execute: async () => {
|
|
113
|
-
if (await executeFocusedTextEditAction('selectAll')) {
|
|
114
|
-
return { status: 'completed' };
|
|
115
|
-
}
|
|
116
|
-
requireDeps().selectAllEntities();
|
|
117
|
-
return { status: 'completed' };
|
|
118
|
-
},
|
|
119
|
-
}));
|
|
120
|
-
cleanups.push(registerCommand({
|
|
121
|
-
id: 'editor.deselect',
|
|
122
|
-
title: '清除选择',
|
|
123
|
-
execute: () => {
|
|
124
|
-
requireDeps().dispatch({ kind: 'setSelection', id: null }, 'human');
|
|
125
|
-
return { status: 'completed' };
|
|
126
|
-
},
|
|
127
|
-
}));
|
|
128
|
-
cleanups.push(registerCommand({
|
|
129
|
-
id: 'editor.frameSelected',
|
|
130
|
-
title: '聚焦所选',
|
|
131
|
-
execute: () => {
|
|
132
|
-
requireDeps().dispatch({ kind: 'requestFrame' }, 'human');
|
|
133
|
-
return { status: 'completed' };
|
|
134
|
-
},
|
|
135
|
-
}));
|
|
136
|
-
cleanups.push(registerCommand({
|
|
137
|
-
id: 'editor.delete',
|
|
138
|
-
title: '删除所选实体',
|
|
139
|
-
execute: () => {
|
|
140
|
-
const deps = requireDeps();
|
|
141
|
-
const ids = deps.getEntitySelection();
|
|
142
|
-
if (ids.length > 0)
|
|
143
|
-
deps.deleteEntities(ids);
|
|
144
|
-
return { status: 'completed' };
|
|
145
|
-
},
|
|
146
|
-
}));
|
|
147
|
-
// Partial: bus event only, no listener wired yet (see file header). A
|
|
148
|
-
// future preview/HMR owner in a standalone application subscribes to 'preview:reload'
|
|
149
|
-
// and forwards to the Play iframe self-reload. `ctx.bus.emit` is typed
|
|
150
|
-
// against AppBusEventMap which extends Record<string, unknown>, so an
|
|
151
|
-
// ad-hoc topic name is accepted with an `unknown` payload.
|
|
152
|
-
cleanups.push(registerCommand({
|
|
153
|
-
id: 'editor.reloadPreview',
|
|
154
|
-
title: '重载预览 (partial: 事件已发,尚无消费者)',
|
|
155
|
-
execute: () => {
|
|
156
|
-
// Not calling requireDeps() — reloadPreview is decoupled from the
|
|
157
|
-
// editor gateway; the current path is a bus-event handoff, and a
|
|
158
|
-
// future consumer may not even need the router deps.
|
|
159
|
-
ctx.bus.emit('preview:reload', {});
|
|
160
|
-
return { status: 'completed' };
|
|
161
|
-
},
|
|
162
|
-
}));
|
|
163
|
-
cleanups.push(registerCommand({
|
|
164
|
-
id: 'editor.restartPreview',
|
|
165
|
-
title: '重建预览运行时',
|
|
166
|
-
execute: () => {
|
|
167
|
-
const restart = requireDeps().restartPreview;
|
|
168
|
-
if (!restart)
|
|
169
|
-
throw new Error('editor.restartPreview: host did not provide restartPreview');
|
|
170
|
-
restart();
|
|
171
|
-
return { status: 'completed' };
|
|
172
|
-
},
|
|
173
|
-
}));
|
|
174
|
-
return () => {
|
|
175
|
-
// Reverse order so first-registered is torn down last — same pattern
|
|
176
|
-
// as builtin-commands.ts; `.slice()` clones the array so a repeat
|
|
177
|
-
// unload doesn't mutate the closed-over `cleanups`.
|
|
178
|
-
for (const c of cleanups.slice().reverse())
|
|
179
|
-
c();
|
|
180
|
-
};
|
|
181
|
-
},
|
|
182
|
-
};
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { builtinMenusExtension } from '../core/extensions/builtin-menus.js';
|
|
2
|
-
import { snapshotMenu } from './menu-registry.js';
|
|
3
|
-
let adhocCleanup;
|
|
4
|
-
/** Re-register built-in menus when the module-level registry was cleared
|
|
5
|
-
* (React StrictMode dispose race, Vite HMR module swap) but shell chrome
|
|
6
|
-
* is still mounted. Idempotent — no-op when File menu already populated. */
|
|
7
|
-
export function repairBuiltinMenusIfEmpty() {
|
|
8
|
-
if (snapshotMenu('file').length > 0)
|
|
9
|
-
return;
|
|
10
|
-
adhocCleanup?.();
|
|
11
|
-
const result = builtinMenusExtension.setup?.({});
|
|
12
|
-
adhocCleanup = typeof result === 'function' ? result : undefined;
|
|
13
|
-
}
|
|
14
|
-
/** Test-only: tear down ad-hoc repair registration. */
|
|
15
|
-
export function __resetBuiltinMenuRepairForTest() {
|
|
16
|
-
adhocCleanup?.();
|
|
17
|
-
adhocCleanup = undefined;
|
|
18
|
-
}
|