/ concept-collection / seqlab
concept-collection / seqlab
seqlab / src / App.tsx
396 lines · 13.3 KBBlameHistoryRaw
1import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2import ScriptEditor from './editor/ScriptEditor.tsx'
3import Timeline from './viewer/Timeline.tsx'
4import ReportPanel from './viewer/ReportPanel.tsx'
5import BlockInspector from './viewer/BlockInspector.tsx'
6import { runScript, type RunHandle } from './engine/runner.ts'
7import { parseSeq, SeqParseError } from './seq/parseSeq.ts'
8import { reconstruct, type Reconstructed } from './seq/reconstruct.ts'
9import type { ParsedSeq } from './seq/types.ts'
10import { EXAMPLES, findExample } from './examples/index.ts'
11import ExamplesModal from './examples/ExamplesModal.tsx'
13interface ConsoleLine {
14 kind: 'out' | 'err' | 'ok'
15 text: string
18interface LoadedSeq {
19 name: string
20 text: string
21 seq: ParsedSeq
22 rec: Reconstructed
23 source: 'run' | 'upload'
26type RunStatus = 'idle' | 'booting' | 'running'
28function loadSeqFile(name: string, text: string, source: 'run' | 'upload'): LoadedSeq {
29 const seq = parseSeq(text)
30 return { name, text, seq, rec: reconstruct(seq), source }
33function download(name: string, text: string) {
34 const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }))
35 const a = document.createElement('a')
36 a.href = url
37 a.download = name
38 a.click()
39 URL.revokeObjectURL(url)
42export default function App() {
43 const [script, setScript] = useState(() => {
44 const hashExample = readExampleFromHash()
45 return (hashExample ?? EXAMPLES[0]).source
46 })
47 const [scriptName, setScriptName] = useState(() => {
48 const hashExample = readExampleFromHash()
49 return `${(hashExample ?? EXAMPLES[0]).id}.m`
50 })
51 const [consoleLines, setConsoleLines] = useState<ConsoleLine[]>([])
52 const [runStatus, setRunStatus] = useState<RunStatus>('idle')
53 const [loaded, setLoaded] = useState<LoadedSeq[]>([])
54 const [activeSeq, setActiveSeq] = useState(0)
55 const [selectedBlock, setSelectedBlock] = useState<number | null>(null)
56 const [examplesOpen, setExamplesOpen] = useState(false)
57 const [dropActive, setDropActive] = useState(false)
58 const [leftWidth, setLeftWidth] = useState(46) // percent
59 const runRef = useRef<RunHandle | null>(null)
60 const consoleBodyRef = useRef<HTMLDivElement>(null)
61 const fileInputRef = useRef<HTMLInputElement>(null)
63 const appendConsole = useCallback((kind: ConsoleLine['kind'], text: string) => {
64 setConsoleLines((lines) => [...lines, { kind, text }])
65 }, [])
67 useEffect(() => {
68 const el = consoleBodyRef.current
69 if (el) el.scrollTop = el.scrollHeight
70 }, [consoleLines])
72 // doRun reads the current script through a ref so its identity is stable
73 // (the CodeMirror keymap captures it once).
74 const scriptRef = useRef(script)
75 scriptRef.current = script
77 const doRun = useCallback((src?: unknown) => {
78 if (runRef.current) return
79 // May be called from the Run button (MouseEvent), the editor keymap (no
80 // arg), or loadExample (explicit source string). Only a string overrides
81 // the ref, avoiding a race with the setScript state update.
82 const source = typeof src === 'string' ? src : scriptRef.current
83 setConsoleLines([])
84 setRunStatus('booting')
85 const handle = runScript(source, {
86 onOutput: (text) => {
87 setRunStatus('running')
88 appendConsole('out', text)
89 },
90 })
91 runRef.current = handle
92 handle.promise
93 .then((result) => {
94 if (result.aborted) {
95 appendConsole('err', '— run cancelled —\n')
96 return
97 }
98 if (!result.ok) {
99 appendConsole('err', (result.error ?? 'unknown error') + '\n')
100 }
101 if (result.seqFiles.length > 0) {
102 const files: LoadedSeq[] = []
103 for (const f of result.seqFiles) {
104 try {
105 files.push(loadSeqFile(f.name, f.text, 'run'))
106 } catch (err) {
107 appendConsole(
108 'err',
109 `failed to parse ${f.name}: ${err instanceof SeqParseError ? err.message : String(err)}\n`,
110 )
111 }
112 }
113 setLoaded(files)
114 setActiveSeq(0)
115 setSelectedBlock(null)
116 } else if (result.ok) {
117 appendConsole('err', 'The script finished without writing any .seq file — call seq.write(...).\n')
118 }
119 if (result.ok) {
120 appendConsole('ok', `— finished in ${(result.elapsedMs / 1000).toFixed(1)} s —\n`)
121 }
122 })
123 .catch((err) => {
124 appendConsole('err', `engine error: ${err instanceof Error ? err.message : String(err)}\n`)
125 })
126 .finally(() => {
127 runRef.current = null
128 setRunStatus('idle')
129 })
130 }, [appendConsole])
132 const doCancel = useCallback(() => {
133 runRef.current?.cancel()
134 }, [])
136 const loadExample = useCallback((id: string) => {
137 const ex = findExample(id)
138 if (!ex) return
139 // Load into the editor only; the user presses Run to execute it.
140 setScript(ex.source)
141 setScriptName(`${ex.id}.m`)
142 window.location.hash = `example/${ex.id}`
143 setExamplesOpen(false)
144 }, [])
146 const openSeqFile = useCallback(
147 (file: File) => {
148 file.text().then(
149 (text) => {
150 try {
151 const f = loadSeqFile(file.name, text, 'upload')
152 setLoaded([f])
153 setActiveSeq(0)
154 setSelectedBlock(null)
155 } catch (err) {
156 appendConsole(
157 'err',
158 `failed to parse ${file.name}: ${err instanceof Error ? err.message : String(err)}\n`,
159 )
160 }
161 },
162 () => appendConsole('err', `could not read ${file.name}\n`),
163 )
164 },
165 [appendConsole],
166 )
168 // Drag & drop anywhere
169 useEffect(() => {
170 let depth = 0
171 const onDragEnter = (e: DragEvent) => {
172 if (!e.dataTransfer?.types.includes('Files')) return
173 depth++
174 setDropActive(true)
175 }
176 const onDragLeave = () => {
177 depth = Math.max(0, depth - 1)
178 if (depth === 0) setDropActive(false)
179 }
180 const onDragOver = (e: DragEvent) => {
181 if (e.dataTransfer?.types.includes('Files')) e.preventDefault()
182 }
183 const onDrop = (e: DragEvent) => {
184 depth = 0
185 setDropActive(false)
186 const file = e.dataTransfer?.files?.[0]
187 if (file) {
188 e.preventDefault()
189 openSeqFile(file)
190 }
191 }
192 window.addEventListener('dragenter', onDragEnter)
193 window.addEventListener('dragleave', onDragLeave)
194 window.addEventListener('dragover', onDragOver)
195 window.addEventListener('drop', onDrop)
196 return () => {
197 window.removeEventListener('dragenter', onDragEnter)
198 window.removeEventListener('dragleave', onDragLeave)
199 window.removeEventListener('dragover', onDragOver)
200 window.removeEventListener('drop', onDrop)
201 }
202 }, [openSeqFile])
204 // Divider drag
205 const onDividerDown = useCallback((e: React.MouseEvent) => {
206 e.preventDefault()
207 const onMove = (ev: MouseEvent) => {
208 const pct = (ev.clientX / window.innerWidth) * 100
209 setLeftWidth(Math.min(70, Math.max(25, pct)))
210 }
211 const onUp = () => {
212 window.removeEventListener('mousemove', onMove)
213 window.removeEventListener('mouseup', onUp)
214 }
215 window.addEventListener('mousemove', onMove)
216 window.addEventListener('mouseup', onUp)
217 }, [])
219 const active = loaded[activeSeq]
220 const running = runStatus !== 'idle'
221 const consoleEmpty = consoleLines.length === 0
223 const statusLabel = useMemo(() => {
224 if (runStatus === 'booting') return 'starting engine…'
225 if (runStatus === 'running') return 'running…'
226 return null
227 }, [runStatus])
229 return (
230 <div className={`app${dropActive ? ' drop-active' : ''}`}>
231 <header className="header">
232 <div className="logo">
233 <b>seqlab</b>
234 <span className="tagline">
235 powered by{' '}
236 <a href="https://pulseq.github.io/" target="_blank" rel="noopener noreferrer">
237 Pulseq
238 </a>{' '}
239 +{' '}
240 <a href="https://numbl.org" target="_blank" rel="noopener noreferrer">
241 numbl
242 </a>
243 </span>
244 </div>
245 <div className="spacer" />
246 <button onClick={() => fileInputRef.current?.click()}>Open .seq…</button>
247 <input
248 ref={fileInputRef}
249 type="file"
250 accept=".seq,text/plain"
251 style={{ display: 'none' }}
252 onChange={(e) => {
253 const file = e.target.files?.[0]
254 if (file) openSeqFile(file)
255 e.target.value = ''
256 }}
257 />
258 </header>
260 <div className="main">
261 <div className="left" style={{ width: `${leftWidth}%` }}>
262 <div className="editor-toolbar">
263 {running ? (
264 <button className="danger" onClick={doCancel}>
265 ■ Cancel
266 </button>
267 ) : (
268 <button className="primary" onClick={doRun} title="Ctrl+Enter">
269 ▶ Run
270 </button>
271 )}
272 <button className="examples-btn" onClick={() => setExamplesOpen(true)}>
273 ☰ Examples
274 </button>
275 <span className="name">{scriptName}</span>
276 <div className="spacer" style={{ flex: 1 }} />
277 <button onClick={() => download(scriptName, scriptRef.current)}>Download .m</button>
278 </div>
279 <ScriptEditor value={script} onChange={setScript} onRun={doRun} />
280 <div className="console">
281 <div className="console-head">
282 Console
283 {statusLabel && <span>· {statusLabel}</span>}
284 </div>
285 <div className="console-body" ref={consoleBodyRef}>
286 {consoleEmpty && !running ? (
287 <span className="muted" style={{ color: 'var(--text-dim)' }}>
288 Press Run (Ctrl+Enter) to execute the script with pulseq on the numbl MATLAB
289 runtime — entirely in this tab.
290 </span>
291 ) : (
292 consoleLines.map((l, i) => (
293 <span key={i} className={l.kind === 'out' ? undefined : l.kind}>
294 {l.text}
295 </span>
296 ))
297 )}
298 </div>
299 </div>
300 </div>
302 <div className="divider" onMouseDown={onDividerDown} />
304 <div className="right">
305 {!active ? (
306 <div className="viewer-empty">
307 <h2>No sequence loaded</h2>
308 <p className="hint">
309 Run the script on the left to generate a <code>.seq</code> file, pick an example
310 from the gallery, or drop / open an existing <code>.seq</code> file to explore it.
311 </p>
312 </div>
313 ) : (
314 <>
315 {loaded.length > 1 && (
316 <div className="seq-tabs">
317 {loaded.map((f, i) => (
318 <button
319 key={f.name + i}
320 className={i === activeSeq ? 'active' : undefined}
321 onClick={() => {
322 setActiveSeq(i)
323 setSelectedBlock(null)
324 }}
325 >
326 {f.name}
327 </button>
328 ))}
329 </div>
330 )}
331 <div className="viewer-section">
332 <h3>
333 {active.name}
334 <span className="free" />
335 <span className="fine">
336 {active.source === 'run' ? 'generated by script' : 'opened file'}
337 </span>
338 <button onClick={() => download(active.name, active.text)}>Download .seq</button>
339 </h3>
340 <ReportPanel seq={active.seq} rec={active.rec} />
341 </div>
342 <div className="viewer-section">
343 <h3>
344 Timeline
345 <span className="free" />
346 <span className="fine">scroll to zoom · drag to pan · click a block · double-click resets</span>
347 </h3>
348 <Timeline
349 rec={active.rec}
350 selectedBlock={selectedBlock}
351 onSelectBlock={setSelectedBlock}
352 />
353 </div>
354 <div className="viewer-section">
355 <h3>
356 Block inspector
357 <span className="free" />
358 {selectedBlock !== null && (
359 <button onClick={() => setSelectedBlock(null)}>Close</button>
360 )}
361 </h3>
362 {selectedBlock !== null ? (
363 <BlockInspector
364 seq={active.seq}
365 rec={active.rec}
366 blockIndex={selectedBlock}
367 onNavigate={(i) =>
368 setSelectedBlock(Math.min(Math.max(i, 0), active.seq.blocks.length - 1))
369 }
370 />
371 ) : (
372 <p className="inspector-hint">
373 Click a block in the timeline above to inspect its events.
374 </p>
375 )}
376 </div>
377 </>
378 )}
379 </div>
380 </div>
382 {examplesOpen && (
383 <ExamplesModal
384 examples={EXAMPLES}
385 onPick={loadExample}
386 onClose={() => setExamplesOpen(false)}
387 />
388 )}
389 </div>
390 )
393function readExampleFromHash() {
394 const m = window.location.hash.match(/^#example\/([\w-]+)/)
395 return m ? findExample(m[1]) : undefined