@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 +3 -3
- package/src/CommandPalette/CommandPalette.jsx +46 -14
- package/src/Viewfinder.jsx +7 -4
- package/src/canvas/CanvasPage.jsx +60 -2
- package/src/canvas/WebGLContextPool.jsx +292 -0
- package/src/canvas/WebGLContextPool.test.jsx +165 -0
- package/src/canvas/componentIsolate.jsx +45 -15
- package/src/canvas/componentSetIsolate.jsx +257 -0
- package/src/canvas/widgets/ComponentSetWidget.jsx +2 -208
- package/src/canvas/widgets/ComponentWidget.jsx +7 -132
- package/src/canvas/widgets/ComponentWidget.module.css +0 -26
- package/src/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
- package/src/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
- package/src/canvas/widgets/PromptWidget.jsx +16 -2
- package/src/canvas/widgets/StorySetWidget.jsx +208 -0
- package/src/canvas/widgets/StorySetWidget.module.css +89 -0
- package/src/canvas/widgets/StoryWidget.jsx +3 -4
- package/src/canvas/widgets/TerminalWidget.jsx +146 -100
- package/src/canvas/widgets/TerminalWidget.module.css +23 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +1 -61
- package/src/canvas/widgets/expandUtils.js +3 -4
- package/src/canvas/widgets/index.js +2 -2
- package/src/canvas/widgets/snapshotDisplay.test.jsx +1 -1
- package/src/context.jsx +70 -7
- package/src/vite/data-plugin.js +8 -2
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.2.
|
|
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.
|
|
8
|
-
"@dfosco/tiny-canvas": "4.2.
|
|
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
|
|
287
|
+
// Menu tools: close palette and dispatch event to open the toolbar menu
|
|
258
288
|
if (tool.render === 'menu') {
|
|
259
|
-
const
|
|
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
|
-
|
|
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.
|
|
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:
|
|
672
|
-
children: <
|
|
673
|
-
|
|
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
|
}
|
package/src/Viewfinder.jsx
CHANGED
|
@@ -1100,10 +1100,13 @@ export default function Workspace({
|
|
|
1100
1100
|
|
|
1101
1101
|
// Prototypes (ungrouped + from folders)
|
|
1102
1102
|
const addProto = (proto) => {
|
|
1103
|
-
//
|
|
1104
|
-
const
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
+
}
|