Skip to content

Experimental option to open some editors (Event Sheets, Extensions...) into separate windows#8492

Merged
4ian merged 109 commits intomasterfrom
experimental-build/popped-editors
Apr 13, 2026
Merged

Experimental option to open some editors (Event Sheets, Extensions...) into separate windows#8492
4ian merged 109 commits intomasterfrom
experimental-build/popped-editors

Conversation

@4ian
Copy link
Copy Markdown
Owner

@4ian 4ian commented Apr 7, 2026

No description provided.

claude and others added 30 commits March 16, 2026 16:32
Add the ability to pop out any closable editor tab (including Events
Sheet) into a separate browser/Electron window via right-click context
menu. Uses React portals to render the editor content in the new window
while keeping all app logic and state in the main React tree.

- New WindowPortal component that opens a browser window, copies styles
  (including MUI injected styles via MutationObserver), and renders
  children through ReactDOM.createPortal
- Added "Pop out in a separate window" context menu option to ClosableTabs
- Closing the external window closes the editor tab and vice versa
- Full theme support in the popped-out window via FullThemeProvider

https://claude.ai/code/session_01BNeruZtX6hSfXJ2pqjkGAR
Add a PortalContainerContext that provides the DOM container element
where Material-UI portals should render. In the main window this is
undefined (default MUI behavior), but in a popped-out window it points
to the external window's container div.

Two-layer approach for maximum coverage:
1. Global: FullThemeProvider injects portal container into MUI theme
   default props (MuiModal, MuiPopover, MuiMenu, MuiDrawer, MuiTooltip)
   so ALL MUI overlay components automatically render in the correct
   window.
2. Explicit: Key components also read PortalContainerContext directly
   and pass `container` props as a safety net:
   - Dialog.js, LoaderModal.js (MUI Dialog/Modal)
   - ContextMenu.js (MUI Menu + Drawer)
   - InlinePopover.js (MUI Popper)
   - InlineParameterEditor.js (MUI Drawer)
   - InstructionEditorMenu.js (MUI Popover)
   - ExpressionAutocompletionsDisplayer.js (MUI Popper)
   - MaterialUIMenuImplementation.js (submenu MUI Menu)
   - ColorPicker.js (MUI Popover)

https://claude.ai/code/session_01BNeruZtX6hSfXJ2pqjkGAR
- WindowPortal: Provide external window's document.body (instead of
  container div) as PortalContainerContext value. This fixes:
  - ContextMenu not appearing (MUI needs body for anchorPosition positioning)
  - InlinePopover not closing on click-away (ClickAwayListener needs
    correct ownerDocument to detect clicks in the external window)

- EditorTabsPane: Render a full Toolbar inside each popped-out WindowPortal
  with its own ref. Route the editor's setToolbar callback to the
  popped-out Toolbar when isPoppedOut is true, so toolbar buttons properly
  interact with the editor in the separate window.

https://claude.ai/code/session_01BNeruZtX6hSfXJ2pqjkGAR
The inline ref callback on the popped-out Toolbar was calling
updateToolbar() on every render, which set state in the Toolbar,
triggering a re-render, creating a new callback identity, causing
React to re-invoke the ref — producing an infinite loop.

Track which popped-out toolbars have been initialized so
updateToolbar is only called once per pop-out lifecycle.

https://claude.ai/code/session_01BNeruZtX6hSfXJ2pqjkGAR
The inline ref callback was clearing the initialized-tracking set on
null (React's cleanup between re-renders), which reset the guard and
allowed updateToolbar() to fire again on the next render — sustaining
the infinite loop.

Now: ignore null entirely in the ref callback. Cleanup of both the
ref and the tracking set is handled by onPopInTab (called on close
and pop-back-in).

https://claude.ai/code/session_01BNeruZtX6hSfXJ2pqjkGAR
MUI v4 uses JSS to inject <style> elements into the main window's
document.head. When components render in a separate window via
WindowPortal, their styles were injected into the wrong document.

The MutationObserver approach (copying styles from main to child)
had race conditions and missed updates to existing style elements.

Fix: create a dedicated JSS instance targeting the child window's
document when FullThemeProvider detects a portal container from a
different window. This makes JSS inject styles directly into the
correct document.

Also disconnect the MutationObserver on cleanup to prevent leaks.

https://claude.ai/code/session_01BNeruZtX6hSfXJ2pqjkGAR
Three fixes for the separate-window (WindowPortal) support:

1. ElementWithMenu: `instanceof HTMLElement` fails across windows
   because each window has its own constructor. Use `nodeType === 1`
   instead, which is the DOM-standard way to check for Element nodes.

2. SemiControlledAutoComplete: MUI Autocomplete's internal Popper
   renders to the main document.body by default. Pass a custom
   PopperComponent with the portal container so the dropdown renders
   in the correct window.

3. InlinePopover: `instanceof MouseEvent` also fails across windows.
   Use `event.type` string check instead.

https://claude.ai/code/session_01BNeruZtX6hSfXJ2pqjkGAR
MUI's Popover uses ownerWindow(anchorEl) to get viewport dimensions
for bounds-checking. With anchorReference="anchorPosition" and no
anchorEl, it falls back to the main window, causing menus to use
the wrong innerWidth/innerHeight for repositioning.

Fix: pass portalContainer as anchorEl when in a separate window.
Positioning is still driven by anchorPosition (not anchorEl), but
MUI now derives the correct window for viewport calculations.

https://claude.ai/code/session_01BNeruZtX6hSfXJ2pqjkGAR
- Add originalPaneIdentifier field to EditorTab to track where a tab
  came from when popped out
- Add 'external' pane to EditorTabsState initial state
- Add popOutTab/popInTab functions in EditorTabsHandler that move tabs
  between their source pane and the external pane
- Add getExternalEditors helper to retrieve popped-out tabs
- Handle openEditorTab finding a tab in the external pane by popping
  it back in first
- Create ExternalEditorWindow component that renders one popped-out
  editor in a WindowPortal with its own Toolbar, FullThemeProvider,
  and ResizeObserver-based SpecificDimensionsWindowSizeProvider
- Create ExternalEditorWindows container that maps external tabs to
  ExternalEditorWindow instances
- Remove all pop-out state/logic from EditorTabsPane (~100 lines):
  poppedOutTabKeys, poppedOutToolbarRefs, initializedPoppedOutToolbars,
  isPoppedOut checks, WindowPortal rendering block
- Wire up onPopOutTab/onPopInTab/onExternalWindowClose in MainFrame
- Render ExternalEditorWindows alongside PanesContainer

https://claude.ai/code/session_01G1DsG5SdZKcoix6z4MNJ5j
…te import

- Add React.Node return type annotations to ExternalEditorWindow and
  ExternalEditorWindows components
- Pass editorTabs through to ExternalEditorWindow via sharedProps spread
- Restructure popOutTab/popInTab to build panes in two steps, avoiding
  Flow's invalid-computed-prop error with explicit + computed keys
- Remove duplicate EditorTab type import in MainFrame/index.js

https://claude.ai/code/session_01G1DsG5SdZKcoix6z4MNJ5j
The ResizeObserver was set up in useEffect with [] deps, but the
container div lives inside a WindowPortal which creates its window
asynchronously. On first render containerRef.current was null, the
effect never re-ran, dimensions stayed null, and
SpecificDimensionsWindowSizeProvider rendered nothing.

Switch to a callback ref so the ResizeObserver attaches as soon as
the DOM node actually mounts inside the portal window.

https://claude.ai/code/session_01G1DsG5SdZKcoix6z4MNJ5j
… toolbar

- Add popOutEnabled prop to ClosableTab/DraggableClosableTab to gray out
  the "Pop out in a separate window" context menu item for scene editor
  tabs (layout, external layout, custom object kinds)
- Add customLeftContent prop to MainFrame Toolbar, rendered on the left
  when showProjectButtons is false
- In ExternalEditorWindow, set showProjectButtons to false and add a
  "Pop back into main window" IconButton using the Restore icon

https://claude.ai/code/session_01G1DsG5SdZKcoix6z4MNJ5j
- In Electron main.js, allow blank-URL pop-outs in setWindowOpenHandler
  instead of denying all new windows. Child windows get standard title
  bar, nodeIntegration, and contextIsolation disabled.
- Add did-create-window listener to enable @electron/remote on child
  windows, block navigation, and prevent further window nesting.
- Make Window.setUpContextMenu accept an optional targetWindow param
  so it can set up context menus in pop-out windows (both Electron
  and web), not just the main window.
- Call Window.setUpContextMenu(externalWindow) in WindowPortal after
  creating the external window.

https://claude.ai/code/session_01G1DsG5SdZKcoix6z4MNJ5j
remote.getCurrentWindow() always returns the main window, even when
the right-click happens in a pop-out. Use BrowserWindow.getFocusedWindow()
(which is the window the user just clicked in) with a fallback to
getCurrentWindow().

Applied in both ElectronMenuImplementation.showMenu and
Window.setUpContextMenu.

https://claude.ai/code/session_01G1DsG5SdZKcoix6z4MNJ5j
Root cause: MUI's ClickAwayListener uses ReactDOM.findDOMNode on its
child to determine which document to attach listeners to. Because the
child is a Popper (which portals its content), findDOMNode returns null
and the listener falls back to the main window's `document`. Clicks in
the external window never bubble to the main document, so onClickAway
never fires.

Fix: Add a useEffect that, when the popover is in an external window
(portalContainer differs from document), attaches a mouseup listener
directly on the external window's document. Clicks outside the popper
content trigger onApply, matching the existing ClickAwayListener
behavior. A ref wrapper div on the Popper content allows contains()
checks.

https://claude.ai/code/session_01G1DsG5SdZKcoix6z4MNJ5j
…nal windows

External (popped-out) editor windows now have their own keyboard shortcut
listeners and command palette instances, so shortcuts like Ctrl+S and Ctrl+P
work correctly in external windows on both web and Electron.

Changes:
- useKeyboardShortcuts: accept targetDocument and ignoreHandledByElectron params
- isUserTyping/isDialogOpen: accept optional targetDocument param
- WindowPortal: add onWindowReady callback to expose the external window ref
- ExternalEditorWindow: wire up local shortcuts, command palette, and compute
  adaptive window dimensions based on originalPaneIdentifier

https://claude.ai/code/session_01Y7C4smFZcx6ZfJZq5QTzdG
- Rename getPopOutDimensions return keys to popOutWidth/popOutHeight
- Pass props.previewDebuggerServer instead of null so in-game editor
  shortcuts are handled in external windows too
- Rename WindowPortal width/height props to initialWidth/initialHeight
  to clarify they are only used on mount

https://claude.ai/code/session_01Y7C4smFZcx6ZfJZq5QTzdG
…ired

These props are always passed by ExternalEditorWindow, the only consumer.
Remove optional markers and default values, and simplify null guards.

https://claude.ai/code/session_01Y7C4smFZcx6ZfJZq5QTzdG
The isDialogOpen() check looks for a div.main-frame element in the
document to determine if focus is inside the main content area. External
windows didn't have this class, so when EventsSheet took focus (via
tabIndex and _ensureFocused), isDialogOpen() returned true and blocked
all shortcuts. Add className="main-frame" to the external window's
container div so the check works correctly.

https://claude.ai/code/session_01Y7C4smFZcx6ZfJZq5QTzdG
The isDialogOpen() check looks for a div.main-frame element to determine
if focus is inside the main content area. External windows didn't have
such an element, so when EventsSheet took focus (via tabIndex and
_ensureFocused), isDialogOpen() returned true and blocked all shortcuts.

Add className="popped-out-frame" to the external window's container and
update isDialogOpen() to also check for this class.

https://claude.ai/code/session_01Y7C4smFZcx6ZfJZq5QTzdG
Monaco editor uses CSSStyleSheet.insertRule() to inject CSS rules
dynamically rather than setting style element textContent. The
_copyStyles function in WindowPortal only copied textContent, so
Monaco's layout-critical CSS rules were never copied to the external
window, causing all dimension computations to fail (0 widths, no
content rendered).

Fix _copyStyles to serialize CSSOM rules from style elements when
textContent is empty. Also add a delayed editor.layout() call in
CodeEditor to handle any remaining timing issues between CSS copying
and Monaco's initial layout computation.

https://claude.ai/code/session_01Y7C4smFZcx6ZfJZq5QTzdG
claude and others added 29 commits April 4, 2026 17:14
beforeunload fires before the user answers the browser's "leave page?"
confirmation dialog (from CloseConfirmDialog). If the user cancels, the
popped-out windows were already closed.

Switch to the unload event which only fires after the user confirms the
close, so cancelling preserves the popped-out windows.

https://claude.ai/code/session_01FrBtMXstqs8qMyZppXUb7U
…th ESM and AMD

The old patch-package patch only modified the ESM clipboard module, so
the Electron paste fallback didn't work in popped-out windows which load
Monaco via the AMD loader. Move the fix to a runtime patch in MonacoSetup.js
(applyElectronClipboardPatch) that is called after editor creation in both
the main editor and PoppedOutMonacoEditor, keeping the fix in one place.

https://claude.ai/code/session_01BxFngmmzdFM16i1rY9Me3s
Override the clipboard paste action's run method directly so the Electron
fallback covers all paste triggers (keyboard shortcuts, context menu,
programmatic), not just Ctrl+V and Shift+Insert keybindings.

https://claude.ai/code/session_01BxFngmmzdFM16i1rY9Me3s
When popping out an editor tab, the main frame now selects the tab
just before the popped-out tab instead of always falling back to the
first tab (home).

https://claude.ai/code/session_01AKW6eYtaZub2D4oDWnzCrE
…ertMessage

Window.showConfirmDialog uses the native browser confirm dialog which
always appears in the main window, breaking the UX when called from a
PoppedOutEditorContainerWindow. Switch to useAlertDialog's
showConfirmation which renders an in-app React dialog that respects
the current window context.

https://claude.ai/code/session_01NW9VQGvvtg7mjv9hWAc5SY
…esourcesEditor

Use useAlertDialog hook in InstructionParametersEditor and AlertContext
with static contextType in ResourcesEditor (class component) to show
in-app confirmation dialogs instead of native browser dialogs that
always appear in the main window.

https://claude.ai/code/session_01NW9VQGvvtg7mjv9hWAc5SY
The "ResizeObserver loop completed with undelivered notifications" error
is a non-fatal browser warning (per W3C spec) that occurs when a
ResizeObserver callback triggers layout changes producing additional
observations that can't be delivered in the same frame. This happens
when resizing react-mosaic panels in a popped-out editor window because
WindowPortal's ResizeObserver calls setWindowSize(), triggering a React
re-render that causes further resize observations.

In development, react-error-overlay treats this as a fatal error and
shows a red overlay. The fix suppresses this specific error on both the
main window and the external popped-out window by calling
stopImmediatePropagation before react-error-overlay's handler runs.

https://claude.ai/code/session_016EQE821byiGiwsXZg2pcH3
Log a message explaining the silenced error is a benign W3C spec warning
(observations deferred to next frame). Throttle the log to at most once
per second to avoid console spam during continuous resizing.

https://claude.ai/code/session_016EQE821byiGiwsXZg2pcH3
…eObserverError

Extract the shared logic into a reusable function so both index.js
(main window) and WindowPortal.js (popped-out window) call the same
helper instead of duplicating the handler.

https://claude.ai/code/session_016EQE821byiGiwsXZg2pcH3
Move the silenceBenignResizeObserverError(window) call below all imports.
ES module hoisting guarantees the import is resolved before any code
runs, so the listener is still registered before react-error-overlay.

https://claude.ai/code/session_016EQE821byiGiwsXZg2pcH3
…k bundles

CRA's react-error-overlay is injected as a webpack entry point that runs
before application code, so the listener in index.js was too late to
call stopImmediatePropagation before the overlay handler. Moving the
listener to an inline <script> in index.html ensures it is registered
before any bundle code loads.

The Utils/SilenceBenignResizeObserverError.js helper is kept for
popped-out windows created by WindowPortal (which open as about:blank
and don't share the main window's inline scripts).

https://claude.ai/code/session_016EQE821byiGiwsXZg2pcH3
When an EventsSheet editor was in an external/popped-out window and the
user clicked its scene in the project manager, openEditorTab() would
detect the tab in the 'external' pane and call popInTab() to move it
back to the main window. Instead, treat it the same as any other
already-open tab: leave it where it is and just update the focus.

The explicit "Pop In" button still works as before since it calls
popInTab() directly.

https://claude.ai/code/session_011LRZD5VVzJwizPY8GNedm3
@4ian 4ian merged commit fd3103a into master Apr 13, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants