@fps-games/editor 0.1.1-beta.5 → 0.1.1-beta.7

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 (102) hide show
  1. package/dist/local-editor-harness.d.ts +15 -3
  2. package/dist/local-editor-harness.d.ts.map +1 -1
  3. package/dist/local-editor-harness.js +278 -35
  4. package/dist/local-editor-harness.js.map +1 -1
  5. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts +33 -0
  6. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.d.ts.map +1 -0
  7. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js +231 -0
  8. package/node_modules/@fps-games/editor-babylon/dist/editor-sky.js.map +1 -0
  9. package/node_modules/@fps-games/editor-babylon/dist/editor-world.d.ts +3 -0
  10. package/node_modules/@fps-games/editor-babylon/dist/editor-world.d.ts.map +1 -1
  11. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js +8 -0
  12. package/node_modules/@fps-games/editor-babylon/dist/editor-world.js.map +1 -1
  13. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts +2 -0
  14. package/node_modules/@fps-games/editor-babylon/dist/index.d.ts.map +1 -1
  15. package/node_modules/@fps-games/editor-babylon/dist/index.js +2 -0
  16. package/node_modules/@fps-games/editor-babylon/dist/index.js.map +1 -1
  17. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.d.ts +40 -0
  18. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.d.ts.map +1 -0
  19. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js +237 -0
  20. package/node_modules/@fps-games/editor-babylon/dist/infinite-grid.js.map +1 -0
  21. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.d.ts +1 -1
  22. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.d.ts.map +1 -1
  23. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.js +15 -50
  24. package/node_modules/@fps-games/editor-babylon/dist/projection-selection-controller.js.map +1 -1
  25. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts +5 -0
  26. package/node_modules/@fps-games/editor-babylon/dist/projection.d.ts.map +1 -1
  27. package/node_modules/@fps-games/editor-babylon/dist/projection.js +61 -8
  28. package/node_modules/@fps-games/editor-babylon/dist/projection.js.map +1 -1
  29. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts +7 -0
  30. package/node_modules/@fps-games/editor-babylon/dist/types.d.ts.map +1 -1
  31. package/node_modules/@fps-games/editor-babylon/package.json +4 -4
  32. package/node_modules/@fps-games/editor-browser/dist/index.d.ts +2 -0
  33. package/node_modules/@fps-games/editor-browser/dist/index.d.ts.map +1 -1
  34. package/node_modules/@fps-games/editor-browser/dist/index.js +1 -0
  35. package/node_modules/@fps-games/editor-browser/dist/index.js.map +1 -1
  36. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-context-menu.js +97 -46
  37. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-context-menu.js.map +1 -1
  38. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts +15 -0
  39. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.d.ts.map +1 -1
  40. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js +199 -10
  41. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-actions.js.map +1 -1
  42. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.d.ts +1 -0
  43. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.d.ts.map +1 -1
  44. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js +141 -17
  45. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-controller.js.map +1 -1
  46. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-tree.js +0 -2
  47. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-tree.js.map +1 -1
  48. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.d.ts.map +1 -1
  49. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.js +6 -2
  50. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-hierarchy-view.js.map +1 -1
  51. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.d.ts +1 -1
  52. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.d.ts.map +1 -1
  53. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.js +16 -0
  54. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-icons.js.map +1 -1
  55. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts +8 -1
  56. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.d.ts.map +1 -1
  57. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js +358 -53
  58. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-panels.js.map +1 -1
  59. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.d.ts +5 -0
  60. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.d.ts.map +1 -1
  61. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js +28 -8
  62. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shared.js.map +1 -1
  63. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.d.ts.map +1 -1
  64. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js +7 -1
  65. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-shortcuts.js.map +1 -1
  66. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.d.ts.map +1 -1
  67. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.js +14 -0
  68. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-theme.js.map +1 -1
  69. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.d.ts +11 -0
  70. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.d.ts.map +1 -0
  71. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.js +148 -0
  72. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-tooltip.js.map +1 -0
  73. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts +58 -1
  74. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-types.d.ts.map +1 -1
  75. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.d.ts +35 -0
  76. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.d.ts.map +1 -1
  77. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.js +375 -1
  78. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui-workbench.js.map +1 -1
  79. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts +5 -4
  80. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.d.ts.map +1 -1
  81. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js +934 -202
  82. package/node_modules/@fps-games/editor-browser/dist/local-editor-ui.js.map +1 -1
  83. package/node_modules/@fps-games/editor-browser/package.json +2 -2
  84. package/node_modules/@fps-games/editor-core/dist/index.d.ts +1 -0
  85. package/node_modules/@fps-games/editor-core/dist/index.d.ts.map +1 -1
  86. package/node_modules/@fps-games/editor-core/dist/index.js +1 -0
  87. package/node_modules/@fps-games/editor-core/dist/index.js.map +1 -1
  88. package/node_modules/@fps-games/editor-core/dist/inspector.d.ts +6 -1
  89. package/node_modules/@fps-games/editor-core/dist/inspector.d.ts.map +1 -1
  90. package/node_modules/@fps-games/editor-core/dist/inspector.js +6 -1
  91. package/node_modules/@fps-games/editor-core/dist/inspector.js.map +1 -1
  92. package/node_modules/@fps-games/editor-core/dist/scene-graph.d.ts +7 -0
  93. package/node_modules/@fps-games/editor-core/dist/scene-graph.d.ts.map +1 -1
  94. package/node_modules/@fps-games/editor-core/dist/scene-graph.js.map +1 -1
  95. package/node_modules/@fps-games/editor-core/dist/selection-strategy.d.ts +12 -0
  96. package/node_modules/@fps-games/editor-core/dist/selection-strategy.d.ts.map +1 -0
  97. package/node_modules/@fps-games/editor-core/dist/selection-strategy.js +52 -0
  98. package/node_modules/@fps-games/editor-core/dist/selection-strategy.js.map +1 -0
  99. package/node_modules/@fps-games/editor-core/package.json +2 -2
  100. package/node_modules/@fps-games/editor-forge-play/package.json +2 -2
  101. package/node_modules/@fps-games/editor-protocol/package.json +1 -1
  102. package/package.json +6 -6
@@ -1,18 +1,21 @@
1
1
  import * as LocalEditorPanels from './local-editor-ui-panels';
2
2
  import { createLocalEditorHierarchyController } from './local-editor-ui-hierarchy-controller';
3
3
  import { createLocalEditorContextMenuController } from './local-editor-ui-context-menu';
4
+ import { createLocalEditorTooltipController } from './local-editor-ui-tooltip';
4
5
  import { DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS, DEFAULT_EDITOR_TRANSFORM_TOOL_DESCRIPTORS, } from '@fps-games/editor-core';
5
- export { createLocalEditorHierarchyBlankMenu, createLocalEditorHierarchyDeleteShortcutAction, createLocalEditorHierarchyNodeMenu, } from './local-editor-ui-hierarchy-actions';
6
+ export { createLocalEditorHierarchyBlankMenu, createLocalEditorHierarchyCopyShortcutAction, createLocalEditorHierarchyDeleteShortcutAction, createLocalEditorHierarchyDuplicateShortcutAction, createLocalEditorHierarchyNodeMenu, createLocalEditorHierarchyPasteShortcutAction, createLocalEditorHierarchySelectAllShortcutAction, } from './local-editor-ui-hierarchy-actions';
6
7
  export { createLocalEditorHierarchyController, } from './local-editor-ui-hierarchy-controller';
7
8
  export { canLocalEditorHierarchyNodeHaveChildren, createLocalEditorHierarchyTreeModel, isLocalEditorHierarchyNodeMovable, } from './local-editor-ui-hierarchy-tree';
8
9
  export { createLocalEditorContextMenuController, } from './local-editor-ui-context-menu';
10
+ export { createLocalEditorTooltipController, } from './local-editor-ui-tooltip';
9
11
  import { createLocalEditorWorkbenchInputRouter } from './local-editor-ui-input-router';
10
12
  import { createLocalEditorPanelRegistry } from './local-editor-ui-panel-registry';
11
13
  import * as LocalEditorShared from './local-editor-ui-shared';
12
14
  import { createShortcutHelpPanel as createLocalEditorShortcutHelpPanel } from './local-editor-ui-shortcuts';
13
15
  import { applyLocalEditorTheme, ensureLocalEditorTheme, LOCAL_EDITOR_THEME_CLASS, normalizeLocalEditorThemeName, } from './local-editor-ui-theme';
14
- import { createDefaultLocalEditorWorkbenchLayout, createLocalEditorWorkbench, createSceneHeaderToolbar, createWorkbenchPanelContent, } from './local-editor-ui-workbench';
15
- export { applyLocalEditorBrowserInspectorControlBinding, createLocalEditorBrowserInspectorControlRegistry, formatLocalEditorBrowserInspectorValue, resolveLocalEditorBrowserInspectorControlRegistration, } from './local-editor-ui-panels';
16
+ import { createDefaultLocalEditorWorkbenchLayout, createLocalEditorWorkbenchLayoutController, createLocalEditorWorkbench, createSceneHeaderToolbar, createWorkbenchPanelContent, } from './local-editor-ui-workbench';
17
+ import { createLocalEditorIcon } from './local-editor-ui-icons';
18
+ export { applyLocalEditorBrowserInspectorControlBinding, createLocalEditorBrowserInspectorControlRegistry, formatLocalEditorBrowserInspectorValue, resolveLocalEditorBrowserInspectorSectionStatus, resolveLocalEditorBrowserInspectorControlRegistration, } from './local-editor-ui-panels';
16
19
  export { DEFAULT_LOCAL_EDITOR_THEME, LOCAL_EDITOR_THEME_CLASS, applyLocalEditorTheme, normalizeLocalEditorThemeName, } from './local-editor-ui-theme';
17
20
  export { LOCAL_EDITOR_ICON_NAMES, createLocalEditorIcon, isLocalEditorIconName, resolveLocalEditorIconName, } from './local-editor-ui-icons';
18
21
  function readInspectorInputValue(input) {
@@ -67,6 +70,62 @@ const PLACEMENT_MODE_ICONS = {
67
70
  ground: 'place-ground',
68
71
  surface: 'place-surface',
69
72
  };
73
+ const PLACEMENT_MODE_DESCRIPTIONS = {
74
+ off: '关闭放置模式',
75
+ ground: '放置到 XZ 地面',
76
+ surface: '放置到场景表面',
77
+ };
78
+ const TRANSFORM_ACTION_GROUPS = [
79
+ {
80
+ title: '对齐到 Active Object',
81
+ hint: '需要至少 2 个选中对象',
82
+ actions: ['align-x', 'align-y', 'align-z', 'align-all'],
83
+ },
84
+ {
85
+ title: '均匀分布',
86
+ hint: '需要至少 3 个选中对象',
87
+ actions: ['distribute-x', 'distribute-y', 'distribute-z'],
88
+ },
89
+ ];
90
+ const TRANSFORM_ACTION_LABELS = {
91
+ 'align-x': '对齐 X',
92
+ 'align-y': '对齐 Y',
93
+ 'align-z': '对齐 Z',
94
+ 'align-all': '对齐全',
95
+ 'distribute-x': '分布 X',
96
+ 'distribute-y': '分布 Y',
97
+ 'distribute-z': '分布 Z',
98
+ };
99
+ function createTransformToolTooltip(descriptor) {
100
+ const toolLabel = descriptor.shortcut
101
+ ? `${descriptor.label}工具 (${descriptor.shortcut})`
102
+ : `${descriptor.label}工具`;
103
+ return `${toolLabel} · ${LocalEditorShared.toTransformMouseHint(descriptor.tool)}`;
104
+ }
105
+ function createToolbarIconButton(doc, label, icon, tooltip = label, toolbarRole = 'command') {
106
+ return LocalEditorShared.createButton(doc, label, {
107
+ icon,
108
+ labelMode: 'icon-only',
109
+ variant: 'toolbar-icon',
110
+ tooltip,
111
+ ariaLabel: tooltip,
112
+ toolbarRole,
113
+ });
114
+ }
115
+ function setToolbarButtonTooltip(button, tooltip) {
116
+ button.dataset.editorTooltip = tooltip;
117
+ button.setAttribute('aria-label', tooltip);
118
+ }
119
+ function setToolbarButtonIcon(doc, button, icon) {
120
+ const currentIcon = button.querySelector('[data-editor-icon]');
121
+ if (currentIcon?.dataset.editorIcon === icon)
122
+ return;
123
+ const nextIcon = createLocalEditorIcon(doc, icon);
124
+ if (currentIcon)
125
+ currentIcon.replaceWith(nextIcon);
126
+ else
127
+ button.prepend(nextIcon);
128
+ }
70
129
  function readInspectorVectorInputValue(input) {
71
130
  const wrapper = input.closest('[data-inspector-vector-control]');
72
131
  const values = {};
@@ -112,16 +171,136 @@ function readInspectorImmediateSource(input) {
112
171
  return 'color';
113
172
  return 'input';
114
173
  }
174
+ function createCoordinateAxesOverlay(doc) {
175
+ const root = doc.createElement('div');
176
+ root.dataset.editorCoordinateAxesOverlay = 'true';
177
+ root.style.cssText = [
178
+ 'position:absolute',
179
+ 'right:14px',
180
+ 'bottom:14px',
181
+ 'width:112px',
182
+ 'height:112px',
183
+ 'display:none',
184
+ 'z-index:2',
185
+ 'box-sizing:border-box',
186
+ 'border:1px solid color-mix(in srgb, var(--fps-editor-border) 78%, transparent)',
187
+ 'border-radius:8px',
188
+ 'background:color-mix(in srgb, var(--fps-editor-chrome) 58%, transparent)',
189
+ 'box-shadow:var(--fps-editor-shadow-panel)',
190
+ 'pointer-events:none',
191
+ 'overflow:hidden',
192
+ ].join(';');
193
+ const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
194
+ svg.setAttribute('viewBox', '0 0 112 112');
195
+ svg.setAttribute('aria-hidden', 'true');
196
+ svg.style.cssText = 'display:block;width:100%;height:100%';
197
+ root.appendChild(svg);
198
+ const lineLayer = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
199
+ const labelLayer = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
200
+ svg.appendChild(lineLayer);
201
+ svg.appendChild(labelLayer);
202
+ const axisGroups = new Map();
203
+ const axisLines = new Map();
204
+ const axisLabels = new Map();
205
+ const axisDiscs = new Map();
206
+ for (const axis of ['x', 'y', 'z']) {
207
+ const group = doc.createElementNS('http://www.w3.org/2000/svg', 'g');
208
+ const line = doc.createElementNS('http://www.w3.org/2000/svg', 'line');
209
+ line.setAttribute('stroke-width', '3.2');
210
+ line.setAttribute('stroke-linecap', 'round');
211
+ line.setAttribute('x1', '56');
212
+ line.setAttribute('y1', '56');
213
+ const disc = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
214
+ disc.setAttribute('r', '7');
215
+ const label = doc.createElementNS('http://www.w3.org/2000/svg', 'text');
216
+ label.setAttribute('text-anchor', 'middle');
217
+ label.setAttribute('dominant-baseline', 'middle');
218
+ label.setAttribute('dy', '0.04em');
219
+ label.style.cssText = [
220
+ 'fill:#172033',
221
+ 'font-size:10px',
222
+ 'font-weight:900',
223
+ 'font-family:Arial, Helvetica, sans-serif',
224
+ 'letter-spacing:0',
225
+ ].join(';');
226
+ group.appendChild(disc);
227
+ group.appendChild(label);
228
+ lineLayer.appendChild(line);
229
+ labelLayer.appendChild(group);
230
+ axisGroups.set(axis, group);
231
+ axisLines.set(axis, line);
232
+ axisLabels.set(axis, label);
233
+ axisDiscs.set(axis, disc);
234
+ }
235
+ const centerDot = doc.createElementNS('http://www.w3.org/2000/svg', 'circle');
236
+ centerDot.setAttribute('cx', '56');
237
+ centerDot.setAttribute('cy', '56');
238
+ centerDot.setAttribute('r', '3.2');
239
+ centerDot.setAttribute('fill', 'color-mix(in srgb, var(--fps-editor-text) 82%, transparent)');
240
+ labelLayer.appendChild(centerDot);
241
+ return {
242
+ root,
243
+ lineLayer,
244
+ labelLayer,
245
+ axisGroups,
246
+ axisLines,
247
+ axisLabels,
248
+ axisDiscs,
249
+ centerDot,
250
+ };
251
+ }
252
+ function renderCoordinateAxesOverlay(overlay, state) {
253
+ if (!state?.axes?.length) {
254
+ overlay.root.style.display = 'none';
255
+ return;
256
+ }
257
+ overlay.root.style.display = '';
258
+ const center = 56;
259
+ const lineLength = 38;
260
+ const sortedAxes = [...state.axes].sort((left, right) => right.depth - left.depth);
261
+ for (const axis of sortedAxes) {
262
+ const group = overlay.axisGroups.get(axis.id);
263
+ const line = overlay.axisLines.get(axis.id);
264
+ const disc = overlay.axisDiscs.get(axis.id);
265
+ const label = overlay.axisLabels.get(axis.id);
266
+ if (!group || !line || !disc || !label)
267
+ continue;
268
+ const length = lineLength * axis.scale;
269
+ const endX = center + axis.x * length;
270
+ const endY = center + axis.y * length;
271
+ line.setAttribute('x2', formatSvgNumber(endX));
272
+ line.setAttribute('y2', formatSvgNumber(endY));
273
+ line.setAttribute('stroke', axis.color);
274
+ disc.setAttribute('cx', formatSvgNumber(endX));
275
+ disc.setAttribute('cy', formatSvgNumber(endY));
276
+ disc.setAttribute('fill', axis.color);
277
+ label.setAttribute('x', formatSvgNumber(endX));
278
+ label.setAttribute('y', formatSvgNumber(endY));
279
+ label.textContent = axis.label;
280
+ group.style.opacity = String(0.72 + axis.scale * 0.28);
281
+ overlay.lineLayer.appendChild(line);
282
+ overlay.labelLayer.appendChild(group);
283
+ }
284
+ overlay.labelLayer.appendChild(overlay.centerDot);
285
+ }
286
+ function formatSvgNumber(value) {
287
+ return Number.isFinite(value) ? value.toFixed(2) : '0';
288
+ }
115
289
  export function createLocalEditorBrowserUi(options = {}) {
116
290
  const doc = options.document ?? document;
117
291
  const root = options.root ?? doc.body;
118
292
  const callbacks = options.callbacks ?? {};
293
+ const localTestActionsEnabled = options.localTestActions === true;
119
294
  ensureLocalEditorTheme(doc);
120
295
  let activeTheme = normalizeLocalEditorThemeName(options.theme);
121
296
  const inputRouter = createLocalEditorWorkbenchInputRouter(doc);
122
297
  const contextMenu = createLocalEditorContextMenuController(doc, (open) => {
123
298
  inputRouter.setContextMenuOpen(open);
124
299
  }, activeTheme);
300
+ const tooltipSurfaces = new Set();
301
+ const tooltipController = createLocalEditorTooltipController(doc, root, activeTheme, {
302
+ scope: () => tooltipSurfaces,
303
+ });
125
304
  let currentState = null;
126
305
  let inspectorFilter = '';
127
306
  const workbenchLayout = createDefaultLocalEditorWorkbenchLayout();
@@ -160,37 +339,64 @@ export function createLocalEditorBrowserUi(options = {}) {
160
339
  hostChrome.appendChild(hostModeLabel);
161
340
  hostChrome.appendChild(enterEditorButton);
162
341
  root.appendChild(hostChrome);
342
+ tooltipSurfaces.add(hostChrome);
163
343
  const dirtyBadge = doc.createElement('span');
344
+ dirtyBadge.dataset.editorDirtyBadge = 'true';
345
+ dirtyBadge.dataset.editorTooltip = '有未保存更改';
346
+ dirtyBadge.setAttribute('role', 'status');
347
+ dirtyBadge.setAttribute('aria-label', '有未保存更改');
164
348
  dirtyBadge.style.cssText = [
165
349
  'display:none',
166
- 'height:20px',
350
+ 'width:8px',
351
+ 'height:8px',
167
352
  'align-items:center',
168
- 'padding:0 7px',
353
+ 'justify-content:center',
354
+ 'padding:0',
169
355
  'border-radius:999px',
170
- 'background:var(--fps-editor-danger-soft)',
356
+ 'background:var(--fps-editor-danger-strong)',
171
357
  'border:1px solid var(--fps-editor-danger-border)',
172
- 'color:var(--fps-editor-danger-text)',
173
- 'font-size:11px',
174
- 'font-weight:800',
358
+ 'box-shadow:0 0 0 3px var(--fps-editor-danger-soft)',
175
359
  ].join(';');
176
- dirtyBadge.textContent = '未保存';
177
360
  const saveButton = LocalEditorShared.createButton(doc, '保存场景', { icon: 'save' });
178
361
  const saveAndRunButton = LocalEditorShared.createButton(doc, '保存并运行', { icon: 'execute' });
179
362
  const discardRunButton = LocalEditorShared.createButton(doc, '放弃并运行', { icon: 'discard' });
180
- const undoButton = LocalEditorShared.createButton(doc, '撤销', { icon: 'undo' });
181
- const redoButton = LocalEditorShared.createButton(doc, '重做', { icon: 'redo' });
182
- let helpOpen = false;
183
- const status = doc.createElement('span');
184
- status.style.cssText = [
185
- 'flex:0 1 300px',
186
- 'min-width:120px',
187
- 'max-width:320px',
188
- 'overflow:hidden',
189
- 'text-overflow:ellipsis',
190
- 'white-space:nowrap',
191
- 'color:var(--fps-editor-muted)',
192
- 'font-size:11px',
363
+ const localTestButton = createToolbarIconButton(doc, '本地测试', 'execute', '打开本地测试操作');
364
+ localTestButton.dataset.editorLocalTestToggle = 'true';
365
+ const localTestMenu = doc.createElement('div');
366
+ localTestMenu.classList.add(LOCAL_EDITOR_THEME_CLASS);
367
+ localTestMenu.dataset.editorLocalTestMenu = 'true';
368
+ localTestMenu.style.cssText = [
369
+ 'position:fixed',
370
+ 'z-index:2147483641',
371
+ 'display:none',
372
+ 'flex-direction:column',
373
+ 'gap:6px',
374
+ 'min-width:150px',
375
+ 'padding:8px',
376
+ 'border:1px solid var(--fps-editor-border)',
377
+ 'border-radius:3px',
378
+ 'background:var(--fps-editor-panel)',
379
+ 'box-shadow:var(--fps-editor-shadow-popover)',
380
+ 'pointer-events:auto',
381
+ ].join(';');
382
+ for (const button of [saveButton, saveAndRunButton, discardRunButton]) {
383
+ button.style.justifyContent = 'flex-start';
384
+ button.style.width = '100%';
385
+ localTestMenu.appendChild(button);
386
+ }
387
+ const localTestGroup = doc.createElement('div');
388
+ localTestGroup.style.cssText = [
389
+ `display:${localTestActionsEnabled ? 'flex' : 'none'}`,
390
+ 'align-items:center',
391
+ 'gap:4px',
193
392
  ].join(';');
393
+ localTestGroup.appendChild(localTestButton);
394
+ const undoButton = createToolbarIconButton(doc, '撤销', 'undo', '撤销');
395
+ const redoButton = createToolbarIconButton(doc, '重做', 'redo', '重做');
396
+ let helpOpen = false;
397
+ const editorStatusButton = createToolbarIconButton(doc, '编辑器状态', 'status', '编辑器状态');
398
+ editorStatusButton.dataset.editorStatusButton = 'true';
399
+ editorStatusButton.style.cursor = 'help';
194
400
  const workbench = createLocalEditorWorkbench(doc);
195
401
  const hierarchyPanel = createWorkbenchPanelContent(doc);
196
402
  const assetPanel = workbench.bottomDock;
@@ -198,6 +404,8 @@ export function createLocalEditorBrowserUi(options = {}) {
198
404
  workbench.leftDock.appendChild(hierarchyPanel);
199
405
  workbench.rightDock.appendChild(inspectorPanel);
200
406
  root.appendChild(workbench.root);
407
+ tooltipSurfaces.add(workbench.root);
408
+ const workbenchLayoutController = createLocalEditorWorkbenchLayoutController(doc, workbench);
201
409
  const sceneToolOverlay = createSceneHeaderToolbar(doc);
202
410
  const sceneTitle = doc.createElement('div');
203
411
  sceneTitle.textContent = 'Preview';
@@ -215,6 +423,14 @@ export function createLocalEditorBrowserUi(options = {}) {
215
423
  ].join(';');
216
424
  const sceneQuickActions = doc.createElement('div');
217
425
  sceneQuickActions.style.cssText = 'display:flex;align-items:center;gap:4px';
426
+ const sceneUtilityActions = doc.createElement('div');
427
+ sceneUtilityActions.style.cssText = [
428
+ 'display:flex',
429
+ 'align-items:center',
430
+ 'gap:4px',
431
+ 'padding-left:8px',
432
+ 'border-left:1px solid var(--fps-editor-divider)',
433
+ ].join(';');
218
434
  const toolGroup = doc.createElement('div');
219
435
  toolGroup.style.cssText = [
220
436
  'display:flex',
@@ -226,14 +442,15 @@ export function createLocalEditorBrowserUi(options = {}) {
226
442
  const toolButtons = new Map();
227
443
  const transformToolDescriptors = Object.values(DEFAULT_EDITOR_TRANSFORM_TOOL_DESCRIPTORS);
228
444
  for (const descriptor of transformToolDescriptors) {
229
- const shortcut = descriptor.shortcut ? `${descriptor.shortcut} ` : '';
230
- const button = LocalEditorShared.createButton(doc, `${shortcut}${descriptor.label}`, {
445
+ const tooltip = createTransformToolTooltip(descriptor);
446
+ const button = LocalEditorShared.createButton(doc, descriptor.label, {
231
447
  icon: TRANSFORM_TOOL_ICONS[descriptor.tool],
448
+ labelMode: 'icon-only',
449
+ variant: 'toolbar-icon',
450
+ tooltip,
451
+ ariaLabel: tooltip,
232
452
  });
233
453
  button.dataset.transformTool = descriptor.tool;
234
- button.title = descriptor.handles.length > 0
235
- ? `${descriptor.label} · ${descriptor.handles.map(handle => handle.label).join(' / ')}`
236
- : descriptor.label;
237
454
  toolButtons.set(descriptor.tool, button);
238
455
  toolGroup.appendChild(button);
239
456
  }
@@ -246,21 +463,13 @@ export function createLocalEditorBrowserUi(options = {}) {
246
463
  'border-left:1px solid var(--fps-editor-divider)',
247
464
  ].join(';');
248
465
  const spaceButtons = {
249
- world: LocalEditorShared.createButton(doc, '世界', { icon: TRANSFORM_SPACE_ICONS.world }),
250
- local: LocalEditorShared.createButton(doc, '本地', { icon: TRANSFORM_SPACE_ICONS.local }),
466
+ world: createToolbarIconButton(doc, '世界', TRANSFORM_SPACE_ICONS.world, '世界坐标空间'),
467
+ local: createToolbarIconButton(doc, '本地', TRANSFORM_SPACE_ICONS.local, '本地坐标空间'),
251
468
  };
252
469
  for (const [space, button] of Object.entries(spaceButtons)) {
253
470
  button.dataset.transformSpace = space;
254
471
  spaceGroup.appendChild(button);
255
472
  }
256
- const handleGroup = doc.createElement('div');
257
- handleGroup.style.cssText = [
258
- 'display:flex',
259
- 'align-items:center',
260
- 'gap:4px',
261
- 'padding-left:8px',
262
- 'border-left:1px solid var(--fps-editor-divider)',
263
- ].join(';');
264
473
  const snapGroup = doc.createElement('div');
265
474
  snapGroup.style.cssText = [
266
475
  'display:flex',
@@ -269,24 +478,71 @@ export function createLocalEditorBrowserUi(options = {}) {
269
478
  'padding-left:8px',
270
479
  'border-left:1px solid var(--fps-editor-divider)',
271
480
  ].join(';');
272
- const snapButton = LocalEditorShared.createButton(doc, '吸附', { icon: 'snap' });
481
+ const snapButton = createToolbarIconButton(doc, '吸附设置', 'snap', '吸附设置', 'settings');
273
482
  snapButton.dataset.transformSnapToggle = 'true';
274
- snapButton.title = '移动 / 旋转 / 缩放吸附';
483
+ const snapSettingsPopover = doc.createElement('div');
484
+ snapSettingsPopover.classList.add(LOCAL_EDITOR_THEME_CLASS);
485
+ snapSettingsPopover.dataset.transformSnapSettingsPopover = 'true';
486
+ snapSettingsPopover.setAttribute('role', 'dialog');
487
+ snapSettingsPopover.setAttribute('aria-label', '吸附设置');
488
+ snapSettingsPopover.style.cssText = [
489
+ 'position:fixed',
490
+ 'z-index:2147483641',
491
+ 'display:none',
492
+ 'flex-direction:column',
493
+ 'gap:8px',
494
+ 'width:220px',
495
+ 'padding:10px',
496
+ 'border:1px solid var(--fps-editor-border)',
497
+ 'border-radius:3px',
498
+ 'background:var(--fps-editor-panel)',
499
+ 'box-shadow:var(--fps-editor-shadow-popover)',
500
+ 'pointer-events:auto',
501
+ ].join(';');
502
+ const snapSettingsHeader = doc.createElement('label');
503
+ snapSettingsHeader.style.cssText = [
504
+ 'display:flex',
505
+ 'align-items:center',
506
+ 'justify-content:space-between',
507
+ 'gap:8px',
508
+ 'color:var(--fps-editor-text)',
509
+ 'font-size:12px',
510
+ 'font-weight:900',
511
+ ].join(';');
512
+ const snapSettingsHeaderText = doc.createElement('span');
513
+ snapSettingsHeaderText.textContent = '启用吸附';
514
+ const snapEnabledInput = doc.createElement('input');
515
+ snapEnabledInput.type = 'checkbox';
516
+ snapEnabledInput.dataset.transformSnapEnabled = 'true';
517
+ snapSettingsHeader.appendChild(snapSettingsHeaderText);
518
+ snapSettingsHeader.appendChild(snapEnabledInput);
519
+ snapSettingsPopover.appendChild(snapSettingsHeader);
275
520
  const snapStepInputs = new Map();
276
- for (const [kind, label, title] of [
277
- ['move', 'M', '移动吸附步长'],
278
- ['rotate', 'R', '旋转吸附角度'],
279
- ['scale', 'S', '缩放吸附步长'],
521
+ for (const [kind, label, unit] of [
522
+ ['move', '位移', '单位'],
523
+ ['rotate', '旋转', ''],
524
+ ['scale', '缩放', '倍数'],
280
525
  ]) {
526
+ const row = doc.createElement('label');
527
+ row.style.cssText = [
528
+ 'display:grid',
529
+ 'grid-template-columns:54px 1fr 34px',
530
+ 'align-items:center',
531
+ 'gap:8px',
532
+ 'color:var(--fps-editor-muted-strong)',
533
+ 'font-size:11px',
534
+ 'font-weight:800',
535
+ ].join(';');
536
+ const rowLabel = doc.createElement('span');
537
+ rowLabel.textContent = label;
281
538
  const input = doc.createElement('input');
282
539
  input.type = 'number';
283
540
  input.min = '0.0001';
284
541
  input.step = kind === 'rotate' ? '1' : '0.1';
285
542
  input.dataset.transformSnapStep = kind;
286
- input.title = `${label} · ${title}`;
287
543
  input.style.cssText = [
288
- 'width:46px',
289
544
  'height:26px',
545
+ 'min-width:0',
290
546
  'box-sizing:border-box',
291
547
  'border:1px solid var(--fps-editor-border)',
292
548
  'border-radius:3px',
@@ -296,11 +552,16 @@ export function createLocalEditorBrowserUi(options = {}) {
296
552
  'font-weight:800',
297
553
  'padding:0 4px',
298
554
  ].join(';');
555
+ const rowUnit = doc.createElement('span');
556
+ rowUnit.textContent = unit;
557
+ rowUnit.style.cssText = 'color:var(--fps-editor-muted);font-size:10px;font-weight:800';
558
+ row.appendChild(rowLabel);
559
+ row.appendChild(input);
560
+ row.appendChild(rowUnit);
561
+ snapSettingsPopover.appendChild(row);
299
562
  snapStepInputs.set(kind, input);
300
563
  }
301
564
  snapGroup.appendChild(snapButton);
302
- for (const input of snapStepInputs.values())
303
- snapGroup.appendChild(input);
304
565
  const placementGroup = doc.createElement('div');
305
566
  placementGroup.style.cssText = [
306
567
  'display:flex',
@@ -309,22 +570,40 @@ export function createLocalEditorBrowserUi(options = {}) {
309
570
  'padding-left:8px',
310
571
  'border-left:1px solid var(--fps-editor-divider)',
311
572
  ].join(';');
312
- const placementLabel = doc.createElement('span');
313
- placementLabel.textContent = '放置';
314
- placementLabel.style.cssText = 'color:var(--fps-editor-muted);font-size:11px;font-weight:900;white-space:nowrap';
315
- const placementButtons = {
316
- off: LocalEditorShared.createButton(doc, '', { icon: PLACEMENT_MODE_ICONS.off }),
317
- ground: LocalEditorShared.createButton(doc, '', { icon: PLACEMENT_MODE_ICONS.ground }),
318
- surface: LocalEditorShared.createButton(doc, '', { icon: PLACEMENT_MODE_ICONS.surface }),
319
- };
320
- placementButtons.off.title = '关闭放置模式';
321
- placementButtons.ground.title = '放置到 XZ 地面';
322
- placementButtons.surface.title = '放置到场景表面';
323
- placementGroup.appendChild(placementLabel);
324
- for (const [mode, button] of Object.entries(placementButtons)) {
573
+ const placementButton = createToolbarIconButton(doc, '放置模式', PLACEMENT_MODE_ICONS.off, '放置模式', 'settings');
574
+ placementButton.dataset.placementModeToggle = 'true';
575
+ const placementSettingsPopover = doc.createElement('div');
576
+ placementSettingsPopover.classList.add(LOCAL_EDITOR_THEME_CLASS);
577
+ placementSettingsPopover.dataset.placementSettingsPopover = 'true';
578
+ placementSettingsPopover.setAttribute('role', 'dialog');
579
+ placementSettingsPopover.setAttribute('aria-label', '放置模式');
580
+ placementSettingsPopover.style.cssText = [
581
+ 'position:fixed',
582
+ 'z-index:2147483641',
583
+ 'display:none',
584
+ 'flex-direction:column',
585
+ 'gap:5px',
586
+ 'width:180px',
587
+ 'padding:8px',
588
+ 'border:1px solid var(--fps-editor-border)',
589
+ 'border-radius:3px',
590
+ 'background:var(--fps-editor-panel)',
591
+ 'box-shadow:var(--fps-editor-shadow-popover)',
592
+ 'pointer-events:auto',
593
+ ].join(';');
594
+ const placementButtons = Object.fromEntries(Object.keys(PLACEMENT_MODE_ICONS).map((mode) => {
595
+ const button = LocalEditorShared.createButton(doc, PLACEMENT_MODE_DESCRIPTIONS[mode], {
596
+ icon: PLACEMENT_MODE_ICONS[mode],
597
+ tooltip: PLACEMENT_MODE_DESCRIPTIONS[mode],
598
+ ariaLabel: PLACEMENT_MODE_DESCRIPTIONS[mode],
599
+ });
325
600
  button.dataset.placementMode = mode;
326
- placementGroup.appendChild(button);
327
- }
601
+ button.style.justifyContent = 'flex-start';
602
+ button.style.width = '100%';
603
+ placementSettingsPopover.appendChild(button);
604
+ return [mode, button];
605
+ }));
606
+ placementGroup.appendChild(placementButton);
328
607
  const actionGroup = doc.createElement('div');
329
608
  actionGroup.style.cssText = [
330
609
  'display:flex',
@@ -333,74 +612,75 @@ export function createLocalEditorBrowserUi(options = {}) {
333
612
  'padding-left:8px',
334
613
  'border-left:1px solid var(--fps-editor-divider)',
335
614
  ].join(';');
336
- const transformActionSelect = doc.createElement('select');
337
- transformActionSelect.dataset.transformActionSelect = 'true';
338
- transformActionSelect.title = '多选对齐 / 分布';
339
- transformActionSelect.style.cssText = [
340
- 'height:26px',
615
+ const transformActionButton = createToolbarIconButton(doc, '对齐与分布', 'arrange', '对齐与分布', 'settings');
616
+ transformActionButton.dataset.transformActionToggle = 'true';
617
+ const transformActionPopover = doc.createElement('div');
618
+ transformActionPopover.classList.add(LOCAL_EDITOR_THEME_CLASS);
619
+ transformActionPopover.dataset.transformActionPopover = 'true';
620
+ transformActionPopover.setAttribute('role', 'dialog');
621
+ transformActionPopover.setAttribute('aria-label', '对齐与分布');
622
+ transformActionPopover.style.cssText = [
623
+ 'position:fixed',
624
+ 'z-index:2147483641',
625
+ 'display:none',
626
+ 'flex-direction:column',
627
+ 'gap:9px',
628
+ 'width:236px',
629
+ 'padding:10px',
341
630
  'border:1px solid var(--fps-editor-border)',
342
631
  'border-radius:3px',
343
- 'background:var(--fps-editor-field)',
344
- 'color:var(--fps-editor-text)',
345
- 'font-size:11px',
346
- 'font-weight:800',
347
- 'padding:0 4px',
632
+ 'background:var(--fps-editor-panel)',
633
+ 'box-shadow:var(--fps-editor-shadow-popover)',
634
+ 'pointer-events:auto',
348
635
  ].join(';');
349
- let selectedTransformAction = 'align-all';
350
- for (const action of [
351
- 'align-x',
352
- 'align-y',
353
- 'align-z',
354
- 'align-all',
355
- 'distribute-x',
356
- 'distribute-y',
357
- 'distribute-z',
358
- ]) {
359
- const option = doc.createElement('option');
360
- option.value = action;
361
- option.textContent = LocalEditorShared.toTransformActionLabel(action);
362
- transformActionSelect.appendChild(option);
363
- }
364
- transformActionSelect.value = selectedTransformAction;
365
- const transformActionButton = LocalEditorShared.createButton(doc, '执行', { icon: 'execute' });
366
- transformActionButton.dataset.transformActionRun = 'true';
367
- transformActionButton.title = '对当前多选执行 Transform 操作';
368
- actionGroup.appendChild(transformActionSelect);
636
+ const transformActionButtons = new Map();
637
+ for (const group of TRANSFORM_ACTION_GROUPS) {
638
+ const section = doc.createElement('section');
639
+ section.style.cssText = 'display:flex;flex-direction:column;gap:5px';
640
+ const heading = doc.createElement('div');
641
+ heading.textContent = group.title;
642
+ heading.style.cssText = [
643
+ 'color:var(--fps-editor-text-strong)',
644
+ 'font-size:11px',
645
+ 'font-weight:900',
646
+ ].join(';');
647
+ const hint = doc.createElement('div');
648
+ hint.textContent = group.hint;
649
+ hint.style.cssText = [
650
+ 'color:var(--fps-editor-muted)',
651
+ 'font-size:10px',
652
+ 'font-weight:700',
653
+ 'margin-top:-3px',
654
+ ].join(';');
655
+ section.appendChild(heading);
656
+ section.appendChild(hint);
657
+ for (const action of group.actions) {
658
+ const button = LocalEditorShared.createButton(doc, TRANSFORM_ACTION_LABELS[action], {
659
+ icon: 'arrange',
660
+ tooltip: TRANSFORM_ACTION_LABELS[action],
661
+ ariaLabel: TRANSFORM_ACTION_LABELS[action],
662
+ });
663
+ button.dataset.transformAction = action;
664
+ button.style.justifyContent = 'flex-start';
665
+ button.style.width = '100%';
666
+ transformActionButtons.set(action, button);
667
+ section.appendChild(button);
668
+ }
669
+ transformActionPopover.appendChild(section);
670
+ }
369
671
  actionGroup.appendChild(transformActionButton);
370
- const sceneToolStatus = doc.createElement('span');
371
- sceneToolStatus.style.cssText = [
372
- 'max-width:220px',
373
- 'overflow:hidden',
374
- 'text-overflow:ellipsis',
375
- 'white-space:nowrap',
376
- 'color:var(--fps-editor-muted-strong)',
377
- 'font-size:11px',
378
- ].join(';');
379
- const sceneMouseHint = doc.createElement('span');
380
- sceneMouseHint.style.cssText = [
381
- 'padding-left:8px',
382
- 'border-left:1px solid var(--fps-editor-divider)',
383
- 'max-width:360px',
384
- 'overflow:hidden',
385
- 'text-overflow:ellipsis',
386
- 'white-space:nowrap',
387
- 'color:var(--fps-editor-muted)',
388
- 'font-size:11px',
389
- ].join(';');
390
- sceneMouseHint.textContent = '左键选择 · 空白拖拽框选 · 中键平移 · Alt+左键环绕 · 右键飞行 · 滚轮缩放';
391
- const themeToggleButton = LocalEditorShared.createButton(doc, '主题', { icon: 'theme' });
672
+ const themeToggleButton = createToolbarIconButton(doc, '主题', 'theme', '切换主题');
392
673
  themeToggleButton.dataset.editorThemeToggle = 'true';
393
- themeToggleButton.style.padding = '5px 7px';
394
- const sceneHelpButton = LocalEditorShared.createButton(doc, '快捷键', { icon: 'help' });
395
- sceneHelpButton.style.padding = '5px 7px';
396
- sceneQuickActions.appendChild(saveButton);
397
- sceneQuickActions.appendChild(saveAndRunButton);
398
- sceneQuickActions.appendChild(discardRunButton);
674
+ const gridToggleButton = createToolbarIconButton(doc, '网格', 'grid', '显示 / 隐藏 Scene View 网格');
675
+ gridToggleButton.dataset.editorGridToggle = 'true';
676
+ const sceneHelpButton = createToolbarIconButton(doc, '快捷键', 'help', '快捷键与操作说明');
677
+ sceneQuickActions.appendChild(localTestGroup);
399
678
  sceneQuickActions.appendChild(undoButton);
400
679
  sceneQuickActions.appendChild(redoButton);
401
680
  sceneQuickActions.appendChild(dirtyBadge);
402
- sceneQuickActions.appendChild(themeToggleButton);
403
- sceneQuickActions.appendChild(sceneHelpButton);
681
+ sceneUtilityActions.appendChild(gridToggleButton);
682
+ sceneUtilityActions.appendChild(themeToggleButton);
683
+ sceneUtilityActions.appendChild(sceneHelpButton);
404
684
  const cameraPreviewGroup = doc.createElement('div');
405
685
  cameraPreviewGroup.style.cssText = [
406
686
  'display:flex',
@@ -409,23 +689,56 @@ export function createLocalEditorBrowserUi(options = {}) {
409
689
  'padding-left:8px',
410
690
  'border-left:1px solid var(--fps-editor-divider)',
411
691
  ].join(';');
412
- const sceneCameraButton = LocalEditorShared.createButton(doc, 'Scene Camera', { icon: 'camera' });
692
+ const sceneCameraButton = createToolbarIconButton(doc, ' Main Camera 查看场景', 'camera');
413
693
  sceneCameraButton.dataset.sceneCameraPreviewToggle = 'true';
414
- sceneCameraButton.title = '从 Main Camera 查看当前场景';
415
694
  cameraPreviewGroup.appendChild(sceneCameraButton);
695
+ const toolbarOverflowButton = createToolbarIconButton(doc, '更多', 'chevron-down', '显示隐藏的工具栏命令');
696
+ toolbarOverflowButton.dataset.editorToolbarOverflowToggle = 'true';
697
+ toolbarOverflowButton.style.display = 'none';
698
+ const toolbarOverflowMenu = doc.createElement('div');
699
+ toolbarOverflowMenu.classList.add(LOCAL_EDITOR_THEME_CLASS);
700
+ toolbarOverflowMenu.dataset.editorToolbarOverflowMenu = 'true';
701
+ toolbarOverflowMenu.style.cssText = [
702
+ 'position:fixed',
703
+ 'z-index:2147483641',
704
+ 'display:none',
705
+ 'flex-direction:column',
706
+ 'gap:6px',
707
+ 'min-width:220px',
708
+ 'max-width:min(420px, calc(100vw - 16px))',
709
+ 'max-height:calc(100vh - 72px)',
710
+ 'overflow:auto',
711
+ 'padding:8px',
712
+ 'border:1px solid var(--fps-editor-border)',
713
+ 'border-radius:3px',
714
+ 'background:var(--fps-editor-panel)',
715
+ 'box-shadow:var(--fps-editor-shadow-popover)',
716
+ 'pointer-events:auto',
717
+ ].join(';');
718
+ const coordinateAxesOverlay = createCoordinateAxesOverlay(doc);
416
719
  sceneToolOverlay.appendChild(sceneTitle);
417
720
  sceneToolOverlay.appendChild(sceneQuickActions);
721
+ sceneToolOverlay.appendChild(sceneUtilityActions);
418
722
  sceneToolOverlay.appendChild(cameraPreviewGroup);
419
723
  sceneToolOverlay.appendChild(toolGroup);
420
724
  sceneToolOverlay.appendChild(spaceGroup);
421
- sceneToolOverlay.appendChild(handleGroup);
422
725
  sceneToolOverlay.appendChild(snapGroup);
423
726
  sceneToolOverlay.appendChild(placementGroup);
424
727
  sceneToolOverlay.appendChild(actionGroup);
425
- sceneToolOverlay.appendChild(sceneToolStatus);
426
- sceneToolOverlay.appendChild(sceneMouseHint);
427
- sceneToolOverlay.appendChild(status);
728
+ sceneToolOverlay.appendChild(editorStatusButton);
729
+ sceneToolOverlay.appendChild(toolbarOverflowButton);
428
730
  workbench.sceneHeader.appendChild(sceneToolOverlay);
731
+ workbench.sceneFrame.appendChild(coordinateAxesOverlay.root);
732
+ root.appendChild(localTestMenu);
733
+ root.appendChild(toolbarOverflowMenu);
734
+ root.appendChild(snapSettingsPopover);
735
+ root.appendChild(placementSettingsPopover);
736
+ root.appendChild(transformActionPopover);
737
+ tooltipSurfaces.add(localTestMenu);
738
+ tooltipSurfaces.add(toolbarOverflowMenu);
739
+ tooltipSurfaces.add(snapSettingsPopover);
740
+ tooltipSurfaces.add(placementSettingsPopover);
741
+ tooltipSurfaces.add(transformActionPopover);
429
742
  const boxSelectionOverlay = doc.createElement('div');
430
743
  boxSelectionOverlay.classList.add(LOCAL_EDITOR_THEME_CLASS);
431
744
  boxSelectionOverlay.style.cssText = [
@@ -438,35 +751,394 @@ export function createLocalEditorBrowserUi(options = {}) {
438
751
  'pointer-events:none',
439
752
  ].join(';');
440
753
  root.appendChild(boxSelectionOverlay);
754
+ tooltipSurfaces.add(boxSelectionOverlay);
441
755
  const shortcutHelpPanel = createLocalEditorShortcutHelpPanel(doc);
442
756
  shortcutHelpPanel.classList.add(LOCAL_EDITOR_THEME_CLASS);
443
757
  root.appendChild(shortcutHelpPanel);
758
+ tooltipSurfaces.add(shortcutHelpPanel);
444
759
  function applyThemeToSurfaces() {
445
760
  applyLocalEditorTheme(hostChrome, activeTheme);
446
761
  applyLocalEditorTheme(workbench.root, activeTheme);
447
762
  applyLocalEditorTheme(shortcutHelpPanel, activeTheme);
448
763
  applyLocalEditorTheme(boxSelectionOverlay, activeTheme);
764
+ applyLocalEditorTheme(localTestMenu, activeTheme);
765
+ applyLocalEditorTheme(toolbarOverflowMenu, activeTheme);
766
+ applyLocalEditorTheme(snapSettingsPopover, activeTheme);
767
+ applyLocalEditorTheme(placementSettingsPopover, activeTheme);
768
+ applyLocalEditorTheme(transformActionPopover, activeTheme);
769
+ tooltipController.setTheme(activeTheme);
449
770
  contextMenu.setTheme?.(activeTheme);
450
771
  }
451
772
  function updateThemeToggleButton() {
452
773
  const nextTheme = activeTheme === 'dark' ? 'light' : 'dark';
453
774
  themeToggleButton.dataset.editorTheme = activeTheme;
454
775
  themeToggleButton.setAttribute('aria-pressed', activeTheme === 'light' ? 'true' : 'false');
455
- themeToggleButton.title = nextTheme === 'light' ? '切换为浅色主题' : '切换为深色主题';
776
+ setToolbarButtonTooltip(themeToggleButton, nextTheme === 'light' ? '切换为浅色主题' : '切换为深色主题');
456
777
  }
457
778
  function setActiveTheme(theme) {
458
779
  activeTheme = normalizeLocalEditorThemeName(theme);
459
780
  applyThemeToSurfaces();
460
781
  updateThemeToggleButton();
461
782
  }
783
+ let localTestMenuOpen = false;
784
+ let snapSettingsOpen = false;
785
+ let placementSettingsOpen = false;
786
+ let transformActionOpen = false;
787
+ function closeLocalTestMenu() {
788
+ localTestMenuOpen = false;
789
+ localTestMenu.style.display = 'none';
790
+ LocalEditorShared.applyButtonActiveState(localTestButton, false);
791
+ }
792
+ function closeSnapSettingsPopover() {
793
+ snapSettingsOpen = false;
794
+ snapSettingsPopover.style.display = 'none';
795
+ snapButton.setAttribute('aria-expanded', 'false');
796
+ const enabled = currentState?.transformOperations?.settings.snap.enabled
797
+ ?? DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS.snap.enabled;
798
+ LocalEditorShared.applyButtonActiveState(snapButton, enabled);
799
+ }
800
+ function closePlacementSettingsPopover() {
801
+ placementSettingsOpen = false;
802
+ placementSettingsPopover.style.display = 'none';
803
+ placementButton.setAttribute('aria-expanded', 'false');
804
+ const mode = currentState?.transformOperations?.settings.placementMode
805
+ ?? DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS.placementMode;
806
+ LocalEditorShared.applyButtonActiveState(placementButton, mode !== 'off');
807
+ }
808
+ function closeTransformActionPopover() {
809
+ transformActionOpen = false;
810
+ transformActionPopover.style.display = 'none';
811
+ transformActionButton.setAttribute('aria-expanded', 'false');
812
+ LocalEditorShared.applyButtonActiveState(transformActionButton, false);
813
+ }
814
+ function openLocalTestMenu() {
815
+ if (!localTestActionsEnabled || localTestButton.style.display === 'none')
816
+ return;
817
+ closeToolbarOverflowMenu();
818
+ closeSnapSettingsPopover();
819
+ closePlacementSettingsPopover();
820
+ closeTransformActionPopover();
821
+ const win = doc.defaultView;
822
+ const rect = localTestButton.getBoundingClientRect();
823
+ const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
824
+ localTestMenu.style.top = `${Math.max(8, rect.bottom + 5)}px`;
825
+ localTestMenu.style.right = `${Math.max(8, viewportWidth - rect.right)}px`;
826
+ localTestMenu.style.display = 'flex';
827
+ localTestMenuOpen = true;
828
+ LocalEditorShared.applyButtonActiveState(localTestButton, true);
829
+ }
830
+ function openSnapSettingsPopover() {
831
+ if (snapButton.disabled || snapGroup.style.display === 'none')
832
+ return;
833
+ closeToolbarOverflowMenu();
834
+ closeLocalTestMenu();
835
+ closePlacementSettingsPopover();
836
+ closeTransformActionPopover();
837
+ const win = doc.defaultView;
838
+ const rect = snapButton.getBoundingClientRect();
839
+ const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
840
+ snapSettingsPopover.style.display = 'flex';
841
+ const popoverWidth = snapSettingsPopover.offsetWidth || 220;
842
+ snapSettingsPopover.style.top = `${Math.max(8, rect.bottom + 5)}px`;
843
+ snapSettingsPopover.style.left = `${Math.max(8, Math.min(rect.left, viewportWidth - popoverWidth - 8))}px`;
844
+ snapSettingsOpen = true;
845
+ snapButton.setAttribute('aria-expanded', 'true');
846
+ LocalEditorShared.applyButtonActiveState(snapButton, true);
847
+ }
848
+ function toggleSnapSettingsPopover() {
849
+ if (snapSettingsOpen)
850
+ closeSnapSettingsPopover();
851
+ else
852
+ openSnapSettingsPopover();
853
+ }
854
+ function openPlacementSettingsPopover() {
855
+ if (placementButton.disabled || placementGroup.style.display === 'none')
856
+ return;
857
+ closeToolbarOverflowMenu();
858
+ closeLocalTestMenu();
859
+ closeSnapSettingsPopover();
860
+ closeTransformActionPopover();
861
+ const win = doc.defaultView;
862
+ const rect = placementButton.getBoundingClientRect();
863
+ const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
864
+ placementSettingsPopover.style.display = 'flex';
865
+ const popoverWidth = placementSettingsPopover.offsetWidth || 180;
866
+ placementSettingsPopover.style.top = `${Math.max(8, rect.bottom + 5)}px`;
867
+ placementSettingsPopover.style.left = `${Math.max(8, Math.min(rect.left, viewportWidth - popoverWidth - 8))}px`;
868
+ placementSettingsOpen = true;
869
+ placementButton.setAttribute('aria-expanded', 'true');
870
+ LocalEditorShared.applyButtonActiveState(placementButton, true);
871
+ }
872
+ function togglePlacementSettingsPopover() {
873
+ if (placementSettingsOpen)
874
+ closePlacementSettingsPopover();
875
+ else
876
+ openPlacementSettingsPopover();
877
+ }
878
+ function openTransformActionPopover() {
879
+ if (transformActionButton.disabled || actionGroup.style.display === 'none')
880
+ return;
881
+ closeToolbarOverflowMenu();
882
+ closeLocalTestMenu();
883
+ closeSnapSettingsPopover();
884
+ closePlacementSettingsPopover();
885
+ const win = doc.defaultView;
886
+ const rect = transformActionButton.getBoundingClientRect();
887
+ const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
888
+ transformActionPopover.style.display = 'flex';
889
+ const popoverWidth = transformActionPopover.offsetWidth || 236;
890
+ transformActionPopover.style.top = `${Math.max(8, rect.bottom + 5)}px`;
891
+ transformActionPopover.style.left = `${Math.max(8, Math.min(rect.left, viewportWidth - popoverWidth - 8))}px`;
892
+ transformActionOpen = true;
893
+ transformActionButton.setAttribute('aria-expanded', 'true');
894
+ LocalEditorShared.applyButtonActiveState(transformActionButton, true);
895
+ }
896
+ function toggleTransformActionPopover() {
897
+ if (transformActionOpen)
898
+ closeTransformActionPopover();
899
+ else
900
+ openTransformActionPopover();
901
+ }
902
+ const onLocalTestButtonClick = (event) => {
903
+ event.stopPropagation();
904
+ if (localTestMenuOpen)
905
+ closeLocalTestMenu();
906
+ else
907
+ openLocalTestMenu();
908
+ };
909
+ const toolbarOverflowStyleProperties = [
910
+ 'alignSelf',
911
+ 'borderLeft',
912
+ 'display',
913
+ 'flex',
914
+ 'height',
915
+ 'maxWidth',
916
+ 'minHeight',
917
+ 'minWidth',
918
+ 'overflow',
919
+ 'paddingLeft',
920
+ 'textOverflow',
921
+ 'whiteSpace',
922
+ 'width',
923
+ ];
924
+ const toolbarButtonOriginalPadding = new WeakMap();
925
+ const toolbarOverflowOriginalStyles = new WeakMap();
926
+ const toolbarOrder = [
927
+ sceneTitle,
928
+ sceneQuickActions,
929
+ sceneUtilityActions,
930
+ cameraPreviewGroup,
931
+ toolGroup,
932
+ spaceGroup,
933
+ snapGroup,
934
+ placementGroup,
935
+ actionGroup,
936
+ editorStatusButton,
937
+ toolbarOverflowButton,
938
+ ];
939
+ const toolbarOverflowItems = [
940
+ { id: 'editor-status', element: editorStatusButton, kind: 'group' },
941
+ { id: 'transform-actions', element: actionGroup, kind: 'group' },
942
+ { id: 'placement', element: placementGroup, kind: 'group' },
943
+ { id: 'snap', element: snapGroup, kind: 'group' },
944
+ { id: 'space', element: spaceGroup, kind: 'group' },
945
+ { id: 'camera-preview', element: cameraPreviewGroup, kind: 'group' },
946
+ { id: 'scene-utilities', element: sceneUtilityActions, kind: 'group' },
947
+ ];
948
+ let toolbarOverflowOpen = false;
949
+ let toolbarOverflowRaf = null;
950
+ function restoreToolbarOverflowItemStyle(item) {
951
+ const original = toolbarOverflowOriginalStyles.get(item.element);
952
+ if (!original)
953
+ return;
954
+ for (const property of toolbarOverflowStyleProperties) {
955
+ item.element.style[property] = original[property] ?? '';
956
+ }
957
+ toolbarOverflowOriginalStyles.delete(item.element);
958
+ item.element.dataset.editorToolbarOverflowPlacement = 'toolbar';
959
+ }
960
+ function applyToolbarOverflowMenuStyle(item) {
961
+ if (!toolbarOverflowOriginalStyles.has(item.element)) {
962
+ const original = {};
963
+ for (const property of toolbarOverflowStyleProperties) {
964
+ original[property] = item.element.style[property];
965
+ }
966
+ toolbarOverflowOriginalStyles.set(item.element, original);
967
+ }
968
+ item.element.dataset.editorToolbarOverflowPlacement = 'menu';
969
+ item.element.style.alignSelf = 'stretch';
970
+ item.element.style.borderLeft = '0';
971
+ item.element.style.display = item.kind === 'group' ? 'flex' : 'block';
972
+ item.element.style.flex = '0 0 auto';
973
+ item.element.style.height = '';
974
+ item.element.style.maxWidth = 'none';
975
+ item.element.style.minHeight = '0';
976
+ item.element.style.minWidth = '0';
977
+ item.element.style.overflow = item.kind === 'group' ? 'visible' : 'hidden';
978
+ item.element.style.paddingLeft = '0';
979
+ item.element.style.textOverflow = item.kind === 'text' ? 'ellipsis' : '';
980
+ item.element.style.whiteSpace = item.kind === 'text' ? 'nowrap' : '';
981
+ item.element.style.width = '100%';
982
+ }
983
+ function setToolbarButtonCompact(button, compact) {
984
+ if (button.dataset.editorButtonLabelMode === 'icon-only')
985
+ return;
986
+ const label = button.querySelector('[data-editor-button-label]');
987
+ if (!label)
988
+ return;
989
+ if (!toolbarButtonOriginalPadding.has(button)) {
990
+ toolbarButtonOriginalPadding.set(button, button.style.padding);
991
+ }
992
+ const accessibleLabel = button.dataset.editorToolbarLabel || label.textContent || button.title;
993
+ button.dataset.editorToolbarLabel = accessibleLabel;
994
+ button.setAttribute('aria-label', accessibleLabel);
995
+ if (!button.title)
996
+ button.title = accessibleLabel;
997
+ button.style.padding = compact ? '4px 6px' : toolbarButtonOriginalPadding.get(button) ?? '';
998
+ label.style.display = compact ? 'inline-block' : '';
999
+ label.style.width = compact ? '0' : '';
1000
+ label.style.maxWidth = compact ? '0' : '';
1001
+ label.style.opacity = compact ? '0' : '';
1002
+ label.style.marginLeft = compact ? '-5px' : '';
1003
+ }
1004
+ function setToolbarCompact(compact) {
1005
+ for (const button of Array.from(sceneToolOverlay.querySelectorAll('button'))) {
1006
+ setToolbarButtonCompact(button, compact);
1007
+ }
1008
+ }
1009
+ function closeToolbarOverflowMenu() {
1010
+ toolbarOverflowOpen = false;
1011
+ toolbarOverflowMenu.style.display = 'none';
1012
+ LocalEditorShared.applyButtonActiveState(toolbarOverflowButton, false);
1013
+ }
1014
+ function openToolbarOverflowMenu() {
1015
+ if (toolbarOverflowMenu.childElementCount === 0)
1016
+ return;
1017
+ closeLocalTestMenu();
1018
+ closeSnapSettingsPopover();
1019
+ closePlacementSettingsPopover();
1020
+ closeTransformActionPopover();
1021
+ const win = doc.defaultView;
1022
+ const rect = toolbarOverflowButton.getBoundingClientRect();
1023
+ const viewportWidth = win?.innerWidth ?? doc.documentElement.clientWidth;
1024
+ toolbarOverflowMenu.style.top = `${Math.max(8, rect.bottom + 5)}px`;
1025
+ toolbarOverflowMenu.style.right = `${Math.max(8, viewportWidth - rect.right)}px`;
1026
+ toolbarOverflowMenu.style.display = 'flex';
1027
+ toolbarOverflowOpen = true;
1028
+ LocalEditorShared.applyButtonActiveState(toolbarOverflowButton, true);
1029
+ }
1030
+ function resetToolbarOverflowLayout() {
1031
+ setToolbarCompact(false);
1032
+ for (const item of toolbarOverflowItems)
1033
+ restoreToolbarOverflowItemStyle(item);
1034
+ for (const item of toolbarOrder) {
1035
+ item.dataset.editorToolbarOverflowed = 'false';
1036
+ sceneToolOverlay.appendChild(item);
1037
+ }
1038
+ LocalEditorShared.clearElement(toolbarOverflowMenu);
1039
+ toolbarOverflowButton.style.display = 'none';
1040
+ sceneToolOverlay.style.overflowX = 'hidden';
1041
+ closeToolbarOverflowMenu();
1042
+ }
1043
+ function layoutToolbarOverflow() {
1044
+ toolbarOverflowRaf = null;
1045
+ resetToolbarOverflowLayout();
1046
+ if (sceneToolOverlay.style.display === 'none')
1047
+ return;
1048
+ const availableWidth = sceneToolOverlay.clientWidth;
1049
+ if (availableWidth <= 0)
1050
+ return;
1051
+ const isOverflowing = () => sceneToolOverlay.scrollWidth > availableWidth + 1;
1052
+ if (!isOverflowing())
1053
+ return;
1054
+ toolbarOverflowButton.style.display = 'inline-flex';
1055
+ for (const item of toolbarOverflowItems) {
1056
+ if (!isOverflowing())
1057
+ break;
1058
+ if (item.element.style.display === 'none')
1059
+ continue;
1060
+ item.element.dataset.editorToolbarOverflowed = 'true';
1061
+ applyToolbarOverflowMenuStyle(item);
1062
+ toolbarOverflowMenu.appendChild(item.element);
1063
+ }
1064
+ if (toolbarOverflowMenu.childElementCount === 0) {
1065
+ toolbarOverflowButton.style.display = 'none';
1066
+ }
1067
+ if (isOverflowing()) {
1068
+ setToolbarCompact(true);
1069
+ }
1070
+ sceneToolOverlay.style.overflowX = isOverflowing() ? 'auto' : 'hidden';
1071
+ }
1072
+ function scheduleToolbarOverflowLayout() {
1073
+ const win = doc.defaultView;
1074
+ if (!win) {
1075
+ layoutToolbarOverflow();
1076
+ return;
1077
+ }
1078
+ if (toolbarOverflowRaf != null)
1079
+ win.cancelAnimationFrame(toolbarOverflowRaf);
1080
+ toolbarOverflowRaf = win.requestAnimationFrame(layoutToolbarOverflow);
1081
+ }
1082
+ const onToolbarOverflowButtonClick = (event) => {
1083
+ event.stopPropagation();
1084
+ if (toolbarOverflowOpen)
1085
+ closeToolbarOverflowMenu();
1086
+ else
1087
+ openToolbarOverflowMenu();
1088
+ };
1089
+ const onToolbarOverflowPointerDown = (event) => {
1090
+ const target = event.target instanceof HTMLElement ? event.target : null;
1091
+ if (!target)
1092
+ return;
1093
+ if (target.closest('[data-editor-local-test-menu]') || target.closest('[data-editor-local-test-toggle]'))
1094
+ return;
1095
+ if (target.closest('[data-editor-toolbar-overflow-menu]') || target.closest('[data-editor-toolbar-overflow-toggle]'))
1096
+ return;
1097
+ if (target.closest('[data-transform-snap-settings-popover]') || target.closest('[data-transform-snap-toggle]'))
1098
+ return;
1099
+ if (target.closest('[data-placement-settings-popover]') || target.closest('[data-placement-mode-toggle]'))
1100
+ return;
1101
+ if (target.closest('[data-transform-action-popover]') || target.closest('[data-transform-action-toggle]'))
1102
+ return;
1103
+ closeLocalTestMenu();
1104
+ closeToolbarOverflowMenu();
1105
+ closeSnapSettingsPopover();
1106
+ closePlacementSettingsPopover();
1107
+ closeTransformActionPopover();
1108
+ };
1109
+ const ResizeObserverCtor = doc.defaultView?.ResizeObserver;
1110
+ const toolbarResizeObserver = ResizeObserverCtor
1111
+ ? new ResizeObserverCtor(() => scheduleToolbarOverflowLayout())
1112
+ : null;
1113
+ toolbarOverflowButton.addEventListener('click', onToolbarOverflowButtonClick);
1114
+ localTestButton.addEventListener('click', onLocalTestButtonClick);
1115
+ doc.addEventListener('pointerdown', onToolbarOverflowPointerDown);
1116
+ toolbarResizeObserver?.observe(sceneToolOverlay);
1117
+ doc.defaultView?.addEventListener('resize', scheduleToolbarOverflowLayout);
462
1118
  applyThemeToSurfaces();
463
1119
  updateThemeToggleButton();
464
1120
  enterEditorButton.addEventListener('click', () => callbacks.onEnterEditor?.());
465
- saveButton.addEventListener('click', () => callbacks.onSaveScene?.());
466
- saveAndRunButton.addEventListener('click', () => callbacks.onSaveAndRunGame?.());
467
- discardRunButton.addEventListener('click', () => callbacks.onDiscardAndRunGame?.());
1121
+ saveButton.addEventListener('click', () => {
1122
+ closeLocalTestMenu();
1123
+ if (localTestActionsEnabled)
1124
+ callbacks.onSaveScene?.();
1125
+ });
1126
+ saveAndRunButton.addEventListener('click', () => {
1127
+ closeLocalTestMenu();
1128
+ if (localTestActionsEnabled)
1129
+ callbacks.onSaveAndRunGame?.();
1130
+ });
1131
+ discardRunButton.addEventListener('click', () => {
1132
+ closeLocalTestMenu();
1133
+ if (localTestActionsEnabled)
1134
+ callbacks.onDiscardAndRunGame?.();
1135
+ });
468
1136
  undoButton.addEventListener('click', () => callbacks.onUndo?.());
469
1137
  redoButton.addEventListener('click', () => callbacks.onRedo?.());
1138
+ gridToggleButton.addEventListener('click', () => {
1139
+ const visible = currentState?.grid?.visible ?? true;
1140
+ callbacks.onGridVisibleChange?.(!visible);
1141
+ });
470
1142
  sceneCameraButton.addEventListener('click', () => {
471
1143
  const enabled = currentState?.sceneCameraPreview?.enabled ?? false;
472
1144
  callbacks.onSceneCameraPreviewToggle?.(!enabled);
@@ -503,12 +1175,18 @@ export function createLocalEditorBrowserUi(options = {}) {
503
1175
  if (space)
504
1176
  callbacks.onTransformSpaceChange?.(space);
505
1177
  });
506
- snapButton.addEventListener('click', () => {
507
- const enabled = currentState?.transformOperations?.settings.snap.enabled
508
- ?? DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS.snap.enabled;
509
- callbacks.onTransformSnapEnabledChange?.(!enabled);
1178
+ snapButton.addEventListener('click', (event) => {
1179
+ event.stopPropagation();
1180
+ toggleSnapSettingsPopover();
510
1181
  });
511
- snapGroup.addEventListener('change', (event) => {
1182
+ snapSettingsPopover.addEventListener('change', (event) => {
1183
+ const enabledInput = event.target instanceof HTMLInputElement
1184
+ ? event.target.closest('[data-transform-snap-enabled]')
1185
+ : null;
1186
+ if (enabledInput) {
1187
+ callbacks.onTransformSnapEnabledChange?.(enabledInput.checked);
1188
+ return;
1189
+ }
512
1190
  const input = event.target instanceof HTMLInputElement ? event.target : null;
513
1191
  const kind = input?.dataset.transformSnapStep;
514
1192
  if (!input || !kind)
@@ -522,18 +1200,31 @@ export function createLocalEditorBrowserUi(options = {}) {
522
1200
  callbacks.onTransformSnapStepChange?.({ kind, value });
523
1201
  });
524
1202
  placementGroup.addEventListener('click', (event) => {
1203
+ event.stopPropagation();
1204
+ togglePlacementSettingsPopover();
1205
+ });
1206
+ placementSettingsPopover.addEventListener('click', (event) => {
525
1207
  const target = event.target instanceof HTMLElement ? event.target : null;
526
1208
  const mode = target?.closest('[data-placement-mode]')?.dataset.placementMode;
527
- if (mode)
528
- callbacks.onPlacementModeChange?.(mode);
1209
+ if (!mode)
1210
+ return;
1211
+ callbacks.onPlacementModeChange?.(mode);
1212
+ closePlacementSettingsPopover();
529
1213
  });
530
- transformActionSelect.addEventListener('change', () => {
531
- selectedTransformAction = transformActionSelect.value;
532
- if (currentState)
533
- render(currentState);
1214
+ actionGroup.addEventListener('click', (event) => {
1215
+ event.stopPropagation();
1216
+ toggleTransformActionPopover();
534
1217
  });
535
- transformActionButton.addEventListener('click', () => {
536
- callbacks.onTransformAction?.(selectedTransformAction);
1218
+ transformActionPopover.addEventListener('click', (event) => {
1219
+ const target = event.target instanceof HTMLElement ? event.target : null;
1220
+ const action = target?.closest('[data-transform-action]')?.dataset.transformAction;
1221
+ if (!action)
1222
+ return;
1223
+ const transformOperations = currentState?.transformOperations;
1224
+ if (!transformOperations || !isTransformActionEnabled(action, transformOperations))
1225
+ return;
1226
+ callbacks.onTransformAction?.(action);
1227
+ closeTransformActionPopover();
537
1228
  });
538
1229
  assetPanel.addEventListener('click', (event) => {
539
1230
  const target = event.target instanceof HTMLElement ? event.target : null;
@@ -621,10 +1312,25 @@ export function createLocalEditorBrowserUi(options = {}) {
621
1312
  if (currentState?.mode !== 'editor' || currentState.busy)
622
1313
  return;
623
1314
  const key = event.key.toLowerCase();
1315
+ if (key === 'escape' && snapSettingsOpen) {
1316
+ event.preventDefault();
1317
+ closeSnapSettingsPopover();
1318
+ return;
1319
+ }
1320
+ if (key === 'escape' && placementSettingsOpen) {
1321
+ event.preventDefault();
1322
+ closePlacementSettingsPopover();
1323
+ return;
1324
+ }
1325
+ if (key === 'escape' && transformActionOpen) {
1326
+ event.preventDefault();
1327
+ closeTransformActionPopover();
1328
+ return;
1329
+ }
624
1330
  const primaryModifier = event.metaKey || event.ctrlKey;
625
1331
  const handleDocumentShortcut = inputRouter.shouldHandleDocumentShortcut(event);
626
1332
  const handleGlobalShortcut = inputRouter.shouldHandleGlobalShortcut(event);
627
- if (handleDocumentShortcut && primaryModifier && key === 's') {
1333
+ if (handleDocumentShortcut && localTestActionsEnabled && primaryModifier && key === 's') {
628
1334
  event.preventDefault();
629
1335
  callbacks.onSaveScene?.();
630
1336
  return;
@@ -642,8 +1348,7 @@ export function createLocalEditorBrowserUi(options = {}) {
642
1348
  callbacks.onRedo?.();
643
1349
  return;
644
1350
  }
645
- if (handleGlobalShortcut && (key === 'delete' || key === 'backspace')) {
646
- hierarchyController.handleDeleteShortcut(event);
1351
+ if (handleGlobalShortcut && hierarchyController.handleEditShortcut(event)) {
647
1352
  return;
648
1353
  }
649
1354
  if (!handleGlobalShortcut || event.metaKey || event.ctrlKey || event.altKey)
@@ -667,7 +1372,10 @@ export function createLocalEditorBrowserUi(options = {}) {
667
1372
  }
668
1373
  if (key === 'escape') {
669
1374
  event.preventDefault();
670
- callbacks.onCancelActiveOperation?.();
1375
+ if (callbacks.onCancelEditorIntent)
1376
+ callbacks.onCancelEditorIntent();
1377
+ else
1378
+ callbacks.onCancelActiveOperation?.();
671
1379
  }
672
1380
  };
673
1381
  doc.addEventListener('keydown', onKeyDown);
@@ -720,33 +1428,6 @@ export function createLocalEditorBrowserUi(options = {}) {
720
1428
  ? CSS.escape(value)
721
1429
  : value.replace(/["\\]/g, '\\$&');
722
1430
  }
723
- function renderTransformHandleBadges(transformTool) {
724
- LocalEditorShared.clearElement(handleGroup);
725
- const tool = transformTool?.activeTool ?? 'select';
726
- const descriptor = DEFAULT_EDITOR_TRANSFORM_TOOL_DESCRIPTORS[tool];
727
- handleGroup.style.display = descriptor.handles.length > 0 ? 'flex' : 'none';
728
- for (const handle of descriptor.handles) {
729
- const badge = doc.createElement('span');
730
- badge.dataset.transformHandle = handle.constraint;
731
- badge.title = handle.description;
732
- badge.textContent = handle.label;
733
- badge.style.cssText = [
734
- 'height:22px',
735
- 'display:inline-flex',
736
- 'align-items:center',
737
- 'padding:0 7px',
738
- 'border:1px solid var(--fps-editor-border)',
739
- 'border-radius:3px',
740
- 'background:var(--fps-editor-field)',
741
- 'color:var(--fps-editor-muted-strong)',
742
- 'font-size:11px',
743
- 'font-weight:800',
744
- 'white-space:nowrap',
745
- 'pointer-events:none',
746
- ].join(';');
747
- handleGroup.appendChild(badge);
748
- }
749
- }
750
1431
  function formatSnapStepInputValue(snap, kind) {
751
1432
  const value = kind === 'move'
752
1433
  ? snap.moveStep
@@ -767,25 +1448,40 @@ export function createLocalEditorBrowserUi(options = {}) {
767
1448
  const disabled = state.busy;
768
1449
  if (!inEditor || disabled)
769
1450
  contextMenu.close();
770
- hostChrome.style.display = inEditor ? 'none' : 'flex';
1451
+ hostChrome.style.display = !inEditor && localTestActionsEnabled ? 'flex' : 'none';
771
1452
  enterEditorButton.disabled = disabled;
772
- for (const button of [saveButton, saveAndRunButton, discardRunButton, undoButton, redoButton, sceneHelpButton, sceneCameraButton]) {
773
- button.style.display = '';
1453
+ for (const button of [saveButton, saveAndRunButton, discardRunButton, undoButton, redoButton, sceneHelpButton, sceneCameraButton, gridToggleButton]) {
1454
+ button.style.display = 'inline-flex';
774
1455
  button.disabled = disabled;
775
1456
  }
1457
+ localTestGroup.style.display = inEditor && localTestActionsEnabled ? 'flex' : 'none';
1458
+ localTestButton.disabled = disabled;
1459
+ if (!inEditor || !localTestActionsEnabled)
1460
+ closeLocalTestMenu();
776
1461
  if (!inEditor)
777
1462
  helpOpen = false;
778
1463
  inputRouter.setModalOpen(inEditor && helpOpen);
779
1464
  const transformTool = state.transformTool ?? null;
780
1465
  workbench.root.style.display = inEditor ? '' : 'none';
781
1466
  sceneToolOverlay.style.display = inEditor ? 'flex' : 'none';
1467
+ if (!inEditor)
1468
+ closeSnapSettingsPopover();
1469
+ if (!inEditor)
1470
+ closePlacementSettingsPopover();
1471
+ if (!inEditor)
1472
+ closeTransformActionPopover();
782
1473
  const sceneCameraPreview = state.sceneCameraPreview ?? { enabled: false, available: false };
783
1474
  cameraPreviewGroup.style.display = inEditor ? 'flex' : 'none';
784
1475
  sceneCameraButton.disabled = disabled || !sceneCameraPreview.available;
785
- sceneCameraButton.title = sceneCameraPreview.available
1476
+ const sceneCameraTooltip = sceneCameraPreview.available
786
1477
  ? '从 Main Camera 查看当前场景'
787
1478
  : '当前场景没有可预览的 Main Camera';
1479
+ setToolbarButtonTooltip(sceneCameraButton, sceneCameraTooltip);
788
1480
  LocalEditorShared.applyButtonActiveState(sceneCameraButton, sceneCameraPreview.enabled);
1481
+ const gridState = state.grid ?? { visible: false, available: false };
1482
+ gridToggleButton.disabled = disabled || !gridState.available;
1483
+ setToolbarButtonTooltip(gridToggleButton, gridState.visible ? '隐藏 Scene View 网格' : '显示 Scene View 网格');
1484
+ LocalEditorShared.applyButtonActiveState(gridToggleButton, gridState.visible);
789
1485
  for (const [tool, button] of toolButtons) {
790
1486
  button.disabled = disabled;
791
1487
  LocalEditorShared.applyButtonActiveState(button, transformTool?.activeTool === tool);
@@ -795,7 +1491,6 @@ export function createLocalEditorBrowserUi(options = {}) {
795
1491
  LocalEditorShared.applyButtonActiveState(button, transformTool?.activeSpace === space);
796
1492
  }
797
1493
  sceneHelpButton.disabled = disabled;
798
- renderTransformHandleBadges(transformTool);
799
1494
  const transformOperations = state.transformOperations ?? {
800
1495
  settings: DEFAULT_EDITOR_TRANSFORM_OPERATION_SETTINGS,
801
1496
  selectedCount: state.selectedIds.length,
@@ -806,53 +1501,73 @@ export function createLocalEditorBrowserUi(options = {}) {
806
1501
  const operationSettings = transformOperations.settings;
807
1502
  snapGroup.style.display = inEditor ? 'flex' : 'none';
808
1503
  snapButton.disabled = disabled;
809
- LocalEditorShared.applyButtonActiveState(snapButton, operationSettings.snap.enabled);
1504
+ snapEnabledInput.disabled = disabled;
1505
+ snapEnabledInput.checked = operationSettings.snap.enabled;
1506
+ LocalEditorShared.applyButtonActiveState(snapButton, operationSettings.snap.enabled || snapSettingsOpen);
1507
+ setToolbarButtonTooltip(snapButton, `吸附设置 · 位移 ${formatSnapStepInputValue(operationSettings.snap, 'move')} · 旋转 ${formatSnapStepInputValue(operationSettings.snap, 'rotate')}° · 缩放 ${formatSnapStepInputValue(operationSettings.snap, 'scale')}`);
810
1508
  for (const [kind, input] of snapStepInputs) {
811
1509
  input.disabled = disabled;
812
1510
  if (doc.activeElement !== input)
813
1511
  input.value = formatSnapStepInputValue(operationSettings.snap, kind);
814
1512
  }
1513
+ if (!inEditor || disabled || snapGroup.style.display === 'none')
1514
+ closeSnapSettingsPopover();
815
1515
  placementGroup.style.display = inEditor ? 'flex' : 'none';
1516
+ placementButton.disabled = disabled;
1517
+ setToolbarButtonIcon(doc, placementButton, PLACEMENT_MODE_ICONS[operationSettings.placementMode]);
1518
+ setToolbarButtonTooltip(placementButton, `放置模式 · ${PLACEMENT_MODE_DESCRIPTIONS[operationSettings.placementMode]}`);
1519
+ LocalEditorShared.applyButtonActiveState(placementButton, operationSettings.placementMode !== 'off' || placementSettingsOpen);
816
1520
  for (const [mode, button] of Object.entries(placementButtons)) {
817
1521
  button.disabled = disabled;
818
1522
  LocalEditorShared.applyButtonActiveState(button, operationSettings.placementMode === mode);
819
1523
  }
1524
+ if (!inEditor || disabled || placementGroup.style.display === 'none')
1525
+ closePlacementSettingsPopover();
820
1526
  actionGroup.style.display = inEditor ? 'flex' : 'none';
821
- transformActionSelect.disabled = disabled;
822
- transformActionButton.disabled = disabled || !isTransformActionEnabled(selectedTransformAction, transformOperations);
823
- transformActionButton.title = selectedTransformAction.startsWith('align-')
824
- ? '需要至少 2 个选中对象,并以 active object 为目标'
825
- : '需要至少 3 个选中对象';
1527
+ const hasTransformAction = transformOperations.canAlign || transformOperations.canDistribute;
1528
+ transformActionButton.disabled = disabled || !hasTransformAction;
1529
+ setToolbarButtonTooltip(transformActionButton, hasTransformAction ? '对齐与分布' : '选择至少 2 个对象后可用');
1530
+ LocalEditorShared.applyButtonActiveState(transformActionButton, transformActionOpen);
1531
+ for (const [action, button] of transformActionButtons) {
1532
+ const enabled = isTransformActionEnabled(action, transformOperations);
1533
+ button.disabled = disabled || !enabled;
1534
+ button.style.opacity = button.disabled ? '0.45' : '1';
1535
+ button.style.cursor = button.disabled ? 'not-allowed' : 'pointer';
1536
+ }
1537
+ if (!inEditor || disabled || !hasTransformAction || actionGroup.style.display === 'none')
1538
+ closeTransformActionPopover();
826
1539
  undoButton.disabled = disabled || !state.session?.canUndo;
827
1540
  redoButton.disabled = disabled || !state.session?.canRedo;
828
1541
  LocalEditorShared.applyButtonActiveState(sceneHelpButton, inEditor && helpOpen);
829
1542
  shortcutHelpPanel.style.display = inEditor && helpOpen ? '' : 'none';
830
1543
  dirtyBadge.style.display = inEditor && state.session?.dirty ? 'inline-flex' : 'none';
831
- const dragSuffix = transformTool?.dragPhase === 'dragging'
832
- ? ` | dragging ${transformTool.draggingNodeId ?? 'selection'}`
833
- : '';
834
- status.textContent = [
1544
+ const editorStatusText = [
835
1545
  state.status,
836
- state.session?.dirty ? '*' : '',
1546
+ state.session?.dirty ? '有未保存更改' : '',
837
1547
  LocalEditorShared.formatAuthoringSourceLabel(state.session?.source),
838
1548
  state.summary,
839
1549
  ].filter(Boolean).join(' | ');
840
- status.textContent += dragSuffix;
841
- status.title = state.statusDetails || status.textContent;
842
- status.style.color = state.statusTone === 'error'
1550
+ const transformStatusText = transformTool?.dragPhase === 'dragging'
1551
+ ? `正在拖拽 ${transformTool.draggingNodeId ?? '选择对象'}`
1552
+ : LocalEditorShared.toTransformToolStatusLabel(transformTool?.activeTool ?? 'select', transformTool?.activeSpace ?? 'world');
1553
+ const statusToneColor = state.statusTone === 'error'
843
1554
  ? 'var(--fps-editor-danger-strong)'
844
1555
  : state.statusTone === 'warning'
845
1556
  ? 'var(--fps-editor-warn)'
846
1557
  : state.statusTone === 'success'
847
1558
  ? 'var(--fps-editor-success)'
848
1559
  : 'var(--fps-editor-muted)';
849
- sceneToolStatus.textContent = transformTool?.dragPhase === 'dragging'
850
- ? `正在拖拽 ${transformTool.draggingNodeId ?? '选择对象'}`
851
- : LocalEditorShared.toTransformToolStatusLabel(transformTool?.activeTool ?? 'select', transformTool?.activeSpace ?? 'world');
852
- sceneMouseHint.textContent = [
853
- LocalEditorShared.toTransformMouseHint(transformTool?.activeTool ?? 'select'),
1560
+ setToolbarButtonTooltip(editorStatusButton, [
1561
+ `编辑器状态:${editorStatusText}`,
1562
+ state.statusDetails && state.statusDetails !== state.status ? state.statusDetails : '',
1563
+ transformStatusText,
854
1564
  LocalEditorShared.toTransformOperationStatusLabel(operationSettings),
855
- ].join(' · ');
1565
+ ].filter(Boolean).join(' · '));
1566
+ editorStatusButton.style.color = statusToneColor;
1567
+ editorStatusButton.style.borderColor = state.statusTone == null || state.statusTone === 'default'
1568
+ ? 'var(--fps-editor-border)'
1569
+ : statusToneColor;
1570
+ renderCoordinateAxesOverlay(coordinateAxesOverlay, inEditor ? state.coordinateAxes ?? null : null);
856
1571
  const boxSelection = state.boxSelection;
857
1572
  if (inEditor && boxSelection?.active) {
858
1573
  boxSelectionOverlay.style.display = '';
@@ -864,6 +1579,7 @@ export function createLocalEditorBrowserUi(options = {}) {
864
1579
  else {
865
1580
  boxSelectionOverlay.style.display = 'none';
866
1581
  }
1582
+ scheduleToolbarOverflowLayout();
867
1583
  if (!inEditor)
868
1584
  return;
869
1585
  const hierarchyDescriptor = panelRegistry.getActivePanel('left');
@@ -886,11 +1602,27 @@ export function createLocalEditorBrowserUi(options = {}) {
886
1602
  return activeTheme;
887
1603
  },
888
1604
  dispose() {
1605
+ if (toolbarOverflowRaf != null) {
1606
+ doc.defaultView?.cancelAnimationFrame(toolbarOverflowRaf);
1607
+ toolbarOverflowRaf = null;
1608
+ }
1609
+ toolbarResizeObserver?.disconnect();
1610
+ toolbarOverflowButton.removeEventListener('click', onToolbarOverflowButtonClick);
1611
+ localTestButton.removeEventListener('click', onLocalTestButtonClick);
1612
+ doc.removeEventListener('pointerdown', onToolbarOverflowPointerDown);
1613
+ doc.defaultView?.removeEventListener('resize', scheduleToolbarOverflowLayout);
889
1614
  hostChrome.remove();
890
1615
  workbench.root.remove();
1616
+ localTestMenu.remove();
1617
+ toolbarOverflowMenu.remove();
1618
+ snapSettingsPopover.remove();
1619
+ placementSettingsPopover.remove();
1620
+ transformActionPopover.remove();
891
1621
  boxSelectionOverlay.remove();
892
1622
  shortcutHelpPanel.remove();
893
1623
  hierarchyController.dispose();
1624
+ workbenchLayoutController.dispose();
1625
+ tooltipController.dispose();
894
1626
  contextMenu.dispose();
895
1627
  inputRouter.dispose();
896
1628
  doc.removeEventListener('keydown', onKeyDown);