@forgeax/interface 0.5.0 → 0.6.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.
@@ -10,7 +10,7 @@
10
10
  * Ctrl+/ focus chat composer
11
11
  * Ctrl+Shift+H open Settings → Changelog (was Ctrl+H before 2026-08-04 —
12
12
  * UE-parity editor hide claimed Ctrl+H for "show all hidden")
13
- * Esc stop viewport Play; otherwise close current overlay
13
+ * Esc close current overlay (higher-priority owners may claim it)
14
14
  * (Settings → Dashboard → Fullscreen)
15
15
  *
16
16
  * IME 安全:
@@ -77,16 +77,9 @@ function mod(e) {
77
77
  }
78
78
  // Is the scene editor the surface the user is currently looking at?
79
79
  //
80
- // The remaining legacy `edit` group (Ctrl+D / viewport W-E-R-F …) is injected
81
- // globally at boot and still dispatches against module-level editor selection /
82
- // viewport state. This coarse surface gate lets those keys escape whenever the editor is
83
- // not the foreground surface. Two facts, both interface-local (no editor
84
- // import, no focus/DOM resolver — that larger redesign is ADR-0029 scope; this
85
- // is its Phase 0 short-term mitigation):
86
- // 1. an overlay (Dashboard / Settings) is covering the shell, or
87
- // 2. the editor Page anchor is not currently visible.
88
- // Read live at event time (cached snapshot — cheap) so switching tab / opening
89
- // an overlay takes effect immediately.
80
+ // Contributions classified as edit use the product shell's existing surface
81
+ // identity and visibility policy. Read at event time so page/overlay changes
82
+ // take effect without rebuilding the host or listener.
90
83
  export function isEditorSurfaceActive() {
91
84
  if (useShellStore.getState().activeOverlay)
92
85
  return false;
@@ -95,7 +88,6 @@ export function isEditorSurfaceActive() {
95
88
  : document.querySelector('[data-surface-anchor="edit"]');
96
89
  return Boolean(anchor && anchor.getClientRects().length > 0);
97
90
  }
98
- let routerDeps = null;
99
91
  const transientKeydownHandlers = new Set();
100
92
  export function registerGlobalKeydownHandler(handler) {
101
93
  transientKeydownHandlers.add(handler);
@@ -108,204 +100,9 @@ export function dispatchGlobalKeydownHandlers(event) {
108
100
  }
109
101
  return false;
110
102
  }
111
- /** Inject the editor-side callbacks the router needs. Called once at host boot
112
- * (forgeax-editor standalone/main.tsx) BEFORE the App mounts (useGlobalShortcuts
113
- * reads this at effect time, which is after mount, so registration first is safe). */
114
- export function registerKeyboardRouterDeps(deps) {
115
- routerDeps = deps;
116
- }
117
- /** Read the currently injected router deps. Returns null until the host has
118
- * called registerKeyboardRouterDeps() at boot. Command-bus wrappers
119
- * (editor-commands extension) resolve deps lazily via this getter so they
120
- * stay editor-agnostic and boot-order-safe. */
121
- export function getKeyboardRouterDeps() {
122
- return routerDeps;
123
- }
124
- // Build the edit-domain shortcut list from injected deps. Pure dispatcher: every
125
- // branch routes through a dep callback (which the editor maps onto gateway ops),
126
- // so this file stays editor-agnostic. Three-layer guards (IME / typing-target /
127
- // play-mode) are enforced by the host's onKey wrapper (isComposing / isTypingTarget)
128
- // plus the per-op play-mode checks below.
129
- function editShortcuts(deps) {
130
- const routeCtrlD = () => {
131
- const domain = deps.getLastSelectionDomain() ?? 'entity';
132
- if (domain === 'asset') {
133
- for (const a of deps.getAssetSelection())
134
- deps.duplicateAsset(a.guid, a.packPath);
135
- return true;
136
- }
137
- const ids = deps.getEntitySelection();
138
- if (ids.length > 0) {
139
- deps.duplicateEntities(ids);
140
- return true;
141
- }
142
- return false;
143
- };
144
- // UE-parity editor hide (docs 2026-08-04-editor-hide-ue-parity-plan M2):
145
- // H hides the selection, Ctrl+H shows every hidden entity, Shift+H hides
146
- // the unselected (isolate). Entity-domain only; under Play the game keeps
147
- // its keys (same guard as entity Delete).
148
- const routeH = () => {
149
- if (deps.isPlayMode())
150
- return false;
151
- const ids = deps.getEntitySelection();
152
- if (ids.length === 0)
153
- return false;
154
- deps.hideEntities(ids);
155
- return true;
156
- };
157
- const routeShiftH = () => {
158
- if (deps.isPlayMode())
159
- return false;
160
- if (deps.getEntitySelection().length === 0)
161
- return false;
162
- deps.hideUnselected();
163
- return true;
164
- };
165
- const routeCtrlH = () => {
166
- if (deps.isPlayMode())
167
- return false;
168
- deps.showAllHidden();
169
- return true;
170
- };
171
- // Viewport Game View toggle. Plain G is editor-only while not playing so a
172
- // running game retains its gameplay binding; Shift+G remains the explicit
173
- // Play shortcut and is also available in Edit Viewport.
174
- const routeShiftG = () => {
175
- deps.dispatch({ kind: 'setDisplay', display: deps.getDisplay() === 'game' ? 'scene' : 'game' }, 'human');
176
- return true;
177
- };
178
- const routeEditorOwnedPlayKey = (e) => {
179
- if (!e)
180
- return true;
181
- deps.handleViewportKeyDown(e);
182
- return true;
183
- };
184
- const routeViewportInput = (e) => {
185
- if (!e)
186
- return false;
187
- if (deps.getInputTarget() === 'game')
188
- return false;
189
- deps.handleViewportKeyDown(e);
190
- return true;
191
- };
192
- return [
193
- {
194
- combo: 'Ctrl+D',
195
- group: 'edit',
196
- label: t('shortcuts.duplicateSelection'),
197
- match: (e) => mod(e) && !e.shiftKey && !e.altKey
198
- && (e.code === 'KeyD' || safeKeyLower(e) === 'd'),
199
- run: routeCtrlD,
200
- },
201
- {
202
- combo: 'H',
203
- group: 'edit',
204
- label: t('shortcuts.hideSelected'),
205
- match: (e) => !mod(e) && !e.shiftKey && !e.altKey && e.code === 'KeyH',
206
- run: routeH,
207
- },
208
- {
209
- combo: 'Shift+H',
210
- group: 'edit',
211
- label: t('shortcuts.hideUnselected'),
212
- match: (e) => !mod(e) && e.shiftKey && !e.altKey && e.code === 'KeyH',
213
- run: routeShiftH,
214
- },
215
- {
216
- combo: 'Ctrl+H',
217
- group: 'edit',
218
- label: t('shortcuts.showAllHidden'),
219
- match: (e) => mod(e) && !e.shiftKey && !e.altKey && e.code === 'KeyH',
220
- run: routeCtrlH,
221
- },
222
- {
223
- combo: 'Shift+G',
224
- group: 'edit',
225
- label: t('shortcuts.toggleViewportGameView'),
226
- match: (e) => !mod(e) && e.shiftKey && !e.altKey
227
- && (e.key === 'g' || e.key === 'G'),
228
- run: routeShiftG,
229
- },
230
- {
231
- combo: 'G',
232
- group: 'edit',
233
- label: t('shortcuts.toggleViewportGameView'),
234
- match: (e) => !mod(e) && !e.shiftKey && !e.altKey
235
- && !deps.isPlayMode() && deps.getInputTarget() !== 'game'
236
- && (e.key === 'g' || e.key === 'G'),
237
- run: routeShiftG,
238
- },
239
- {
240
- combo: 'Ctrl+Z',
241
- group: 'edit',
242
- label: t('shortcuts.undo'),
243
- allowInInput: true,
244
- match: (e) => mod(e) && !e.altKey && !e.shiftKey && (e.code === 'KeyZ' || safeKeyLower(e) === 'z'),
245
- run: () => { deps.undo(); return true; },
246
- },
247
- {
248
- combo: 'Ctrl+Shift+Z',
249
- group: 'edit',
250
- label: t('shortcuts.redo'),
251
- allowInInput: true,
252
- match: (e) => mod(e) && !e.altKey && e.shiftKey && (e.code === 'KeyZ' || safeKeyLower(e) === 'z'),
253
- run: () => { deps.redo(); return true; },
254
- },
255
- {
256
- combo: 'Ctrl+Y',
257
- group: 'edit',
258
- label: t('shortcuts.redo'),
259
- allowInInput: true,
260
- match: (e) => mod(e) && !e.altKey && !e.shiftKey && (e.code === 'KeyY' || safeKeyLower(e) === 'y'),
261
- run: () => { deps.redo(); return true; },
262
- },
263
- {
264
- combo: 'Ctrl+S',
265
- group: 'edit',
266
- label: t('shortcuts.save'),
267
- // Must fire while typing in Input Map / MI fields (⌘/Ctrl+S is a document
268
- // command, not a text-editing key). Without this, focus inside `.im-editor`
269
- // makes isTypingTarget true and silently drops save.
270
- allowInInput: true,
271
- match: (e) => mod(e) && !e.altKey && !e.shiftKey && (e.code === 'KeyS' || safeKeyLower(e) === 's'),
272
- run: () => { deps.save(); return true; },
273
- },
274
- {
275
- combo: 'Viewport camera and fly input',
276
- group: 'edit',
277
- label: t('shortcuts.viewportNavigation'),
278
- match: (e) => {
279
- if (deps.getInputTarget() === 'game')
280
- return false;
281
- const key = safeKeyLower(e);
282
- if (!key)
283
- return false;
284
- // UE-style view presets (Alt+G/H/J/K) route to the viewport handler,
285
- // which owns the camera. All other Alt combos stay excluded here.
286
- if (e.altKey) {
287
- return !mod(e) && !e.shiftKey && ['g', 'h', 'j', 'k'].includes(key);
288
- }
289
- const plainCameraKey = !mod(e)
290
- && (['w', 'e', 'r', 'f', 'a', 's', 'd', 'q', 'v', 'z', 'c', 'escape', 'shift'].includes(key)
291
- || /^[1-9]$/.test(key));
292
- const bookmarkKey = mod(e) && !e.shiftKey && /^[1-9]$/.test(key);
293
- return plainCameraKey || bookmarkKey;
294
- },
295
- run: routeViewportInput,
296
- },
297
- {
298
- combo: 'Play editor input shield',
299
- group: 'edit',
300
- label: t('shortcuts.shieldGameInput'),
301
- match: () => deps.isPlayMode() && deps.getInputTarget() !== 'game',
302
- run: routeEditorOwnedPlayKey,
303
- },
304
- ];
305
- }
306
- // Build the shortcut registry. Each match() / run() is plain JS so we can
307
- // drive them from a Settings table later (or a Command Palette).
308
- export function buildShortcuts() {
103
+ // Build the Settings list in product order, not event-routing priority order.
104
+ // Exact duplicate descriptions share a row, but remain separate runtime owners.
105
+ export function buildShortcuts(contributions) {
309
106
  const store = useShellStore.getState;
310
107
  const shortcuts = [
311
108
  // ── Layout (collapse / fullscreen) ──
@@ -394,13 +191,6 @@ export function buildShortcuts() {
394
191
  allowInInput: true,
395
192
  match: (e) => e.key === 'Escape' && !mod(e) && !e.shiftKey && !e.altKey,
396
193
  run: () => {
397
- // Escape is a Play-only viewport shortcut: stop the transient play
398
- // session and return to edit mode. The listener stays in the single
399
- // global router; outside Play this branch is inactive.
400
- if (routerDeps?.isPlayMode()) {
401
- routerDeps.dispatch({ kind: 'stop' }, 'human');
402
- return true;
403
- }
404
194
  const s = store();
405
195
  // Browser fullscreen exits automatically on Esc — but be defensive
406
196
  // in case some browser swallows the event before reaching the native
@@ -460,18 +250,29 @@ export function buildShortcuts() {
460
250
  },
461
251
  },
462
252
  ];
463
- // Inject the host editor's remaining edit-domain shortcuts when deps were
464
- // registered at boot. Focus-owned F2/Delete/Mod+A live in host.keybindings.
465
- if (routerDeps)
466
- shortcuts.push(...editShortcuts(routerDeps));
467
253
  shortcuts.push({
254
+ priority: -20,
468
255
  combo: 'Ctrl+K',
469
256
  group: 'general',
470
257
  label: t('shortcuts.toggleCommandPalette'),
471
258
  match: (e) => mod(e) && !e.altKey && !e.shiftKey && (e.code === 'KeyK' || safeKeyLower(e) === 'k'),
472
259
  run: () => { toggleCommandPalette(); return true; },
473
260
  });
474
- return shortcuts;
261
+ const shell = shortcuts.map((shortcut) => ({ priority: 10, ...shortcut }));
262
+ const rowKey = ({ combo, group, label }) => JSON.stringify([combo, group, label]);
263
+ const seen = new Set(shell.map(rowKey));
264
+ const contributedRows = [];
265
+ for (const shortcut of contributions?.snapshot() ?? []) {
266
+ const key = rowKey(shortcut);
267
+ if (seen.has(key))
268
+ continue;
269
+ seen.add(key);
270
+ contributedRows.push(shortcut);
271
+ }
272
+ return [...shell.slice(0, -1), ...contributedRows, ...shell.slice(-1)];
273
+ }
274
+ function orderShortcuts(shell, contributions) {
275
+ return [...shell, ...(contributions?.snapshot() ?? [])].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
475
276
  }
476
277
  /**
477
278
  * Mount once at App root. Returns nothing — purely an effect.
@@ -481,9 +282,9 @@ export function buildShortcuts() {
481
282
  * Ctrl+Shift+1/2/3 (which Chrome maps to tab switching only WITHOUT Shift,
482
283
  * so we're safe, but we preventDefault anyway).
483
284
  */
484
- export function useGlobalShortcuts(keybindings) {
285
+ export function useGlobalShortcuts(keybindings, contributions) {
485
286
  useEffect(() => {
486
- const shortcuts = buildShortcuts();
287
+ const shell = buildShortcuts();
487
288
  const onKey = (e) => {
488
289
  // 0. IME composing — bail. Never intercept Chinese pinyin chord.
489
290
  if (isComposing(e))
@@ -499,11 +300,11 @@ export function useGlobalShortcuts(keybindings) {
499
300
  // 2. The contextual resolver gets first refusal inside the ONE capture
500
301
  // listener. A handled or disabled-but-claimed binding must not fall into
501
302
  // legacy application shortcuts; passthrough/unclaimed continues below.
502
- const contextual = keybindings?.handle(e);
303
+ const contextual = keybindings.handle(e);
503
304
  if (contextual?.status === 'handled' || contextual?.status === 'claimed-disabled')
504
305
  return;
505
- // 3. Find first matching legacy shortcut.
506
- for (const s of shortcuts) {
306
+ // 3. Read live host contributions after contextual first refusal.
307
+ for (const s of orderShortcuts(shell, contributions)) {
507
308
  // A focused preview owns camera/fly keys locally. Keep non-edit global
508
309
  // shortcuts available, but never route preview input to the main editor.
509
310
  if (shouldSkipGlobalShortcut(e, s))
@@ -532,7 +333,7 @@ export function useGlobalShortcuts(keybindings) {
532
333
  onKeyDown: onKey,
533
334
  });
534
335
  return disposeKeydownObservation;
535
- }, [keybindings]);
336
+ }, [keybindings, contributions]);
536
337
  }
537
338
  // macOS pretty-printing for shortcut combos shown in Settings.
538
339
  // Returns the canonical UI string. When platform is omitted, auto-detects macOS.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgeax/interface",
3
- "version": "0.5.0",
3
+ "version": "0.6.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.86.0",
95
+ "@forgeax/app-shell": "0.87.1",
96
96
  "@forgeax/extension-host": "0.3.2",
97
97
  "@forgeax/extension-platform": "0.5.0",
98
98
  "@forgeax/toolkit": "0.1.2",
@@ -1,2 +0,0 @@
1
- import type { AppExtension } from '../app-shell/types';
2
- export declare const editorCommandsExtension: AppExtension;
@@ -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
- };