@dfosco/storyboard-react 4.2.4 → 4.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.2.4",
3
+ "version": "4.2.6",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@base-ui/react": "^1.4.0",
7
- "@dfosco/storyboard-core": "4.2.4",
8
- "@dfosco/tiny-canvas": "4.2.4",
7
+ "@dfosco/storyboard-core": "4.2.6",
8
+ "@dfosco/tiny-canvas": "4.2.6",
9
9
  "@neodrag/react": "^2.3.1",
10
10
  "@radix-ui/react-dialog": "^1.1.15",
11
11
  "@radix-ui/react-visually-hidden": "^1.2.4",
@@ -56,6 +56,31 @@ function AvatarIcon({ username }) {
56
56
  )
57
57
  }
58
58
 
59
+ /**
60
+ * Alt-reactive label for the "Hide toolbars" command palette item.
61
+ * Shows "Completely hide toolbars" when alt is held, "Hide toolbars" otherwise.
62
+ */
63
+ function HideToolbarsLabel({ isHidden }) {
64
+ const [altHeld, setAltHeld] = useState(false)
65
+ useEffect(() => {
66
+ const onKey = (e) => setAltHeld(e.altKey)
67
+ const onUp = () => setAltHeld(false)
68
+ document.addEventListener('keydown', onKey, true)
69
+ document.addEventListener('keyup', onUp, true)
70
+ return () => {
71
+ document.removeEventListener('keydown', onKey, true)
72
+ document.removeEventListener('keyup', onUp, true)
73
+ }
74
+ }, [])
75
+
76
+ return (
77
+ <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
78
+ <span>{altHeld ? 'Completely hide toolbars' : 'Hide toolbars'}</span>
79
+ <span>{isHidden ? '✓' : ''}</span>
80
+ </span>
81
+ )
82
+ }
83
+
59
84
  /**
60
85
  * Check if a tool should be hidden from the command palette on the current route.
61
86
  * Uses the same pattern-matching logic as excludeRoutes.
@@ -228,11 +253,16 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
228
253
  remainingItems.push({
229
254
  id: `cfg:${section.id}:${toolId}`,
230
255
  children: label,
231
- keywords: [label, toolId, 'hide', 'show', 'toolbar'].filter(Boolean),
256
+ keywords: [label, toolId, 'hide', 'show', 'toolbar', 'completely'].filter(Boolean),
232
257
  showType: false,
233
258
  onClick: () => {
259
+ document.documentElement.classList.remove('storyboard-chrome-completely-hidden')
234
260
  document.documentElement.classList.toggle('storyboard-chrome-hidden')
235
261
  },
262
+ onAltClick: () => {
263
+ document.documentElement.classList.add('storyboard-chrome-hidden')
264
+ document.documentElement.classList.add('storyboard-chrome-completely-hidden')
265
+ },
236
266
  })
237
267
  continue
238
268
  }
@@ -254,19 +284,17 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
254
284
  onClick: () => { window.location.href = resolvedUrl },
255
285
  })
256
286
  } else {
257
- // Menu tools: close palette and click the toolbar button to open the menu
287
+ // Menu tools: close palette and dispatch event to open the toolbar menu
258
288
  if (tool.render === 'menu') {
259
- const ariaLabel = tool.ariaLabel || tool.label || toolId
289
+ const handlerId = tool.handler || `core:${toolId}`
260
290
  remainingItems.push({
261
291
  id: `cfg:${section.id}:${toolId}`,
262
292
  children: label,
263
293
  keywords: [label, toolId].filter(Boolean),
264
294
  showType: false,
265
295
  onClick: () => {
266
- // Find and click the toolbar button
267
296
  setTimeout(() => {
268
- const btn = document.querySelector(`[aria-label="${ariaLabel}"]`)
269
- if (btn) btn.click()
297
+ window.dispatchEvent(new CustomEvent('storyboard:open-tool-menu', { detail: { action: handlerId } }))
270
298
  }, 100)
271
299
  },
272
300
  })
@@ -549,7 +577,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
549
577
  const pushProtoFlows = (p) => {
550
578
  if (p.isExternal) {
551
579
  sourceItems.push({ name: p.name, route: p.externalUrl, id: p.dirName, type: 'prototype', isExternal: true })
552
- } else if (p.hideFlows || p.flows.length <= 1) {
580
+ } else if (p.flows.length <= 1) {
553
581
  const route = p.flows.length === 1 ? `${prefix}${p.flows[0].route}` : `${prefix}/${p.dirName}`
554
582
  sourceItems.push({ name: p.name, route, id: p.dirName, type: 'prototype' })
555
583
  } else {
@@ -668,17 +696,19 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
668
696
  const isHidden = document.documentElement.classList.contains('storyboard-chrome-hidden')
669
697
  items.push({
670
698
  id: `cfg:${section.id}:${toolId}`,
671
- toolIcon: isHidden ? 'primer/light-bulb' : 'primer/light-bulb',
672
- children: <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
673
- <span>Hide toolbars</span>
674
- <span>{isHidden ? '✓' : ''}</span>
675
- </span>,
676
- keywords: [label, toolId, 'hide', 'show', 'toolbar'].filter(Boolean),
699
+ toolIcon: 'primer/light-bulb',
700
+ children: <HideToolbarsLabel isHidden={isHidden} />,
701
+ keywords: [label, toolId, 'hide', 'show', 'toolbar', 'completely'].filter(Boolean),
677
702
  showType: false,
678
703
  closeOnSelect: entryCloseOnSelect,
679
704
  onClick: () => {
705
+ document.documentElement.classList.remove('storyboard-chrome-completely-hidden')
680
706
  document.documentElement.classList.toggle('storyboard-chrome-hidden')
681
707
  },
708
+ onAltClick: () => {
709
+ document.documentElement.classList.add('storyboard-chrome-hidden')
710
+ document.documentElement.classList.add('storyboard-chrome-completely-hidden')
711
+ },
682
712
  })
683
713
  continue
684
714
  }
@@ -1220,7 +1250,7 @@ export default function StoryboardCommandPalette({ basePath }) {
1220
1250
  !search && <Command.Separator key={list.id} />
1221
1251
  ) : (
1222
1252
  <Command.Group key={list.id} heading={list.heading}>
1223
- {list.items.map(({ id, children, keywords, onClick, itemType, toolIcon, toolMeta, closeOnSelect, hideFromSearch, url }) => {
1253
+ {list.items.map(({ id, children, keywords, onClick, onAltClick, itemType, toolIcon, toolMeta, closeOnSelect, hideFromSearch, url }) => {
1224
1254
  if (search && hideFromSearch) return null
1225
1255
  if (hiddenFromSearchIds.size > 0) {
1226
1256
  for (const toolId of hiddenFromSearchIds) {
@@ -1236,6 +1266,8 @@ export default function StoryboardCommandPalette({ basePath }) {
1236
1266
  copyLinkToClipboard(url, itemType)
1237
1267
  } else if (url && modifierHeldRef.current) {
1238
1268
  window.open(url, '_blank')
1269
+ } else if (onAltClick && altHeldRef.current) {
1270
+ onAltClick()
1239
1271
  } else {
1240
1272
  onClick?.()
1241
1273
  }
@@ -1100,10 +1100,13 @@ export default function Workspace({
1100
1100
 
1101
1101
  // Prototypes (ungrouped + from folders)
1102
1102
  const addProto = (proto) => {
1103
- // For prototypes with flows, use the first flow's route
1104
- const route = proto.flows?.length > 0
1105
- ? proto.flows[0].route
1106
- : `/${proto.dirName}`
1103
+ // Prefer a flow marked as default, fall back to the first flow
1104
+ const defaultFlow = proto.flows?.find(f => f.meta?.default === true)
1105
+ const route = defaultFlow
1106
+ ? defaultFlow.route
1107
+ : proto.flows?.length > 0
1108
+ ? proto.flows[0].route
1109
+ : `/${proto.dirName}`
1107
1110
 
1108
1111
  items.push({
1109
1112
  id: `proto:${proto.dirName}`,
@@ -15,6 +15,7 @@ import { getCanvasZoom } from '@dfosco/storyboard-core'
15
15
  import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
16
16
  import { registerHotPoolDevLogs } from './hotPoolDevLogs.js'
17
17
  import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
18
+ import { WebGLContextPoolProvider, usePoolVisibilityUpdater } from './WebGLContextPool.jsx'
18
19
 
19
20
  import WidgetChrome from './widgets/WidgetChrome.jsx'
20
21
  import ComponentWidget from './widgets/ComponentWidget.jsx'
@@ -1086,6 +1087,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1086
1087
  position,
1087
1088
  })
1088
1089
  if (result.success && result.widget) {
1090
+ if (result.hotSession?.webglReady) {
1091
+ result.widget.props = { ...result.widget.props, webglReady: true }
1092
+ }
1089
1093
  setLocalWidgets((prev) => [...(prev || []), result.widget])
1090
1094
  setSelectedWidgetIds(new Set([result.widget.id]))
1091
1095
  }
@@ -1239,6 +1243,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1239
1243
  position,
1240
1244
  })
1241
1245
  if (result.success && result.widget) {
1246
+ if (result.hotSession?.webglReady) {
1247
+ result.widget.props = { ...result.widget.props, webglReady: true }
1248
+ }
1242
1249
  newWidgets.push(result.widget)
1243
1250
  }
1244
1251
  } catch (err) {
@@ -1996,6 +2003,10 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1996
2003
  position: pos,
1997
2004
  })
1998
2005
  if (result.success && result.widget) {
2006
+ // Hot pool WebGL-ready flag: add to props so TerminalWidget starts PINNED
2007
+ if (result.hotSession?.webglReady) {
2008
+ result.widget.props = { ...result.widget.props, webglReady: true }
2009
+ }
1999
2010
  undoRedo.snapshot(stateRef.current, 'add')
2000
2011
  setLocalWidgets((prev) => [...(prev || []), result.widget])
2001
2012
  setSelectedWidgetIds(new Set([result.widget.id]))
@@ -2190,6 +2201,50 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2190
2201
  window[CANVAS_BRIDGE_STATE_KEY] = bridge
2191
2202
  }, [localWidgets, localConnectors])
2192
2203
 
2204
+ // ── WebGL context pool: viewport-based visibility tracking ──
2205
+ const updatePoolVisibility = usePoolVisibilityUpdater()
2206
+ const poolRafRef = useRef(null)
2207
+
2208
+ // Compute viewport rect in canvas coordinates and update terminal priorities
2209
+ const syncPoolVisibility = useCallback(() => {
2210
+ const el = scrollRef.current
2211
+ if (!el || !localWidgets) return
2212
+ const currentZoom = zoomRef.current || 100
2213
+ const currentScale = currentZoom / 100
2214
+ const viewportRect = {
2215
+ x: el.scrollLeft / currentScale,
2216
+ y: el.scrollTop / currentScale,
2217
+ w: el.clientWidth / currentScale,
2218
+ h: el.clientHeight / currentScale,
2219
+ }
2220
+ updatePoolVisibility(viewportRect, localWidgets, selectedWidgetIds, null)
2221
+ }, [updatePoolVisibility, localWidgets, selectedWidgetIds])
2222
+
2223
+ // Throttle visibility updates via rAF on scroll
2224
+ useEffect(() => {
2225
+ const el = scrollRef.current
2226
+ if (!el) return
2227
+ function onScroll() {
2228
+ if (poolRafRef.current) return
2229
+ poolRafRef.current = requestAnimationFrame(() => {
2230
+ poolRafRef.current = null
2231
+ syncPoolVisibility()
2232
+ })
2233
+ }
2234
+ el.addEventListener('scroll', onScroll, { passive: true })
2235
+ // Initial sync
2236
+ syncPoolVisibility()
2237
+ return () => {
2238
+ el.removeEventListener('scroll', onScroll)
2239
+ if (poolRafRef.current) cancelAnimationFrame(poolRafRef.current)
2240
+ }
2241
+ }, [syncPoolVisibility])
2242
+
2243
+ // Re-sync on zoom changes
2244
+ useEffect(() => {
2245
+ syncPoolVisibility()
2246
+ }, [zoom, syncPoolVisibility])
2247
+
2193
2248
  // Delete selected widget on Delete/Backspace key
2194
2249
  useEffect(() => {
2195
2250
  function handleSelectStart(e) {
@@ -2427,6 +2482,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2427
2482
  position: { x: baseX + relX, y: baseY + relY },
2428
2483
  })
2429
2484
  if (result.success && result.widget) {
2485
+ if (result.hotSession?.webglReady) {
2486
+ result.widget.props = { ...result.widget.props, webglReady: true }
2487
+ }
2430
2488
  newWidgets.push(result.widget)
2431
2489
  }
2432
2490
  }
@@ -2954,7 +3012,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2954
3012
  const filteredConnectors = localConnectors
2955
3013
 
2956
3014
  return (
2957
- <>
3015
+ <WebGLContextPoolProvider>
2958
3016
  <div className={styles.canvasTitle}>
2959
3017
  <a href={(import.meta.env?.BASE_URL || '/')} className={styles.canvasLogo} aria-label="Go to homepage">
2960
3018
  <Icon name="home" size={16} color="#fff" />
@@ -3029,6 +3087,6 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
3029
3087
  </button>
3030
3088
  </aside>
3031
3089
  )}
3032
- </>
3090
+ </WebGLContextPoolProvider>
3033
3091
  )
3034
3092
  }
@@ -0,0 +1,292 @@
1
+ import { createContext, useContext, useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
2
+
3
+ /**
4
+ * WebGL Context Pool — manages live WebGL slots for terminal widgets.
5
+ *
6
+ * Browsers cap WebGL contexts at ~8-16. This pool ensures only a subset
7
+ * of terminal widgets hold live ghostty-web renderers at any time.
8
+ * Offscreen terminals release their context and show a frozen snapshot.
9
+ *
10
+ * Architecture:
11
+ * - Lease-based: widgets register and receive live/frozen status
12
+ * - Priority: PINNED > VISIBLE > NEAR_VIEWPORT > OFFSCREEN
13
+ * - Hysteresis: 3s grace before evicting recently-visible terminals
14
+ * - Generation tokens prevent stale async opens after eviction
15
+ */
16
+
17
+ // Priority levels (higher = more important to keep live)
18
+ export const Priority = {
19
+ OFFSCREEN: 0,
20
+ NEAR_VIEWPORT: 1,
21
+ VISIBLE: 2,
22
+ PINNED: 3, // expanded, focused, or actively interacting
23
+ }
24
+
25
+ const DEFAULT_MAX_LIVE = 6
26
+ const HYSTERESIS_MS = 3000
27
+
28
+ // ── Pool Engine (framework-agnostic) ─────────────────────────────────
29
+
30
+ class ContextPool {
31
+ constructor(maxLive = DEFAULT_MAX_LIVE) {
32
+ this._maxLive = maxLive
33
+ /** @type {Map<string, { priority: number, generation: number, live: boolean, lastVisible: number }>} */
34
+ this._slots = new Map()
35
+ this._listeners = new Set()
36
+ this._hysteresisTimers = new Map()
37
+ // Monotonic version counter — bumped on every state change so
38
+ // useSyncExternalStore detects updates via a new snapshot value.
39
+ this._version = 0
40
+ }
41
+
42
+ /** Subscribe to pool state changes. Returns unsubscribe fn. */
43
+ subscribe(listener) {
44
+ this._listeners.add(listener)
45
+ return () => this._listeners.delete(listener)
46
+ }
47
+
48
+ _notify() {
49
+ this._version++
50
+ for (const fn of this._listeners) fn()
51
+ }
52
+
53
+ /** Register a widget. Returns its initial generation. */
54
+ register(widgetId, initialPriority = Priority.OFFSCREEN) {
55
+ if (this._slots.has(widgetId)) return this._slots.get(widgetId).generation
56
+ this._slots.set(widgetId, {
57
+ priority: initialPriority,
58
+ generation: 0,
59
+ live: false,
60
+ lastVisible: initialPriority >= Priority.VISIBLE ? Date.now() : 0,
61
+ })
62
+ this._recompute()
63
+ return 0
64
+ }
65
+
66
+ /** Unregister a widget (on unmount). */
67
+ unregister(widgetId) {
68
+ this._slots.delete(widgetId)
69
+ const timer = this._hysteresisTimers.get(widgetId)
70
+ if (timer) { clearTimeout(timer); this._hysteresisTimers.delete(widgetId) }
71
+ this._recompute()
72
+ }
73
+
74
+ /** Update a widget's priority. Triggers recomputation. */
75
+ setPriority(widgetId, priority) {
76
+ const slot = this._slots.get(widgetId)
77
+ if (!slot) return
78
+ const prev = slot.priority
79
+ slot.priority = priority
80
+
81
+ // Track when widget was last visible/pinned for hysteresis
82
+ if (priority >= Priority.VISIBLE) {
83
+ slot.lastVisible = Date.now()
84
+ }
85
+
86
+ // Immediate recompute when priority increases or widget is pinned
87
+ if (priority > prev || priority === Priority.PINNED) {
88
+ this._recompute()
89
+ return
90
+ }
91
+
92
+ // When priority drops (e.g. scrolled offscreen), use hysteresis
93
+ if (priority < prev && prev >= Priority.VISIBLE) {
94
+ const existing = this._hysteresisTimers.get(widgetId)
95
+ if (existing) clearTimeout(existing)
96
+ this._hysteresisTimers.set(widgetId, setTimeout(() => {
97
+ this._hysteresisTimers.delete(widgetId)
98
+ this._recompute()
99
+ }, HYSTERESIS_MS))
100
+ return
101
+ }
102
+
103
+ this._recompute()
104
+ }
105
+
106
+ /** Get current state for a widget. */
107
+ getSlot(widgetId) {
108
+ return this._slots.get(widgetId) || null
109
+ }
110
+
111
+ /**
112
+ * Recompute which widgets should be live.
113
+ * Sorts by priority (desc), then by lastVisible (desc).
114
+ * Top N get live status; rest are frozen.
115
+ */
116
+ _recompute() {
117
+ const entries = [...this._slots.entries()]
118
+
119
+ // Sort: pinned first, then by priority desc, then by recency
120
+ entries.sort(([, a], [, b]) => {
121
+ if (a.priority !== b.priority) return b.priority - a.priority
122
+ return b.lastVisible - a.lastVisible
123
+ })
124
+
125
+ let liveCount = 0
126
+ const changes = []
127
+
128
+ for (const [id, slot] of entries) {
129
+ // Pinned terminals always get a slot (they bypass the cap)
130
+ const shouldBeLive = slot.priority === Priority.PINNED || liveCount < this._maxLive
131
+ if (shouldBeLive) liveCount++
132
+
133
+ if (slot.live !== shouldBeLive) {
134
+ changes.push({ id, slot, shouldBeLive })
135
+ }
136
+ }
137
+
138
+ if (changes.length === 0) return
139
+
140
+ for (const { slot, shouldBeLive } of changes) {
141
+ slot.live = shouldBeLive
142
+ if (!shouldBeLive) {
143
+ // Bump generation so stale async opens abort
144
+ slot.generation++
145
+ }
146
+ }
147
+
148
+ this._notify()
149
+ }
150
+ }
151
+
152
+ // ── React Context ────────────────────────────────────────────────────
153
+
154
+ const WebGLPoolContext = createContext(null)
155
+
156
+ /**
157
+ * Provider that creates and owns the context pool.
158
+ * Place this around the canvas widget tree in CanvasPage.
159
+ *
160
+ * @param {{ maxLive?: number, children: React.ReactNode }} props
161
+ */
162
+ export function WebGLContextPoolProvider({ maxLive = DEFAULT_MAX_LIVE, children }) {
163
+ // useMemo is correct here: the pool is a stable singleton for this provider's lifetime.
164
+ // eslint-disable-next-line react-hooks/exhaustive-deps
165
+ const pool = useMemo(() => new ContextPool(maxLive), [])
166
+ return (
167
+ <WebGLPoolContext.Provider value={pool}>
168
+ {children}
169
+ </WebGLPoolContext.Provider>
170
+ )
171
+ }
172
+
173
+ /**
174
+ * Hook that manages a terminal widget's WebGL slot lifecycle.
175
+ *
176
+ * Returns:
177
+ * - `isLive`: whether this widget should create a live ghostty terminal
178
+ * - `generation`: counter that increments on each live→frozen transition;
179
+ * use as an effect dep to detect when to re-create the terminal
180
+ * - `setPriority(p)`: update this widget's priority
181
+ *
182
+ * @param {string} widgetId
183
+ * @returns {{ isLive: boolean, generation: number, setPriority: (p: number) => void }}
184
+ */
185
+ export function useWebGLSlot(widgetId, initialPriority) {
186
+ const pool = useContext(WebGLPoolContext)
187
+
188
+ // Register on mount, unregister on unmount
189
+ useEffect(() => {
190
+ if (!pool) return
191
+ pool.register(widgetId, initialPriority)
192
+ return () => pool.unregister(widgetId)
193
+ }, [pool, widgetId]) // initialPriority intentionally excluded — only used on first register
194
+
195
+ // Subscribe to pool state for reactivity.
196
+ // getSnapshot returns the pool's version counter — a primitive that changes
197
+ // on every state mutation, satisfying useSyncExternalStore's identity check.
198
+ const subscribe = useCallback(
199
+ (cb) => pool ? pool.subscribe(cb) : () => {},
200
+ [pool],
201
+ )
202
+ const getSnapshot = useCallback(() => {
203
+ if (!pool) return -1
204
+ return pool._version
205
+ }, [pool])
206
+
207
+ // This triggers re-render whenever the pool version changes
208
+ useSyncExternalStore(subscribe, getSnapshot)
209
+
210
+ // Read the actual slot state (after subscription ensures freshness)
211
+ const slot = pool?.getSlot(widgetId)
212
+
213
+ const setPriority = useCallback(
214
+ (p) => pool?.setPriority(widgetId, p),
215
+ [pool, widgetId],
216
+ )
217
+
218
+ // When there's no pool provider (e.g. standalone usage), always be live
219
+ if (!pool || !slot) {
220
+ return { isLive: true, generation: 0, setPriority: () => {} }
221
+ }
222
+
223
+ return {
224
+ isLive: slot.live,
225
+ generation: slot.generation,
226
+ setPriority,
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Hook for CanvasPage to batch-update visibility for all terminal widgets.
232
+ * Call this with the current viewport rect and widget positions.
233
+ *
234
+ * @returns {(viewportRect: {x:number,y:number,w:number,h:number}, widgets: Array<{id:string,type:string,position:{x:number,y:number},props?:object}>, selectedIds: Set<string>, expandedId: string|null) => void}
235
+ */
236
+ export function usePoolVisibilityUpdater() {
237
+ const pool = useContext(WebGLPoolContext)
238
+
239
+ return useCallback((viewportRect, widgets, selectedIds, expandedId) => {
240
+ if (!pool) return
241
+
242
+ const NEAR_MARGIN = 400 // canvas-space pixels for "near viewport" zone
243
+
244
+ const terminalTypes = new Set(['terminal', 'agent', 'prompt'])
245
+
246
+ for (const w of widgets) {
247
+ if (!terminalTypes.has(w.type)) continue
248
+
249
+ // Determine priority
250
+ if (w.id === expandedId) {
251
+ pool.setPriority(w.id, Priority.PINNED)
252
+ continue
253
+ }
254
+
255
+ if (selectedIds?.has(w.id)) {
256
+ pool.setPriority(w.id, Priority.PINNED)
257
+ continue
258
+ }
259
+
260
+ const wx = w.position?.x ?? 0
261
+ const wy = w.position?.y ?? 0
262
+ const ww = w.props?.width ?? 800
263
+ const wh = w.props?.height ?? 450
264
+
265
+ // Check overlap with viewport
266
+ const visible = rectsOverlap(
267
+ viewportRect.x, viewportRect.y, viewportRect.w, viewportRect.h,
268
+ wx, wy, ww, wh,
269
+ )
270
+
271
+ if (visible) {
272
+ pool.setPriority(w.id, Priority.VISIBLE)
273
+ continue
274
+ }
275
+
276
+ // Check overlap with expanded viewport (near margin)
277
+ const near = rectsOverlap(
278
+ viewportRect.x - NEAR_MARGIN,
279
+ viewportRect.y - NEAR_MARGIN,
280
+ viewportRect.w + NEAR_MARGIN * 2,
281
+ viewportRect.h + NEAR_MARGIN * 2,
282
+ wx, wy, ww, wh,
283
+ )
284
+
285
+ pool.setPriority(w.id, near ? Priority.NEAR_VIEWPORT : Priority.OFFSCREEN)
286
+ }
287
+ }, [pool])
288
+ }
289
+
290
+ function rectsOverlap(ax, ay, aw, ah, bx, by, bw, bh) {
291
+ return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by
292
+ }