Editor Overview
A developer-oriented tour of the OpenGPEX v2 workspace — how the WebGPU editor is assembled, how panels negotiate space, and where every visible surface comes from.
Entry Chain
The editor mounts through a fully client-side chain (SSR is disabled because the component tree depends on Workers, Canvas, and IndexedDB):
src/app/page.tsx
└─ EditorClient
└─ dynamic(() => import("@opengpex/editor/workspace"), { ssr: false })
└─ <EditorProvider><WorkspaceController /></EditorProvider>
└─ Workspace.tsx (the real workspace assembly)
Path alias: @opengpex/editor/* → ./src/lib/opengpex/*.
Boot Lifecycle
useEditorStatus derives two global states: BOOTING (!state.isLoaded) and READY. Under the hood, EditorProvider.init() walks five stages:
| Stage | Behavior |
|---|---|
checking |
Detects existing State_V1 / State_V2 records |
migrating |
Runs v1→v2 migration + healing (installs a beforeunload guard) |
restoring |
storage.restore() races a RESTORE_TIMEOUT_MS (8s) timer |
finalizing |
Dispatches HYDRATE(savedState), or SET_LOADED for a fresh session (a timed-out/failed restore also raises the core.restoreTimedOut signal, blocking auto-save) |
| READY | fonts.hydrate() fires fire-and-forget; advanced command set is dynamically imported |
Boot progress is written to state.interaction.signals via actions.setStateSignal('core.bootStage', ...) — signals never enter persistence. While BOOTING, a FancyOverlay (z = POPOVER + 10) shows fixed copy plus a per-stage subtitle; it exits with a pure CSS animation and is unmounted on onAnimationEnd.
Screen Layout
The v2 workspace is a floating layout — sidebars overlay the canvas instead of squeezing it:
┌────────────────────────────────────────────────────────────────┐
│ ToolMenu (pinned 44px rail or floating, top-left) ExtendButton (top-right, XTEND_SLOT)
│ ┌──┐ ┌──────────────────────────────────┐ ┌──┐ │
│ │ L│ │ OptionBar (top pill, 48px) │ │ R│ │
│ │ e│ left │ │ right │ e│ │
│ │ f│ drawer│ Viewport │ drawer│ f│ │
│ │ t│ 320px │ CanvasBackdrop / CanvasStage │ 320px │ t│ │
│ │ │ │ ViewportHUD (4 corners) + DOCK │ │ │ │
│ └──┘ └──────────────────────────────────┘ └──┘ │
│ GlobalUI (HUD toasts, confirm/choice dialogs, portal root) │
Geometry constants live in workspace/Workspace.styles.ts:
| Constant | Value | Purpose |
|---|---|---|
TOOLBAR_WIDTH / DRAWER_BAR_WIDTH |
40px | Icon rails on both edges |
SIDEBAR_WIDTH |
320px | Floating drawer width (plugins may override via preferredWidth) |
HEADER_HEIGHT |
48px | OptionBar height |
TOOL_MENU_WIDTH |
44px | Pinned ToolMenu rail |
Layout Negotiation
Floating UI and the canvas reconcile through workspace/LayoutContext.tsx:
- Regions call
registerSlot({ id, role, width?, height? })with rolesLEFT_PUSH | RIGHT_PUSH | TOP_PUSH | BOTTOM_PUSH | OVERLAY. - Per-side maxima produce a
safeRect; aIDLE → MEASURING → STABLEstate machine marks stability after 300ms of silence. useLayoutSyncwrites the stablesafeRectback to Redux (theme.config.insets), which is injected as--v-offset-*CSS variables consumed by corner HUDs and overlays.
Plugin Slots
Nearly every visible region is a plugin slot. Plugins are self-registering modules (no prop assembly); the core registry is generated by pnpm sync-plugins. Key slots:
| Slot | Screen location | Examples |
|---|---|---|
SIDE_BAR |
Floating left/right drawers | Layers, Adjustment, Craft, ImageInfo, AnimatedImages, AITools |
OPTION_BAR |
Top pill, switches with active tool | ColorOptions, ClipOptions, ViewportOptions |
TOOL_MENU / TOOL_SETTINGS |
ToolMenu panel and submenu | SmartGuides, SettingsPanel, StorageInfoPanel |
XTEND_SLOT |
ExtendButton popover (top right) | CloudMenu |
DOCK |
Viewport-area overlay, frame-lifecycle independent | TabDock |
TL/TR/BL/BR |
Viewport corners | Zoom controls |
VIEWPORT_OVERLAY |
Above the canvas transform | SmartGuides |
STAGE_OVERLAY |
Tool interaction overlays | Brush, Eraser, Text, Clip, LayerOverlay |
STAGE_GIZMOS |
Inside the artboard clip div (moves with canvas) | In-canvas gizmos |
ROOT_OVERLAY |
Workspace root | FileLoader, Onboarding |
HIDDEN |
Background services | FontLoader, TimeTraveler |
Slot rendering (PluginSlot.tsx) collects by slot + contributions, orders by order (multiples of 100 insert separators), wraps in PluginErrorBoundary + Suspense, and gates visibility via plugins.isPluginVisible(p, { hasActiveFrame }). ?safe-mode=true loads core plugins only.
Viewport Stack
Inside ViewportInner (bottom to top, all inside a reveal gate):
CanvasBackdrop— checkerboard / rotated background- Stage transform div → artboard clip div →
STAGE_GIZMOSslot CanvasStage— the WebGPU canvas plane (z = 5, pointer-events none, driven byuseFastSync)STAGE_OVERLAYslot — tool interaction layers
There are no scrollbars: pan/zoom is a camera model (useViewportScroll, useCameraInit). First paint is reveal-gated on imagesLoaded and engine.onFirstPaint (each with a 1500ms fallback) so the viewport never shows an empty shell.
Z-Index Policy
Single source of truth: core/helpers/config.ts.
EDITOR_Z_INDEX = {
STAGE: { BACKDROP: 0, CONTENT: 100, GIZMOS: 1000, SYSTEM_TOOLS: 2000, DEVELOPER_ZONE: 3000 },
UI: { WORKSPACE_BASE: 2000, OVERLAY: 4000, POPOVER: 5000, MODAL: 6000, TOOLTIP: 10100 },
}
New floating UI must derive from EDITOR_Z_INDEX plus a small offset — never introduce an independent magnitude.
State and Context Supply
EditorProvider splits context in two:
- EditorStateContext —
state,activeFrame,activeLayer; new object per change, subscribers re-render. - EditorServiceContext —
actionsand singleton services (store, geometry, assets, WorkerBridge, files, pixelFacade, layers, plugins, stateStorage, clipboard, fonts); referentially stable.
Common hooks: useEditorServices (per-plugin scoped executeCommand), useEditorState, usePluginSignals, usePluginConfig, and useVolatileInteraction (a useSyncExternalStore fast path that bypasses reducer-wide updates for high-frequency interaction state).
GlobalUI
GlobalUI mounts unconditionally at the workspace root and owns:
- EditorHUD — the single error feedback channel for the entire product (all command rejections converge here; error toasts auto-clear after 1200ms)
- FancyConfirm / FancyChoice dialogs driven by
state.confirm/state.choice - The portal root (
#editor-portal-root, z = POPOVER + 100)
Last updated: 2026-10-12