@dfosco/storyboard-react 4.1.0-beta.3 → 4.2.0-beta.0
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 +86 -9
- package/src/CommandPalette/command-palette.css +5 -0
- package/src/canvas/CanvasPage.jsx +432 -20
- package/src/canvas/CanvasPage.module.css +26 -4
- package/src/canvas/ConnectorLayer.jsx +252 -0
- package/src/canvas/ConnectorLayer.module.css +60 -0
- package/src/canvas/PageSelector.jsx +376 -37
- package/src/canvas/PageSelector.module.css +93 -6
- package/src/canvas/canvasApi.js +35 -0
- package/src/canvas/useCanvas.js +6 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +27 -6
- package/src/canvas/widgets/MarkdownBlock.module.css +11 -1
- package/src/canvas/widgets/StickyNote.module.css +3 -0
- package/src/canvas/widgets/TerminalWidget.jsx +274 -0
- package/src/canvas/widgets/TerminalWidget.module.css +158 -0
- package/src/canvas/widgets/WidgetChrome.jsx +86 -1
- package/src/canvas/widgets/WidgetChrome.module.css +72 -0
- package/src/canvas/widgets/index.js +2 -0
- package/src/canvas/widgets/widgetConfig.js +78 -0
- package/src/canvas/widgets/widgetProps.js +1 -0
- package/src/context.jsx +15 -0
|
@@ -68,6 +68,7 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
|
|
|
68
68
|
const content = readProp(props, 'content', markdownSchema)
|
|
69
69
|
const width = readProp(props, 'width', markdownSchema)
|
|
70
70
|
const height = props?.height
|
|
71
|
+
const collapsed = !!props?.collapsed
|
|
71
72
|
const canEdit = typeof onUpdate === 'function'
|
|
72
73
|
const [editing, setEditing] = useState(false)
|
|
73
74
|
const editingActive = canEdit && editing
|
|
@@ -121,7 +122,14 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
|
|
|
121
122
|
setEditHeight(blockRef.current.offsetHeight)
|
|
122
123
|
}
|
|
123
124
|
if (textareaRef.current) {
|
|
124
|
-
|
|
125
|
+
// Place cursor at end and prevent scroll jump to top
|
|
126
|
+
const len = textareaRef.current.value.length
|
|
127
|
+
textareaRef.current.setSelectionRange(len, len)
|
|
128
|
+
textareaRef.current.focus({ preventScroll: true })
|
|
129
|
+
// Restore the block's scroll position (captured before React swapped the DOM)
|
|
130
|
+
if (blockRef.current) {
|
|
131
|
+
blockRef.current.scrollTop = blockRef.current.dataset.scrollTop || 0
|
|
132
|
+
}
|
|
125
133
|
}
|
|
126
134
|
} else {
|
|
127
135
|
setEditHeight(null)
|
|
@@ -132,15 +140,19 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
|
|
|
132
140
|
<WidgetWrapper>
|
|
133
141
|
<div
|
|
134
142
|
ref={blockRef}
|
|
135
|
-
className={styles.block}
|
|
136
|
-
style={{
|
|
143
|
+
className={`${styles.block}${collapsed && !editingActive ? ` ${styles.blockCollapsed}` : ''}`}
|
|
144
|
+
style={{
|
|
145
|
+
width,
|
|
146
|
+
...(height ? { height, overflow: 'auto' } : {}),
|
|
147
|
+
...(editHeight ? { height: editHeight, display: 'flex', flexDirection: 'column' } : {}),
|
|
148
|
+
}}
|
|
137
149
|
>
|
|
138
150
|
{editingActive ? (
|
|
139
151
|
<textarea
|
|
140
152
|
ref={textareaRef}
|
|
141
153
|
className={styles.editor}
|
|
142
154
|
data-canvas-allow-text-selection
|
|
143
|
-
style={{
|
|
155
|
+
style={{ flex: 1 }}
|
|
144
156
|
value={content}
|
|
145
157
|
onChange={handleContentChange}
|
|
146
158
|
onBlur={() => setEditing(false)}
|
|
@@ -158,10 +170,19 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
|
|
|
158
170
|
data-canvas-allow-text-selection={!canEdit ? '' : undefined}
|
|
159
171
|
onClick={!canEdit ? (e) => e.stopPropagation() : undefined}
|
|
160
172
|
onCopy={!canEdit ? handleReadOnlyCopy : undefined}
|
|
161
|
-
onDoubleClick={canEdit ? () =>
|
|
173
|
+
onDoubleClick={canEdit ? () => {
|
|
174
|
+
// Save scroll position before switching to editor
|
|
175
|
+
if (blockRef.current) blockRef.current.dataset.scrollTop = blockRef.current.scrollTop
|
|
176
|
+
setEditing(true)
|
|
177
|
+
} : undefined}
|
|
162
178
|
role={canEdit ? 'button' : undefined}
|
|
163
179
|
tabIndex={canEdit ? 0 : undefined}
|
|
164
|
-
onKeyDown={canEdit ? (e) => {
|
|
180
|
+
onKeyDown={canEdit ? (e) => {
|
|
181
|
+
if (e.key === 'Enter') {
|
|
182
|
+
if (blockRef.current) blockRef.current.dataset.scrollTop = blockRef.current.scrollTop
|
|
183
|
+
setEditing(true)
|
|
184
|
+
}
|
|
185
|
+
} : undefined}
|
|
165
186
|
dangerouslySetInnerHTML={{
|
|
166
187
|
__html: renderedHtml || (canEdit
|
|
167
188
|
? '<p class="placeholder">Double-click to edit…</p>'
|
|
@@ -10,6 +10,16 @@
|
|
|
10
10
|
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
.blockCollapsed {
|
|
14
|
+
max-height: 360px;
|
|
15
|
+
overflow: hidden;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.blockCollapsed .preview {
|
|
19
|
+
mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
|
|
20
|
+
-webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
.preview {
|
|
14
24
|
padding: 16px 20px;
|
|
15
25
|
font-size: 14px;
|
|
@@ -206,7 +216,7 @@
|
|
|
206
216
|
width: 100%;
|
|
207
217
|
height: 100%;
|
|
208
218
|
box-sizing: border-box;
|
|
209
|
-
|
|
219
|
+
|
|
210
220
|
padding: 16px 20px;
|
|
211
221
|
border: none;
|
|
212
222
|
outline: none;
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
|
|
13
13
|
overflow: auto;
|
|
14
14
|
position: relative;
|
|
15
|
+
display: flex;
|
|
16
|
+
flex-direction: column;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
:global([data-sb-canvas-theme^='dark']) .sticky {
|
|
@@ -35,6 +37,7 @@
|
|
|
35
37
|
word-break: break-word;
|
|
36
38
|
cursor: text;
|
|
37
39
|
min-height: 60px;
|
|
40
|
+
flex: 1;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
:global([data-sb-canvas-theme^='dark']) .text {
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { useRef, useEffect, useCallback, useState } from 'react'
|
|
2
|
+
import { readProp } from './widgetProps.js'
|
|
3
|
+
import { schemas } from './widgetProps.js'
|
|
4
|
+
import { getTerminalConfig } from '@dfosco/storyboard-core'
|
|
5
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
6
|
+
import styles from './TerminalWidget.module.css'
|
|
7
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
8
|
+
|
|
9
|
+
const terminalSchema = schemas['terminal']
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Lazy-load ghostty-web to avoid bundling WASM in prod.
|
|
13
|
+
*/
|
|
14
|
+
let ghosttyPromise = null
|
|
15
|
+
function loadGhostty() {
|
|
16
|
+
if (!ghosttyPromise) {
|
|
17
|
+
ghosttyPromise = import('ghostty-web').then(async (mod) => {
|
|
18
|
+
if (mod.init) await mod.init()
|
|
19
|
+
return mod
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
return ghosttyPromise
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the WebSocket URL for the terminal backend.
|
|
27
|
+
* Includes the base path (e.g. /branch--4.2.0/) so the proxy routes correctly.
|
|
28
|
+
* Passes canvasId as a query parameter for session scoping.
|
|
29
|
+
*/
|
|
30
|
+
function getWsUrl(sessionId, prettyName) {
|
|
31
|
+
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
32
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
33
|
+
const baseClean = base.endsWith('/') ? base : base + '/'
|
|
34
|
+
const canvasId = window.__storyboardCanvasBridgeState?.canvasId || 'unknown'
|
|
35
|
+
let url = `${protocol}//${location.host}${baseClean}_storyboard/terminal/${sessionId}?canvas=${encodeURIComponent(canvasId)}`
|
|
36
|
+
if (prettyName) url += `&name=${encodeURIComponent(prettyName)}`
|
|
37
|
+
return url
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Calculate terminal cols/rows from pixel dimensions.
|
|
42
|
+
*/
|
|
43
|
+
function calcDimensions(widthPx, heightPx) {
|
|
44
|
+
// Approximate character cell size for 13px monospace
|
|
45
|
+
const cellWidth = 7.8
|
|
46
|
+
const cellHeight = 17
|
|
47
|
+
const padding = 24 // 12px each side
|
|
48
|
+
const cols = Math.max(10, Math.floor((widthPx - padding) / cellWidth))
|
|
49
|
+
const rows = Math.max(4, Math.floor((heightPx - padding) / cellHeight))
|
|
50
|
+
return { cols, rows }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DEFAULT_THEME = {
|
|
54
|
+
background: '#0d1117',
|
|
55
|
+
foreground: '#e6edf3',
|
|
56
|
+
cursor: '#e6edf3',
|
|
57
|
+
selectionBackground: '#264f78',
|
|
58
|
+
black: '#484f58',
|
|
59
|
+
red: '#ff7b72',
|
|
60
|
+
green: '#3fb950',
|
|
61
|
+
yellow: '#d29922',
|
|
62
|
+
blue: '#58a6ff',
|
|
63
|
+
magenta: '#bc8cff',
|
|
64
|
+
cyan: '#39d2c0',
|
|
65
|
+
white: '#b1bac4',
|
|
66
|
+
brightBlack: '#6e7681',
|
|
67
|
+
brightRed: '#ffa198',
|
|
68
|
+
brightGreen: '#56d364',
|
|
69
|
+
brightYellow: '#e3b341',
|
|
70
|
+
brightBlue: '#79c0ff',
|
|
71
|
+
brightMagenta: '#d2a8ff',
|
|
72
|
+
brightCyan: '#56d4dd',
|
|
73
|
+
brightWhite: '#f0f6fc',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
77
|
+
const width = readProp(props, 'width', terminalSchema)
|
|
78
|
+
const height = readProp(props, 'height', terminalSchema)
|
|
79
|
+
const prettyName = props?.prettyName || null
|
|
80
|
+
|
|
81
|
+
const containerRef = useRef(null)
|
|
82
|
+
const termRef = useRef(null)
|
|
83
|
+
const terminalRef = useRef(null)
|
|
84
|
+
const wsRef = useRef(null)
|
|
85
|
+
const [ready, setReady] = useState(false)
|
|
86
|
+
const [error, setError] = useState(null)
|
|
87
|
+
const [sessionEnded, setSessionEnded] = useState(false)
|
|
88
|
+
const [connectAttempt, setConnectAttempt] = useState(0)
|
|
89
|
+
|
|
90
|
+
const handleResize = useCallback((w, h) => {
|
|
91
|
+
onUpdate?.({ width: w, height: h })
|
|
92
|
+
}, [onUpdate])
|
|
93
|
+
|
|
94
|
+
// Initialize terminal
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (!containerRef.current) return
|
|
97
|
+
|
|
98
|
+
let disposed = false
|
|
99
|
+
let term = null
|
|
100
|
+
let ws = null
|
|
101
|
+
|
|
102
|
+
async function setup() {
|
|
103
|
+
try {
|
|
104
|
+
const ghostty = await loadGhostty()
|
|
105
|
+
if (disposed) return
|
|
106
|
+
|
|
107
|
+
const dims = calcDimensions(width, height)
|
|
108
|
+
const cfg = getTerminalConfig()
|
|
109
|
+
|
|
110
|
+
term = new ghostty.Terminal({
|
|
111
|
+
fontSize: cfg.fontSize ?? 13,
|
|
112
|
+
fontFamily: cfg.fontFamily ?? "'Ghostty', 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
|
|
113
|
+
cursorBlink: true,
|
|
114
|
+
cursorStyle: 'bar',
|
|
115
|
+
cols: dims.cols,
|
|
116
|
+
rows: dims.rows,
|
|
117
|
+
theme: { ...DEFAULT_THEME, ...cfg.theme },
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
term.open(containerRef.current)
|
|
121
|
+
termRef.current = term
|
|
122
|
+
|
|
123
|
+
// Connect WebSocket
|
|
124
|
+
const url = getWsUrl(id, prettyName)
|
|
125
|
+
ws = new WebSocket(url)
|
|
126
|
+
wsRef.current = ws
|
|
127
|
+
|
|
128
|
+
ws.onopen = () => {
|
|
129
|
+
if (disposed) return
|
|
130
|
+
setReady(true)
|
|
131
|
+
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
ws.onmessage = (e) => {
|
|
135
|
+
if (disposed) return
|
|
136
|
+
const data = typeof e.data === 'string' ? e.data : null
|
|
137
|
+
// Intercept JSON control messages from the server
|
|
138
|
+
if (data && data.startsWith('{')) {
|
|
139
|
+
try {
|
|
140
|
+
const msg = JSON.parse(data)
|
|
141
|
+
if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') {
|
|
142
|
+
// Control message — don't render to terminal
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Not valid JSON — pass through as terminal data
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
ws.onclose = () => {
|
|
153
|
+
if (disposed) return
|
|
154
|
+
setReady(false)
|
|
155
|
+
setSessionEnded(true)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
ws.onerror = () => {
|
|
159
|
+
if (disposed) return
|
|
160
|
+
setReady(false)
|
|
161
|
+
setSessionEnded(true)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Terminal input → WebSocket
|
|
165
|
+
term.onData((data) => {
|
|
166
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
167
|
+
ws.send(data)
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (!disposed) setError(err.message || 'Failed to load terminal')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setup()
|
|
176
|
+
|
|
177
|
+
return () => {
|
|
178
|
+
disposed = true
|
|
179
|
+
if (ws && ws.readyState <= WebSocket.OPEN) ws.close()
|
|
180
|
+
if (term) term.dispose()
|
|
181
|
+
termRef.current = null
|
|
182
|
+
wsRef.current = null
|
|
183
|
+
}
|
|
184
|
+
}, [id, connectAttempt])
|
|
185
|
+
|
|
186
|
+
// Resize terminal on dimension changes
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!termRef.current) return
|
|
189
|
+
const timer = setTimeout(() => {
|
|
190
|
+
const dims = calcDimensions(width, height)
|
|
191
|
+
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
192
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
193
|
+
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
194
|
+
}
|
|
195
|
+
}, 50)
|
|
196
|
+
return () => clearTimeout(timer)
|
|
197
|
+
}, [width, height])
|
|
198
|
+
|
|
199
|
+
const handleClick = useCallback(() => {
|
|
200
|
+
if (sessionEnded) return
|
|
201
|
+
termRef.current?.focus()
|
|
202
|
+
}, [sessionEnded])
|
|
203
|
+
|
|
204
|
+
const [waking, setWaking] = useState(false)
|
|
205
|
+
|
|
206
|
+
const handleStartSession = useCallback(() => {
|
|
207
|
+
setWaking(true)
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
setWaking(false)
|
|
210
|
+
setSessionEnded(false)
|
|
211
|
+
setError(null)
|
|
212
|
+
setConnectAttempt(c => c + 1)
|
|
213
|
+
}, 1500)
|
|
214
|
+
}, [])
|
|
215
|
+
|
|
216
|
+
// Show interact gate when session is ready but not interacting
|
|
217
|
+
|
|
218
|
+
const titleLabel = `terminal · ${prettyName || '...'}`
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className={styles.container}>
|
|
222
|
+
<div className={styles.titleBar}>{titleLabel}</div>
|
|
223
|
+
<div
|
|
224
|
+
ref={terminalRef}
|
|
225
|
+
className={styles.terminal}
|
|
226
|
+
style={{
|
|
227
|
+
...(typeof width === 'number' ? { width: `${width}px` } : undefined),
|
|
228
|
+
...(typeof height === 'number' ? { height: `${height}px` } : undefined),
|
|
229
|
+
}}
|
|
230
|
+
onClick={handleClick}
|
|
231
|
+
>
|
|
232
|
+
{error && !sessionEnded && (
|
|
233
|
+
<div className={styles.error}>
|
|
234
|
+
<span>⚠ {error}</span>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
<div ref={containerRef} className={styles.xtermContainer} />
|
|
238
|
+
{sessionEnded && (
|
|
239
|
+
<div
|
|
240
|
+
className={overlayStyles.interactOverlay}
|
|
241
|
+
style={{ backgroundColor: '#0d1117', flexDirection: 'column', gap: 0 }}
|
|
242
|
+
onClick={handleStartSession}
|
|
243
|
+
role="button"
|
|
244
|
+
tabIndex={0}
|
|
245
|
+
aria-label="Start terminal session"
|
|
246
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleStartSession() }}
|
|
247
|
+
>
|
|
248
|
+
{!waking && (
|
|
249
|
+
<div className={styles.buddyZzz}>
|
|
250
|
+
<span className={styles.z1}>z</span>
|
|
251
|
+
<span className={styles.z2}>z</span>
|
|
252
|
+
<span className={styles.z3}>z</span>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
<span className={overlayStyles.interactHint}>
|
|
256
|
+
{waking ? 'Waking up...' : 'Start terminal session'}
|
|
257
|
+
</span>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
{!ready && !error && !sessionEnded && (
|
|
261
|
+
<div className={styles.loading}>Connecting…</div>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
{resizable && (
|
|
265
|
+
<ResizeHandle
|
|
266
|
+
targetRef={terminalRef}
|
|
267
|
+
onResize={handleResize}
|
|
268
|
+
minWidth={300}
|
|
269
|
+
minHeight={200}
|
|
270
|
+
/>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
position: relative;
|
|
3
|
+
padding-bottom: 0;
|
|
4
|
+
border-radius: var(--base-size-16, 16px);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/* Match selection outline border-radius to terminal's rounded corners */
|
|
8
|
+
:global(.tc-drag-surface):has(.container) {
|
|
9
|
+
border-radius: 16px;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.titleBar {
|
|
13
|
+
position: absolute;
|
|
14
|
+
top: -28px;
|
|
15
|
+
left: 4px;
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
17
|
+
font-size: 11px;
|
|
18
|
+
color: #8b949e;
|
|
19
|
+
pointer-events: none;
|
|
20
|
+
user-select: none;
|
|
21
|
+
white-space: nowrap;
|
|
22
|
+
z-index: 2;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
[data-widget-selected] .titleBar {
|
|
26
|
+
color: var(--borderColor-accent-emphasis, #0969da);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.terminal {
|
|
30
|
+
position: relative;
|
|
31
|
+
border-radius: var(--base-size-16, 16px);
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
background: #0d1117;
|
|
34
|
+
border: 1px solid var(--borderColor-default, #30363d);
|
|
35
|
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
|
|
36
|
+
padding: 8px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.xtermContainer {
|
|
40
|
+
width: 100%;
|
|
41
|
+
height: 100%;
|
|
42
|
+
box-sizing: border-box;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* ghostty-web / xterm.js container overrides */
|
|
46
|
+
.xtermContainer :global(.xterm) {
|
|
47
|
+
width: 100%;
|
|
48
|
+
height: 100%;
|
|
49
|
+
padding: 12px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.xtermContainer :global(.xterm-viewport) {
|
|
53
|
+
overflow-y: auto;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.xtermContainer :global(.xterm-viewport::-webkit-scrollbar) {
|
|
57
|
+
width: 6px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.xtermContainer :global(.xterm-viewport::-webkit-scrollbar-thumb) {
|
|
61
|
+
background: rgba(255, 255, 255, 0.15);
|
|
62
|
+
border-radius: 3px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.xtermContainer :global(.xterm-viewport::-webkit-scrollbar-track) {
|
|
66
|
+
background: transparent;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.loading {
|
|
70
|
+
position: absolute;
|
|
71
|
+
inset: 0;
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
justify-content: center;
|
|
75
|
+
color: #8b949e;
|
|
76
|
+
font-size: 13px;
|
|
77
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
78
|
+
background: #0d1117;
|
|
79
|
+
z-index: 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.error {
|
|
83
|
+
position: absolute;
|
|
84
|
+
inset: 0;
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
justify-content: center;
|
|
88
|
+
color: #f85149;
|
|
89
|
+
font-size: 13px;
|
|
90
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
91
|
+
background: #0d1117;
|
|
92
|
+
z-index: 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.mutedPrompt {
|
|
96
|
+
color: #484f58;
|
|
97
|
+
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
|
|
98
|
+
font-size: 16px;
|
|
99
|
+
position: absolute;
|
|
100
|
+
top: 12px;
|
|
101
|
+
left: 16px;
|
|
102
|
+
pointer-events: none;
|
|
103
|
+
user-select: none;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/* ── Terminal Zzz Animation ── */
|
|
107
|
+
|
|
108
|
+
.buddyZzz {
|
|
109
|
+
display: flex;
|
|
110
|
+
gap: 8px;
|
|
111
|
+
align-items: baseline;
|
|
112
|
+
margin-bottom: 16px;
|
|
113
|
+
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
|
|
114
|
+
pointer-events: none;
|
|
115
|
+
user-select: none;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.z1 {
|
|
119
|
+
font-size: 18px;
|
|
120
|
+
color: rgba(255, 255, 255, 0.6);
|
|
121
|
+
animation: zFloat 2.4s ease-in-out infinite;
|
|
122
|
+
animation-delay: 0s;
|
|
123
|
+
opacity: 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.z2 {
|
|
127
|
+
font-size: 22px;
|
|
128
|
+
color: rgba(255, 255, 255, 0.5);
|
|
129
|
+
animation: zFloat 2.4s ease-in-out infinite;
|
|
130
|
+
animation-delay: 0.8s;
|
|
131
|
+
opacity: 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.z3 {
|
|
135
|
+
font-size: 28px;
|
|
136
|
+
font-weight: 600;
|
|
137
|
+
color: rgba(255, 255, 255, 0.4);
|
|
138
|
+
animation: zFloat 2.4s ease-in-out infinite;
|
|
139
|
+
animation-delay: 1.6s;
|
|
140
|
+
opacity: 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@keyframes zFloat {
|
|
144
|
+
0% {
|
|
145
|
+
opacity: 0;
|
|
146
|
+
transform: translateY(0);
|
|
147
|
+
}
|
|
148
|
+
15% {
|
|
149
|
+
opacity: 1;
|
|
150
|
+
}
|
|
151
|
+
70% {
|
|
152
|
+
opacity: 0.4;
|
|
153
|
+
}
|
|
154
|
+
100% {
|
|
155
|
+
opacity: 0;
|
|
156
|
+
transform: translateY(-24px);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
|
|
2
2
|
import { Tooltip } from '@primer/react'
|
|
3
3
|
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold } from '@primer/octicons-react'
|
|
4
|
+
import { getConnectorConfig, getInteractGate } from './widgetConfig.js'
|
|
4
5
|
import styles from './WidgetChrome.module.css'
|
|
6
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
5
7
|
|
|
6
8
|
const STICKY_NOTE_COLORS = {
|
|
7
9
|
yellow: { bg: '#fff8c5', border: '#d4a72c', dot: '#e8c846' },
|
|
@@ -393,6 +395,7 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
|
|
|
393
395
|
*/
|
|
394
396
|
export default function WidgetChrome({
|
|
395
397
|
widgetId,
|
|
398
|
+
widgetType,
|
|
396
399
|
features = [],
|
|
397
400
|
selected = false,
|
|
398
401
|
multiSelected = false,
|
|
@@ -402,6 +405,7 @@ export default function WidgetChrome({
|
|
|
402
405
|
onDeselect, // eslint-disable-line no-unused-vars
|
|
403
406
|
onAction,
|
|
404
407
|
onUpdate,
|
|
408
|
+
onConnectorDragStart,
|
|
405
409
|
children,
|
|
406
410
|
readOnly = false,
|
|
407
411
|
}) {
|
|
@@ -452,6 +456,55 @@ export default function WidgetChrome({
|
|
|
452
456
|
const showFeatures = showToolbar && !multiSelected
|
|
453
457
|
const menuFeatures = features.filter((f) => f.menu)
|
|
454
458
|
|
|
459
|
+
// Interact gate — declarative overlay from widgets.config.json
|
|
460
|
+
const gate = widgetType ? getInteractGate(widgetType) : { enabled: false }
|
|
461
|
+
const [interacting, setInteracting] = useState(false)
|
|
462
|
+
const slotRef = useRef(null)
|
|
463
|
+
|
|
464
|
+
// Exit interact mode on click outside or double-Escape
|
|
465
|
+
const lastEscapeRef = useRef(0)
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
if (!gate.enabled || !interacting) return
|
|
468
|
+
const handleMouseDown = (e) => {
|
|
469
|
+
if (slotRef.current && !slotRef.current.contains(e.target)) {
|
|
470
|
+
setInteracting(false)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const handleKeyDown = (e) => {
|
|
474
|
+
if (e.key === 'Escape') {
|
|
475
|
+
const now = Date.now()
|
|
476
|
+
if (now - lastEscapeRef.current < 500) {
|
|
477
|
+
// Double-Escape: exit interact mode but keep widget selected
|
|
478
|
+
e.stopPropagation()
|
|
479
|
+
e.preventDefault()
|
|
480
|
+
setInteracting(false)
|
|
481
|
+
lastEscapeRef.current = 0
|
|
482
|
+
} else {
|
|
483
|
+
// First Escape: let it pass to widget, record timestamp
|
|
484
|
+
lastEscapeRef.current = now
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
document.addEventListener('mousedown', handleMouseDown, true)
|
|
489
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
490
|
+
return () => {
|
|
491
|
+
document.removeEventListener('mousedown', handleMouseDown, true)
|
|
492
|
+
document.removeEventListener('keydown', handleKeyDown, true)
|
|
493
|
+
}
|
|
494
|
+
}, [gate.enabled, interacting])
|
|
495
|
+
|
|
496
|
+
// Exit interact mode when deselected
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
if (!selected && !hovered && interacting) setInteracting(false)
|
|
499
|
+
}, [selected, hovered, interacting])
|
|
500
|
+
|
|
501
|
+
const handleGateClick = useCallback((e) => {
|
|
502
|
+
e.stopPropagation()
|
|
503
|
+
setInteracting(true)
|
|
504
|
+
// Also trigger selection so the widget gets selected
|
|
505
|
+
onSelect?.()
|
|
506
|
+
}, [onSelect])
|
|
507
|
+
|
|
455
508
|
return (
|
|
456
509
|
<div
|
|
457
510
|
className={styles.chromeContainer}
|
|
@@ -460,9 +513,41 @@ export default function WidgetChrome({
|
|
|
460
513
|
onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
|
|
461
514
|
onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
|
|
462
515
|
>
|
|
463
|
-
<div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined}>
|
|
516
|
+
<div ref={slotRef} className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined} data-widget-interacting={interacting || undefined}>
|
|
464
517
|
{children}
|
|
518
|
+
{gate.enabled && !interacting && (
|
|
519
|
+
<div
|
|
520
|
+
className={overlayStyles.interactOverlay}
|
|
521
|
+
onClick={handleGateClick}
|
|
522
|
+
role="button"
|
|
523
|
+
tabIndex={0}
|
|
524
|
+
aria-label={gate.label}
|
|
525
|
+
>
|
|
526
|
+
<span className={overlayStyles.interactHint}>{gate.label}</span>
|
|
527
|
+
</div>
|
|
528
|
+
)}
|
|
465
529
|
</div>
|
|
530
|
+
{!readOnly && onConnectorDragStart && (() => {
|
|
531
|
+
const connConfig = widgetType ? getConnectorConfig(widgetType) : null
|
|
532
|
+
return ['top', 'bottom', 'left', 'right']
|
|
533
|
+
.filter((a) => !connConfig || connConfig.anchors[a] !== 'unavailable')
|
|
534
|
+
.map((anchor) => {
|
|
535
|
+
const disabled = connConfig?.anchors[anchor] === 'disabled'
|
|
536
|
+
return (
|
|
537
|
+
<div
|
|
538
|
+
key={anchor}
|
|
539
|
+
className={`${styles.anchorPort} ${styles[`anchorPort${anchor[0].toUpperCase()}${anchor.slice(1)}`]} ${disabled ? styles.anchorPortDisabled : ''}`}
|
|
540
|
+
onPointerDown={disabled ? undefined : (e) => {
|
|
541
|
+
e.stopPropagation()
|
|
542
|
+
e.nativeEvent?.stopImmediatePropagation?.()
|
|
543
|
+
e.preventDefault()
|
|
544
|
+
onConnectorDragStart(widgetId, anchor, e)
|
|
545
|
+
}}
|
|
546
|
+
data-anchor={anchor}
|
|
547
|
+
/>
|
|
548
|
+
)
|
|
549
|
+
})
|
|
550
|
+
})()}
|
|
466
551
|
<div
|
|
467
552
|
className={styles.toolbar}
|
|
468
553
|
>
|