@blit-sh/react 0.17.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.
Files changed (41) hide show
  1. package/dist/BlitContext.d.ts +17 -0
  2. package/dist/BlitContext.d.ts.map +1 -0
  3. package/dist/BlitContext.js +18 -0
  4. package/dist/BlitContext.js.map +1 -0
  5. package/dist/BlitTerminal.d.ts +25 -0
  6. package/dist/BlitTerminal.d.ts.map +1 -0
  7. package/dist/BlitTerminal.js +1341 -0
  8. package/dist/BlitTerminal.js.map +1 -0
  9. package/dist/hooks/index.d.ts +6 -0
  10. package/dist/hooks/index.d.ts.map +1 -0
  11. package/dist/hooks/index.js +6 -0
  12. package/dist/hooks/index.js.map +1 -0
  13. package/dist/hooks/useBlitConnection.d.ts +3 -0
  14. package/dist/hooks/useBlitConnection.d.ts.map +1 -0
  15. package/dist/hooks/useBlitConnection.js +20 -0
  16. package/dist/hooks/useBlitConnection.js.map +1 -0
  17. package/dist/hooks/useBlitSession.d.ts +4 -0
  18. package/dist/hooks/useBlitSession.d.ts.map +1 -0
  19. package/dist/hooks/useBlitSession.js +17 -0
  20. package/dist/hooks/useBlitSession.js.map +1 -0
  21. package/dist/hooks/useBlitSessions.d.ts +3 -0
  22. package/dist/hooks/useBlitSessions.d.ts.map +1 -0
  23. package/dist/hooks/useBlitSessions.js +8 -0
  24. package/dist/hooks/useBlitSessions.js.map +1 -0
  25. package/dist/hooks/useBlitWorkspace.d.ts +4 -0
  26. package/dist/hooks/useBlitWorkspace.d.ts.map +1 -0
  27. package/dist/hooks/useBlitWorkspace.js +10 -0
  28. package/dist/hooks/useBlitWorkspace.js.map +1 -0
  29. package/dist/hooks/useBlitWorkspaceConnection.d.ts +4 -0
  30. package/dist/hooks/useBlitWorkspaceConnection.d.ts.map +1 -0
  31. package/dist/hooks/useBlitWorkspaceConnection.js +8 -0
  32. package/dist/hooks/useBlitWorkspaceConnection.js.map +1 -0
  33. package/dist/index.d.ts +11 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +8 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/types.d.ts +30 -0
  38. package/dist/types.d.ts.map +1 -0
  39. package/dist/types.js +2 -0
  40. package/dist/types.js.map +1 -0
  41. package/package.json +68 -0
@@ -0,0 +1,1341 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useImperativeHandle, useRef, useState, } from "react";
3
+ import { DEFAULT_FONT, DEFAULT_FONT_SIZE, MOUSE_DOWN, MOUSE_UP, MOUSE_MOVE, measureCell, cssFontFamily, keyToBytes, encoder } from "@blit-sh/core";
4
+ import { useBlitContext, useRequiredBlitWorkspace } from "./BlitContext";
5
+ import { useBlitConnection } from "./hooks/useBlitConnection";
6
+ import { useBlitSession } from "./hooks/useBlitSession";
7
+ // ---------------------------------------------------------------------------
8
+ // Component
9
+ // ---------------------------------------------------------------------------
10
+ /**
11
+ * BlitTerminal renders a blit terminal inside a WebGL canvas.
12
+ *
13
+ * It handles WASM initialisation, server message processing, GL rendering,
14
+ * keyboard/mouse input, and dynamic resizing through a ResizeObserver.
15
+ */
16
+ export function BlitTerminal({ ref, ...props }) {
17
+ const ctx = useBlitContext();
18
+ const workspace = useRequiredBlitWorkspace();
19
+ const session = useBlitSession(props.sessionId);
20
+ const connection = useBlitConnection(session?.connectionId);
21
+ const blitConn = session
22
+ ? workspace.getConnection(session.connectionId)
23
+ : null;
24
+ if (props.sessionId !== null && (!session || !connection || !blitConn)) {
25
+ throw new Error(`Unknown blit session ${props.sessionId}`);
26
+ }
27
+ const { sessionId, fontFamily = ctx.fontFamily ?? DEFAULT_FONT, fontSize = ctx.fontSize ?? DEFAULT_FONT_SIZE, className, style, palette = ctx.palette, readOnly = false, showCursor = true, onRender, scrollbarColor = "rgba(255,255,255,0.3)", scrollbarWidth = 4, advanceRatio = ctx.advanceRatio, } = props;
28
+ // Refs for DOM elements.
29
+ const containerRef = useRef(null);
30
+ const glCanvasRef = useRef(null);
31
+ const inputRef = useRef(null);
32
+ // Refs for mutable state that must not trigger re-renders.
33
+ const viewIdRef = useRef(null);
34
+ const terminalRef = useRef(null);
35
+ const rendererRef = useRef(null);
36
+ const displayCtxRef = useRef(null);
37
+ const applyMetricsRef = useRef(() => { });
38
+ const cellRef = useRef(measureCell(fontFamily, fontSize, undefined, advanceRatio));
39
+ const rowsRef = useRef(24);
40
+ const colsRef = useRef(80);
41
+ const contentDirtyRef = useRef(true);
42
+ const lastOffsetRef = useRef(0);
43
+ /** Track WASM buffer identity to detect heap growth that invalidates vertex pointers. */
44
+ const lastWasmBufferRef = useRef(null);
45
+ const [cellVersion, setCellVersion] = useState(0);
46
+ const rafRef = useRef(0);
47
+ const renderScheduledRef = useRef(false);
48
+ const scrollOffsetRef = useRef(0);
49
+ const scrollFadeRef = useRef(0);
50
+ const scrollFadeTimerRef = useRef(null);
51
+ /** Scrollbar geometry in canvas pixels, updated each render. */
52
+ const scrollbarGeoRef = useRef(null);
53
+ const scrollDraggingRef = useRef(false);
54
+ const scrollDragOffsetRef = useRef(0);
55
+ const cursorBlinkOnRef = useRef(true);
56
+ const cursorBlinkTimerRef = useRef(null);
57
+ const paletteRef = useRef(palette);
58
+ const showCursorRef = useRef(showCursor);
59
+ showCursorRef.current = showCursor;
60
+ const selStartRef = useRef(null);
61
+ const selEndRef = useRef(null);
62
+ const selectingRef = useRef(false);
63
+ const hoveredUrlRef = useRef(null);
64
+ const predictedRef = useRef("");
65
+ const predictedFromRowRef = useRef(0);
66
+ const predictedFromColRef = useRef(0);
67
+ // React state for things the consumer might read.
68
+ const [wasmReady, setWasmReady] = useState(false);
69
+ const doRenderRef = useRef(() => { });
70
+ const scheduleRender = useCallback(() => {
71
+ if (renderScheduledRef.current)
72
+ return;
73
+ renderScheduledRef.current = true;
74
+ rafRef.current = requestAnimationFrame(() => {
75
+ renderScheduledRef.current = false;
76
+ doRenderRef.current();
77
+ });
78
+ }, []);
79
+ // Re-render when showCursor (focused state) changes.
80
+ useEffect(() => {
81
+ contentDirtyRef.current = true;
82
+ scheduleRender();
83
+ }, [showCursor, scheduleRender]);
84
+ // -----------------------------------------------------------------------
85
+ // Connection callbacks — BlitTerminal only cares about UPDATE (rendering)
86
+ // -----------------------------------------------------------------------
87
+ const status = connection?.status ?? "disconnected";
88
+ const reconcilePrediction = useCallback(() => {
89
+ const t = terminalRef.current;
90
+ if (!t || !predictedRef.current)
91
+ return;
92
+ const cr = t.cursor_row;
93
+ const cc = t.cursor_col;
94
+ if (cr !== predictedFromRowRef.current) {
95
+ predictedRef.current = "";
96
+ return;
97
+ }
98
+ const advance = cc - predictedFromColRef.current;
99
+ if (advance > 0 && advance <= predictedRef.current.length) {
100
+ predictedRef.current = predictedRef.current.slice(advance);
101
+ predictedFromColRef.current = cc;
102
+ }
103
+ else if (advance < 0 || advance > predictedRef.current.length) {
104
+ predictedRef.current = "";
105
+ }
106
+ }, []);
107
+ const applyPaletteToTerminal = useCallback((t) => {
108
+ const nextPalette = paletteRef.current;
109
+ if (!t || !nextPalette)
110
+ return;
111
+ t.set_default_colors(...nextPalette.fg, ...nextPalette.bg);
112
+ for (let i = 0; i < 16; i++)
113
+ t.set_ansi_color(i, ...nextPalette.ansi[i]);
114
+ contentDirtyRef.current = true;
115
+ scheduleRender();
116
+ }, []);
117
+ useEffect(() => {
118
+ if (!blitConn || sessionId === null)
119
+ return;
120
+ const syncReadOnlySize = (t) => {
121
+ const tr = t.rows;
122
+ const tc = t.cols;
123
+ if (tr !== rowsRef.current || tc !== colsRef.current) {
124
+ rowsRef.current = tr;
125
+ colsRef.current = tc;
126
+ }
127
+ scheduleRender();
128
+ };
129
+ const unsub = blitConn.addDirtyListener(sessionId, () => {
130
+ const t = blitConn.getTerminal(sessionId);
131
+ if (!t)
132
+ return;
133
+ if (terminalRef.current !== t) {
134
+ terminalRef.current = t;
135
+ applyPaletteToTerminal(t);
136
+ applyMetricsRef.current(t);
137
+ }
138
+ contentDirtyRef.current = true;
139
+ scheduleRender();
140
+ reconcilePrediction();
141
+ if (readOnly)
142
+ syncReadOnlySize(t);
143
+ });
144
+ // Check for a terminal that was created between render and this effect.
145
+ if (sessionId !== null && blitConn) {
146
+ const t = blitConn.getTerminal(sessionId);
147
+ if (t) {
148
+ if (terminalRef.current !== t) {
149
+ terminalRef.current = t;
150
+ applyPaletteToTerminal(t);
151
+ applyMetricsRef.current(t);
152
+ }
153
+ contentDirtyRef.current = true;
154
+ scheduleRender();
155
+ if (readOnly)
156
+ syncReadOnlySize(t);
157
+ }
158
+ }
159
+ return () => {
160
+ unsub();
161
+ };
162
+ }, [
163
+ sessionId,
164
+ readOnly,
165
+ blitConn,
166
+ reconcilePrediction,
167
+ applyPaletteToTerminal,
168
+ ]);
169
+ const sendInput = useCallback((id, data) => {
170
+ workspace.sendInput(id, data);
171
+ }, [workspace]);
172
+ const sendScroll = useCallback((id, offset) => {
173
+ workspace.scrollSession(id, offset);
174
+ }, [workspace]);
175
+ // -----------------------------------------------------------------------
176
+ // Imperative handle
177
+ // -----------------------------------------------------------------------
178
+ useImperativeHandle(ref, () => ({
179
+ get terminal() {
180
+ return terminalRef.current;
181
+ },
182
+ get rows() {
183
+ return rowsRef.current;
184
+ },
185
+ get cols() {
186
+ return colsRef.current;
187
+ },
188
+ status,
189
+ focus() {
190
+ inputRef.current?.focus();
191
+ },
192
+ }), [status]);
193
+ // -----------------------------------------------------------------------
194
+ // WASM init
195
+ // -----------------------------------------------------------------------
196
+ useEffect(() => {
197
+ if (!blitConn) {
198
+ setWasmReady(false);
199
+ return;
200
+ }
201
+ const unsub = blitConn.onReady(() => setWasmReady(true));
202
+ if (blitConn.isReady())
203
+ setWasmReady(true);
204
+ return unsub;
205
+ }, [blitConn]);
206
+ // -----------------------------------------------------------------------
207
+ // Cell measurement (re-measure when font or DPR changes)
208
+ // -----------------------------------------------------------------------
209
+ // Chrome and Firefox update devicePixelRatio on zoom; Safari doesn't.
210
+ // Detect zoom via outerWidth/innerWidth on Safari only — on other browsers
211
+ // outerWidth includes browser chrome, making the ratio unreliable.
212
+ const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
213
+ function effectiveDpr() {
214
+ const base = window.devicePixelRatio || 1;
215
+ if (isSafari && window.outerWidth && window.innerWidth) {
216
+ const zoom = window.outerWidth / window.innerWidth;
217
+ if (zoom > 0.25 && zoom < 8)
218
+ return Math.round(base * zoom * 100) / 100;
219
+ }
220
+ return base;
221
+ }
222
+ const [dpr, setDpr] = useState(effectiveDpr);
223
+ const dprRef = useRef(dpr);
224
+ dprRef.current = dpr;
225
+ applyMetricsRef.current = (t) => {
226
+ const cell = cellRef.current;
227
+ t.set_cell_size(cell.pw, cell.ph);
228
+ t.set_font_family(fontFamily);
229
+ t.set_font_size(fontSize * dpr);
230
+ t.invalidate_render_cache();
231
+ };
232
+ useEffect(() => {
233
+ const check = () => {
234
+ const next = effectiveDpr();
235
+ if (next !== dprRef.current)
236
+ setDpr(next);
237
+ };
238
+ let mq = null;
239
+ if (typeof window.matchMedia === "function") {
240
+ mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
241
+ mq.addEventListener("change", check);
242
+ }
243
+ window.addEventListener("resize", check);
244
+ return () => {
245
+ mq?.removeEventListener("change", check);
246
+ window.removeEventListener("resize", check);
247
+ };
248
+ }, []);
249
+ useEffect(() => {
250
+ if (!blitConn)
251
+ return;
252
+ const rasterFontSize = fontSize * dpr;
253
+ const apply = (forceInvalidate = false) => {
254
+ const cell = measureCell(fontFamily, fontSize, dpr, advanceRatio);
255
+ const changed = cell.pw !== cellRef.current.pw || cell.ph !== cellRef.current.ph;
256
+ const shouldInvalidate = forceInvalidate || changed;
257
+ cellRef.current = cell;
258
+ if (!readOnly) {
259
+ const t = terminalRef.current;
260
+ if (t) {
261
+ t.set_cell_size(cell.pw, cell.ph);
262
+ t.set_font_family(fontFamily);
263
+ t.set_font_size(rasterFontSize);
264
+ if (shouldInvalidate)
265
+ t.invalidate_render_cache();
266
+ }
267
+ blitConn.setCellSize(cell.pw, cell.ph);
268
+ blitConn.setFontFamily(fontFamily);
269
+ blitConn.setFontSize(rasterFontSize);
270
+ }
271
+ if (shouldInvalidate) {
272
+ contentDirtyRef.current = true;
273
+ scheduleRender();
274
+ }
275
+ if (changed) {
276
+ setCellVersion((v) => v + 1);
277
+ }
278
+ };
279
+ // Always apply on effect run (font/size/dpr changed).
280
+ contentDirtyRef.current = true;
281
+ apply(true);
282
+ scheduleRender();
283
+ // Re-measure when fonts finish loading. Even if metrics stay the same,
284
+ // the glyph atlas may need a rebuild because the actual raster changed.
285
+ const onFontsLoaded = () => apply(true);
286
+ document.fonts?.addEventListener("loadingdone", onFontsLoaded);
287
+ // If fonts already loaded while we were setting up, re-apply now.
288
+ if (document.fonts?.status === "loaded")
289
+ apply(true);
290
+ return () => document.fonts?.removeEventListener("loadingdone", onFontsLoaded);
291
+ }, [
292
+ fontFamily,
293
+ fontSize,
294
+ dpr,
295
+ blitConn,
296
+ readOnly,
297
+ scheduleRender,
298
+ advanceRatio,
299
+ ]);
300
+ // -----------------------------------------------------------------------
301
+ // Cursor blink timer
302
+ // -----------------------------------------------------------------------
303
+ useEffect(() => {
304
+ if (readOnly)
305
+ return;
306
+ cursorBlinkOnRef.current = true;
307
+ const timer = setInterval(() => {
308
+ cursorBlinkOnRef.current = !cursorBlinkOnRef.current;
309
+ scheduleRender();
310
+ }, 530);
311
+ cursorBlinkTimerRef.current = timer;
312
+ return () => {
313
+ clearInterval(timer);
314
+ cursorBlinkTimerRef.current = null;
315
+ };
316
+ }, [readOnly, scheduleRender]);
317
+ // -----------------------------------------------------------------------
318
+ // GL renderer lifecycle
319
+ // -----------------------------------------------------------------------
320
+ useEffect(() => {
321
+ if (!blitConn)
322
+ return;
323
+ const shared = blitConn.getSharedRenderer();
324
+ if (shared)
325
+ rendererRef.current = shared.renderer;
326
+ }, [blitConn]);
327
+ // -----------------------------------------------------------------------
328
+ // Terminal instance lifecycle
329
+ // -----------------------------------------------------------------------
330
+ useEffect(() => {
331
+ if (!blitConn) {
332
+ terminalRef.current = null;
333
+ return;
334
+ }
335
+ if (sessionId !== null) {
336
+ blitConn.retain(sessionId);
337
+ const t = blitConn.getTerminal(sessionId);
338
+ if (t) {
339
+ terminalRef.current = t;
340
+ applyPaletteToTerminal(t);
341
+ if (!readOnly) {
342
+ const cell = cellRef.current;
343
+ t.set_cell_size(cell.pw, cell.ph);
344
+ t.set_font_family(fontFamily);
345
+ t.set_font_size(fontSize * dpr);
346
+ }
347
+ contentDirtyRef.current = true;
348
+ scheduleRender();
349
+ }
350
+ }
351
+ else {
352
+ terminalRef.current = null;
353
+ }
354
+ return () => {
355
+ terminalRef.current = null;
356
+ if (sessionId !== null && blitConn)
357
+ blitConn.release(sessionId);
358
+ };
359
+ }, [
360
+ wasmReady,
361
+ sessionId,
362
+ blitConn,
363
+ fontFamily,
364
+ fontSize,
365
+ readOnly,
366
+ applyPaletteToTerminal,
367
+ ]);
368
+ // -----------------------------------------------------------------------
369
+ // Palette changes
370
+ // -----------------------------------------------------------------------
371
+ useEffect(() => {
372
+ paletteRef.current = palette;
373
+ applyPaletteToTerminal(terminalRef.current);
374
+ }, [palette, applyPaletteToTerminal]);
375
+ // -----------------------------------------------------------------------
376
+ // Resize observer
377
+ // -----------------------------------------------------------------------
378
+ useEffect(() => {
379
+ const container = containerRef.current;
380
+ if (!container || readOnly)
381
+ return;
382
+ const handleResize = () => {
383
+ const cell = cellRef.current;
384
+ const w = container.clientWidth;
385
+ const h = container.clientHeight;
386
+ const cols = Math.max(1, Math.floor(w / cell.w));
387
+ const rows = Math.max(1, Math.floor(h / cell.h));
388
+ const sizeChanged = cols !== colsRef.current || rows !== rowsRef.current;
389
+ if (sizeChanged) {
390
+ rowsRef.current = rows;
391
+ colsRef.current = cols;
392
+ if (sessionId !== null && blitConn && viewIdRef.current) {
393
+ blitConn.setViewSize(sessionId, viewIdRef.current, rows, cols);
394
+ }
395
+ }
396
+ // Always schedule — DPR may have changed even if cols/rows didn't.
397
+ contentDirtyRef.current = true;
398
+ scheduleRender();
399
+ };
400
+ // Allocate a view ID for multi-pane size tracking.
401
+ if (!viewIdRef.current && blitConn) {
402
+ viewIdRef.current = blitConn.allocViewId();
403
+ }
404
+ const observer = new ResizeObserver(handleResize);
405
+ observer.observe(container);
406
+ window.addEventListener("resize", handleResize);
407
+ handleResize();
408
+ return () => {
409
+ observer.disconnect();
410
+ window.removeEventListener("resize", handleResize);
411
+ if (sessionId !== null && blitConn && viewIdRef.current) {
412
+ blitConn.removeView(sessionId, viewIdRef.current);
413
+ }
414
+ };
415
+ }, [sessionId, readOnly, blitConn, fontFamily, fontSize, dpr, cellVersion]);
416
+ // Re-send dimensions when the connection becomes ready.
417
+ useEffect(() => {
418
+ if (status === "connected" &&
419
+ sessionId !== null &&
420
+ !readOnly &&
421
+ blitConn &&
422
+ viewIdRef.current) {
423
+ const rows = rowsRef.current;
424
+ const cols = colsRef.current;
425
+ if (rows > 0 && cols > 0) {
426
+ blitConn.setViewSize(sessionId, viewIdRef.current, rows, cols);
427
+ }
428
+ }
429
+ }, [status, sessionId, readOnly, blitConn]);
430
+ // -----------------------------------------------------------------------
431
+ // Render (demand-driven — only rAF when something changed)
432
+ // -----------------------------------------------------------------------
433
+ useEffect(() => {
434
+ if (!blitConn)
435
+ return;
436
+ doRenderRef.current = () => {
437
+ const t0 = performance.now();
438
+ if (!rendererRef.current?.supported) {
439
+ const shared = blitConn.getSharedRenderer();
440
+ if (shared)
441
+ rendererRef.current = shared.renderer;
442
+ if (!rendererRef.current?.supported) {
443
+ if (!readOnly)
444
+ blitConn.noteFrameRendered();
445
+ return;
446
+ }
447
+ }
448
+ if (!terminalRef.current) {
449
+ if (!readOnly)
450
+ blitConn.noteFrameRendered();
451
+ return;
452
+ }
453
+ {
454
+ const t = terminalRef.current;
455
+ const cell = cellRef.current;
456
+ const renderer = rendererRef.current;
457
+ const termCols = t.cols;
458
+ const termRows = t.rows;
459
+ const pw = termCols * cell.pw;
460
+ const ph = termRows * cell.ph;
461
+ if (!readOnly) {
462
+ const cssW = `${termCols * cell.w}px`;
463
+ const cssH = `${termRows * cell.h}px`;
464
+ const glCanvas = glCanvasRef.current;
465
+ if (glCanvas) {
466
+ if (glCanvas.style.width !== cssW)
467
+ glCanvas.style.width = cssW;
468
+ if (glCanvas.style.height !== cssH)
469
+ glCanvas.style.height = cssH;
470
+ }
471
+ }
472
+ const mem = blitConn.wasmMemory();
473
+ // Detect WASM heap growth: if the underlying ArrayBuffer changed,
474
+ // vertex pointers from the previous prepare_render_ops are invalid.
475
+ if (mem.buffer !== lastWasmBufferRef.current) {
476
+ lastWasmBufferRef.current = mem.buffer;
477
+ contentDirtyRef.current = true;
478
+ }
479
+ {
480
+ const gridH = t.rows * cell.ph;
481
+ const gridW = t.cols * cell.pw;
482
+ const xOff = Math.max(0, Math.floor((pw - gridW) / 2));
483
+ const yOff = Math.max(0, Math.floor((ph - gridH) / 2));
484
+ const combined = xOff * 65536 + yOff;
485
+ if (combined !== lastOffsetRef.current) {
486
+ lastOffsetRef.current = combined;
487
+ t.set_render_offset(xOff, yOff);
488
+ contentDirtyRef.current = true;
489
+ }
490
+ }
491
+ if (contentDirtyRef.current) {
492
+ contentDirtyRef.current = false;
493
+ t.prepare_render_ops();
494
+ }
495
+ const bgVerts = new Float32Array(mem.buffer, t.bg_verts_ptr(), t.bg_verts_len());
496
+ const glyphVerts = new Float32Array(mem.buffer, t.glyph_verts_ptr(), t.glyph_verts_len());
497
+ renderer.resize(pw, ph);
498
+ renderer.render(bgVerts, glyphVerts, t.glyph_atlas_canvas(), t.glyph_atlas_version(), t.cursor_visible(), t.cursor_col, t.cursor_row, t.cursor_style(), cursorBlinkOnRef.current, cell, paletteRef.current?.bg ?? [0, 0, 0], showCursorRef.current);
499
+ // Copy GL to display canvas, then draw overlay content on top.
500
+ const shared = blitConn.getSharedRenderer();
501
+ const displayCanvas = glCanvasRef.current;
502
+ if (shared && displayCanvas) {
503
+ if (displayCanvas.width !== pw) {
504
+ displayCanvas.width = pw;
505
+ displayCtxRef.current = null;
506
+ }
507
+ if (displayCanvas.height !== ph) {
508
+ displayCanvas.height = ph;
509
+ displayCtxRef.current = null;
510
+ }
511
+ if (!displayCtxRef.current) {
512
+ displayCtxRef.current = displayCanvas.getContext("2d");
513
+ // Disable any implicit DPR scaling — we manage pixels ourselves.
514
+ displayCtxRef.current?.resetTransform();
515
+ }
516
+ const ctx = displayCtxRef.current;
517
+ if (ctx) {
518
+ ctx.drawImage(shared.canvas, 0, 0, pw, ph, 0, 0, pw, ph);
519
+ // Selection highlight
520
+ const ss = selStartRef.current;
521
+ const se = selEndRef.current;
522
+ if (ss && se) {
523
+ const curScroll = scrollOffsetRef.current;
524
+ const rows = rowsRef.current;
525
+ const toViewRow = (p) => rows - 1 - p.tailOffset + curScroll;
526
+ let sr = toViewRow(ss), sc = ss.col;
527
+ let er = toViewRow(se), ec = se.col;
528
+ if (sr > er || (sr === er && sc > ec)) {
529
+ [sr, sc, er, ec] = [er, ec, sr, sc];
530
+ }
531
+ const r0 = Math.max(0, sr);
532
+ const r1 = Math.min(rows - 1, er);
533
+ ctx.fillStyle = "rgba(100,150,255,0.3)";
534
+ for (let r = r0; r <= r1; r++) {
535
+ const c0 = r === sr ? sc : 0;
536
+ const c1 = r === er ? ec : colsRef.current - 1;
537
+ ctx.fillRect(c0 * cell.pw, r * cell.ph, (c1 - c0 + 1) * cell.pw, cell.ph);
538
+ }
539
+ }
540
+ // URL hover underline
541
+ const hurl = hoveredUrlRef.current;
542
+ if (hurl) {
543
+ const [fgR, fgG, fgB] = paletteRef.current?.fg ?? [204, 204, 204];
544
+ ctx.strokeStyle = `rgba(${fgR},${fgG},${fgB},0.6)`;
545
+ ctx.lineWidth = Math.max(1, Math.round(cell.ph * 0.06));
546
+ const y = hurl.row * cell.ph + cell.ph - ctx.lineWidth;
547
+ ctx.beginPath();
548
+ ctx.moveTo(hurl.startCol * cell.pw, y);
549
+ ctx.lineTo((hurl.endCol + 1) * cell.pw, y);
550
+ ctx.stroke();
551
+ }
552
+ // Overflow text (emoji / wide Unicode)
553
+ const overflowCount = t.overflow_text_count();
554
+ if (overflowCount > 0) {
555
+ const cw = cell.pw;
556
+ const ch = cell.ph;
557
+ const scale = 0.85;
558
+ const scaledH = ch * scale;
559
+ const fSize = Math.max(1, Math.round(scaledH));
560
+ ctx.font = `${fSize}px ${cssFontFamily(fontFamily)}`;
561
+ ctx.textBaseline = "bottom";
562
+ const [fgR, fgG, fgB] = paletteRef.current?.fg ?? [204, 204, 204];
563
+ ctx.fillStyle = `#${fgR.toString(16).padStart(2, "0")}${fgG.toString(16).padStart(2, "0")}${fgB.toString(16).padStart(2, "0")}`;
564
+ for (let i = 0; i < overflowCount; i++) {
565
+ const op = t.overflow_text_op(i);
566
+ if (!op)
567
+ continue;
568
+ const [row, col, colSpan, text] = op;
569
+ const x = col * cw;
570
+ const y = row * ch;
571
+ const w = colSpan * cw;
572
+ const padX = (w - w * scale) / 2;
573
+ const padY = (ch - scaledH) / 2;
574
+ ctx.save();
575
+ ctx.beginPath();
576
+ ctx.rect(x, y, w, ch);
577
+ ctx.clip();
578
+ ctx.fillText(text, x + padX, y + padY + scaledH);
579
+ ctx.restore();
580
+ }
581
+ }
582
+ // Predicted echo
583
+ if (!readOnly && predictedRef.current) {
584
+ const t2 = terminalRef.current;
585
+ if (t2 && t2.echo()) {
586
+ const cw = cell.pw;
587
+ const ch = cell.ph;
588
+ const [fR, fG, fB] = paletteRef.current?.fg ?? [204, 204, 204];
589
+ ctx.fillStyle = `rgba(${fR},${fG},${fB},0.5)`;
590
+ const fSize = Math.max(1, Math.round(ch * 0.85));
591
+ ctx.font = `${fSize}px ${cssFontFamily(fontFamily)}`;
592
+ ctx.textBaseline = "bottom";
593
+ const pred = predictedRef.current;
594
+ const cc = t2.cursor_col;
595
+ const cr = t2.cursor_row;
596
+ for (let i = 0; i < pred.length && cc + i < colsRef.current; i++) {
597
+ ctx.fillText(pred[i], (cc + i) * cw, cr * ch + ch);
598
+ }
599
+ }
600
+ }
601
+ // Scrollbar — always compute geometry for hit testing
602
+ {
603
+ const t2 = terminalRef.current;
604
+ if (t2) {
605
+ const totalLines = t2.scrollback_lines() + rowsRef.current;
606
+ const viewportRows = rowsRef.current;
607
+ if (totalLines > viewportRows) {
608
+ const ch = cell.ph;
609
+ const canvasH = viewportRows * ch;
610
+ const barW = scrollbarWidth;
611
+ const barH = Math.max(barW, (viewportRows / totalLines) * canvasH);
612
+ const maxScroll = totalLines - viewportRows;
613
+ const scrollFraction = Math.min(scrollOffsetRef.current, maxScroll) / maxScroll;
614
+ const barY = (1 - scrollFraction) * (canvasH - barH);
615
+ const barX = colsRef.current * cell.pw - barW - 2;
616
+ scrollbarGeoRef.current = {
617
+ barX,
618
+ barY,
619
+ barW,
620
+ barH,
621
+ canvasH,
622
+ totalLines,
623
+ viewportRows,
624
+ };
625
+ const show = scrollFadeRef.current > 0 ||
626
+ scrollDraggingRef.current ||
627
+ scrollOffsetRef.current > 0;
628
+ if (show) {
629
+ ctx.fillStyle = scrollbarColor;
630
+ ctx.beginPath();
631
+ ctx.roundRect(barX, barY, barW, barH, barW / 2);
632
+ ctx.fill();
633
+ }
634
+ }
635
+ else {
636
+ scrollbarGeoRef.current = null;
637
+ }
638
+ }
639
+ }
640
+ }
641
+ }
642
+ if (!readOnly) {
643
+ blitConn.noteFrameRendered();
644
+ }
645
+ onRender?.(performance.now() - t0);
646
+ }
647
+ };
648
+ // Initial render.
649
+ scheduleRender();
650
+ return () => {
651
+ cancelAnimationFrame(rafRef.current);
652
+ renderScheduledRef.current = false;
653
+ };
654
+ }, [fontFamily, fontSize, dpr, readOnly, scheduleRender, blitConn]);
655
+ // -----------------------------------------------------------------------
656
+ // Keyboard input
657
+ // -----------------------------------------------------------------------
658
+ useEffect(() => {
659
+ const input = inputRef.current;
660
+ if (!input || readOnly)
661
+ return;
662
+ const handleKeyDown = (e) => {
663
+ if (e.defaultPrevented)
664
+ return;
665
+ if (sessionId === null || status !== "connected")
666
+ return;
667
+ if (e.isComposing)
668
+ return;
669
+ if (e.key === "Dead")
670
+ return;
671
+ // Shift+PageUp/PageDown: scroll the scrollback
672
+ if (e.shiftKey && (e.key === "PageUp" || e.key === "PageDown")) {
673
+ const t2 = terminalRef.current;
674
+ const maxScroll = t2 ? t2.scrollback_lines() : 0;
675
+ if (maxScroll > 0 || scrollOffsetRef.current > 0) {
676
+ e.preventDefault();
677
+ const delta = e.key === "PageUp" ? rowsRef.current : -rowsRef.current;
678
+ scrollOffsetRef.current = Math.max(0, Math.min(maxScroll, scrollOffsetRef.current + delta));
679
+ sendScroll(sessionId, scrollOffsetRef.current);
680
+ scrollFadeRef.current = 1;
681
+ if (scrollFadeTimerRef.current)
682
+ clearTimeout(scrollFadeTimerRef.current);
683
+ scrollFadeTimerRef.current = setTimeout(() => {
684
+ scrollFadeRef.current = 0;
685
+ scheduleRender();
686
+ }, 1000);
687
+ scheduleRender();
688
+ }
689
+ return;
690
+ }
691
+ // Shift+Home/End: jump to top/bottom of scrollback
692
+ if (e.shiftKey && (e.key === "Home" || e.key === "End")) {
693
+ const t2 = terminalRef.current;
694
+ const maxScroll = t2 ? t2.scrollback_lines() : 0;
695
+ if (maxScroll > 0 || scrollOffsetRef.current > 0) {
696
+ e.preventDefault();
697
+ scrollOffsetRef.current = e.key === "Home" ? maxScroll : 0;
698
+ sendScroll(sessionId, scrollOffsetRef.current);
699
+ scrollFadeRef.current = 1;
700
+ if (scrollFadeTimerRef.current)
701
+ clearTimeout(scrollFadeTimerRef.current);
702
+ scrollFadeTimerRef.current = setTimeout(() => {
703
+ scrollFadeRef.current = 0;
704
+ scheduleRender();
705
+ }, 1000);
706
+ scheduleRender();
707
+ }
708
+ return;
709
+ }
710
+ const t = terminalRef.current;
711
+ const appCursor = t ? t.app_cursor() : false;
712
+ const bytes = keyToBytes(e, appCursor);
713
+ if (bytes) {
714
+ e.preventDefault();
715
+ if (scrollOffsetRef.current > 0) {
716
+ scrollOffsetRef.current = 0;
717
+ sendScroll(sessionId, 0);
718
+ }
719
+ if (t &&
720
+ t.echo() &&
721
+ e.key.length === 1 &&
722
+ !e.ctrlKey &&
723
+ !e.metaKey &&
724
+ !e.altKey) {
725
+ if (!predictedRef.current) {
726
+ predictedFromRowRef.current = t.cursor_row;
727
+ predictedFromColRef.current = t.cursor_col;
728
+ }
729
+ predictedRef.current += e.key;
730
+ scheduleRender();
731
+ }
732
+ else {
733
+ predictedRef.current = "";
734
+ }
735
+ sendInput(sessionId, bytes);
736
+ }
737
+ };
738
+ const handleCompositionEnd = (e) => {
739
+ if (e.data && sessionId !== null && status === "connected") {
740
+ sendInput(sessionId, encoder.encode(e.data));
741
+ }
742
+ input.value = "";
743
+ };
744
+ const handleInput = (e) => {
745
+ const inputEvent = e;
746
+ if (inputEvent.isComposing) {
747
+ if (inputEvent.inputType === "deleteContentBackward" &&
748
+ !input.value &&
749
+ sessionId !== null &&
750
+ status === "connected") {
751
+ sendInput(sessionId, new Uint8Array([0x7f]));
752
+ }
753
+ return;
754
+ }
755
+ if (inputEvent.inputType === "deleteContentBackward" && !input.value) {
756
+ if (sessionId !== null && status === "connected") {
757
+ sendInput(sessionId, new Uint8Array([0x7f]));
758
+ }
759
+ }
760
+ else if (input.value && sessionId !== null && status === "connected") {
761
+ sendInput(sessionId, encoder.encode(input.value.replace(/\n/g, "\r")));
762
+ }
763
+ input.value = "";
764
+ };
765
+ input.addEventListener("keydown", handleKeyDown);
766
+ input.addEventListener("compositionend", handleCompositionEnd);
767
+ input.addEventListener("input", handleInput);
768
+ return () => {
769
+ input.removeEventListener("keydown", handleKeyDown);
770
+ input.removeEventListener("compositionend", handleCompositionEnd);
771
+ input.removeEventListener("input", handleInput);
772
+ };
773
+ }, [sessionId, status, readOnly, sendInput, sendScroll]);
774
+ // -----------------------------------------------------------------------
775
+ // Wheel scroll (works on unfocused panes too)
776
+ // -----------------------------------------------------------------------
777
+ useEffect(() => {
778
+ const container = containerRef.current;
779
+ if (!container || readOnly)
780
+ return;
781
+ const handleWheel = (e) => {
782
+ const t = terminalRef.current;
783
+ if (t && t.mouse_mode() > 0 && !e.shiftKey) {
784
+ // Mouse mode: handled by the mouse input effect below.
785
+ return;
786
+ }
787
+ if (sessionId !== null && status === "connected") {
788
+ const maxScroll = t ? t.scrollback_lines() : 0;
789
+ if (maxScroll === 0 && scrollOffsetRef.current === 0)
790
+ return;
791
+ e.preventDefault();
792
+ const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
793
+ const lines = Math.round(-delta / 20) || (delta > 0 ? -3 : 3);
794
+ scrollOffsetRef.current = Math.max(0, Math.min(maxScroll, scrollOffsetRef.current + lines));
795
+ sendScroll(sessionId, scrollOffsetRef.current);
796
+ if (scrollOffsetRef.current > 0) {
797
+ scrollFadeRef.current = 1;
798
+ if (scrollFadeTimerRef.current)
799
+ clearTimeout(scrollFadeTimerRef.current);
800
+ scrollFadeTimerRef.current = setTimeout(() => {
801
+ scrollFadeRef.current = 0;
802
+ scheduleRender();
803
+ }, 1000);
804
+ }
805
+ scheduleRender();
806
+ }
807
+ };
808
+ container.addEventListener("wheel", handleWheel, { passive: false });
809
+ return () => container.removeEventListener("wheel", handleWheel);
810
+ }, [sessionId, status, readOnly, sendScroll, scheduleRender]);
811
+ // -----------------------------------------------------------------------
812
+ // Mouse input
813
+ // -----------------------------------------------------------------------
814
+ useEffect(() => {
815
+ const canvas = glCanvasRef.current;
816
+ if (!canvas || readOnly || !blitConn)
817
+ return;
818
+ function mouseToCell(e) {
819
+ const rect = canvas.getBoundingClientRect();
820
+ const cell = cellRef.current;
821
+ return {
822
+ row: Math.min(Math.max(Math.floor((e.clientY - rect.top) / cell.h), 0), rowsRef.current - 1),
823
+ col: Math.min(Math.max(Math.floor((e.clientX - rect.left) / cell.w), 0), colsRef.current - 1),
824
+ };
825
+ }
826
+ // --- Scrollbar interaction helpers ---
827
+ const SCROLLBAR_HIT_PX = 20; // CSS px hit zone from right edge
828
+ function canvasYFromEvent(e) {
829
+ const rect = canvas.getBoundingClientRect();
830
+ const dpr = cellRef.current.pw / cellRef.current.w;
831
+ return (e.clientY - rect.top) * dpr;
832
+ }
833
+ function isNearScrollbar(e) {
834
+ const rect = canvas.getBoundingClientRect();
835
+ return e.clientX >= rect.right - SCROLLBAR_HIT_PX;
836
+ }
837
+ function scrollToCanvasY(y) {
838
+ const geo = scrollbarGeoRef.current;
839
+ if (!geo || sessionId === null || status !== "connected")
840
+ return;
841
+ const fraction = 1 - y / (geo.canvasH - geo.barH);
842
+ const maxScroll = geo.totalLines - geo.viewportRows;
843
+ const offset = Math.round(Math.max(0, Math.min(maxScroll, fraction * maxScroll)));
844
+ scrollOffsetRef.current = offset;
845
+ sendScroll(sessionId, offset);
846
+ scrollFadeRef.current = 1;
847
+ scheduleRender();
848
+ }
849
+ /** Send a structured mouse event to the server (C2S_MOUSE).
850
+ * The server generates the correct escape sequence based on
851
+ * the terminal's current mouse mode, encoding, and cooked-mode state. */
852
+ function sendMouseEvent(type, e, button) {
853
+ if (sessionId === null || status !== "connected")
854
+ return false;
855
+ // Quick client-side check to avoid unnecessary messages.
856
+ // The server does the authoritative check.
857
+ const t = terminalRef.current;
858
+ if (t && t.mouse_mode() === 0)
859
+ return false;
860
+ const pos = mouseToCell(e);
861
+ const typeCode = type === "down" ? MOUSE_DOWN : type === "up" ? MOUSE_UP : MOUSE_MOVE;
862
+ workspace.sendMouse(sessionId, typeCode, button, pos.col, pos.row);
863
+ return true;
864
+ }
865
+ let selecting = false;
866
+ let selGranularity = 1;
867
+ let selAnchorStart = null;
868
+ let selAnchorEnd = null;
869
+ function cellToSel(cell) {
870
+ return {
871
+ row: cell.row,
872
+ col: cell.col,
873
+ tailOffset: scrollOffsetRef.current + (rowsRef.current - 1 - cell.row),
874
+ };
875
+ }
876
+ let autoScrollTimer = null;
877
+ let autoScrollDir = 0;
878
+ const AUTO_SCROLL_INTERVAL_MS = 50;
879
+ const AUTO_SCROLL_LINES = 3;
880
+ function stopAutoScroll() {
881
+ if (autoScrollTimer !== null) {
882
+ clearInterval(autoScrollTimer);
883
+ autoScrollTimer = null;
884
+ }
885
+ autoScrollDir = 0;
886
+ }
887
+ function startAutoScroll(dir) {
888
+ if (autoScrollDir === dir && autoScrollTimer !== null)
889
+ return;
890
+ stopAutoScroll();
891
+ autoScrollDir = dir;
892
+ autoScrollTimer = setInterval(() => {
893
+ if (!selecting || sessionId === null || status !== "connected") {
894
+ stopAutoScroll();
895
+ return;
896
+ }
897
+ const t = terminalRef.current;
898
+ if (!t)
899
+ return;
900
+ const maxScroll = t.scrollback_lines();
901
+ const prev = scrollOffsetRef.current;
902
+ const next = Math.max(0, Math.min(maxScroll, prev + dir * AUTO_SCROLL_LINES));
903
+ if (next === prev)
904
+ return;
905
+ scrollOffsetRef.current = next;
906
+ sendScroll(sessionId, next);
907
+ scrollFadeRef.current = 1;
908
+ if (scrollFadeTimerRef.current)
909
+ clearTimeout(scrollFadeTimerRef.current);
910
+ scrollFadeTimerRef.current = setTimeout(() => {
911
+ scrollFadeRef.current = 0;
912
+ scheduleRender();
913
+ }, 1000);
914
+ const edgeRow = dir === 1 ? 0 : rowsRef.current - 1;
915
+ const edgeCol = dir === 1 ? 0 : colsRef.current - 1;
916
+ const edgeSel = cellToSel({ row: edgeRow, col: edgeCol });
917
+ if (selGranularity >= 2 && selAnchorStart && selAnchorEnd) {
918
+ const { start: dragStart, end: dragEnd } = applyGranularitySel(edgeSel);
919
+ const dragBefore = selPosBefore(dragStart, selAnchorStart);
920
+ if (dragBefore) {
921
+ selStartRef.current = dragStart;
922
+ selEndRef.current = selAnchorEnd;
923
+ }
924
+ else {
925
+ selStartRef.current = selAnchorStart;
926
+ selEndRef.current = dragEnd;
927
+ }
928
+ }
929
+ else {
930
+ selEndRef.current = edgeSel;
931
+ }
932
+ drawSelection();
933
+ }, AUTO_SCROLL_INTERVAL_MS);
934
+ }
935
+ function clearSelection() {
936
+ selStartRef.current = selEndRef.current = null;
937
+ scheduleRender();
938
+ }
939
+ function drawSelection() {
940
+ scheduleRender();
941
+ }
942
+ function getRowText(row) {
943
+ const t = terminalRef.current;
944
+ if (!t)
945
+ return "";
946
+ return t.get_text(row, 0, row, colsRef.current - 1);
947
+ }
948
+ const WORD_CHARS = /[A-Za-z0-9_\-./~:@]/;
949
+ function wordBoundsAt(row, col) {
950
+ const text = getRowText(row);
951
+ if (col >= text.length || !WORD_CHARS.test(text[col])) {
952
+ return { start: col, end: col };
953
+ }
954
+ let start = col;
955
+ while (start > 0 && WORD_CHARS.test(text[start - 1]))
956
+ start--;
957
+ let end = col;
958
+ while (end < text.length - 1 && WORD_CHARS.test(text[end + 1]))
959
+ end++;
960
+ return { start, end };
961
+ }
962
+ function isWrapped(row) {
963
+ const t = terminalRef.current;
964
+ return t ? t.is_wrapped(row) : false;
965
+ }
966
+ function logicalLineRange(row) {
967
+ const maxRow = rowsRef.current - 1;
968
+ let startRow = row;
969
+ while (startRow > 0 && isWrapped(startRow - 1))
970
+ startRow--;
971
+ let endRow = row;
972
+ while (endRow < maxRow && isWrapped(endRow))
973
+ endRow++;
974
+ return { startRow, endRow };
975
+ }
976
+ function applyGranularity(cell) {
977
+ if (selGranularity === 3) {
978
+ const { startRow, endRow } = logicalLineRange(cell.row);
979
+ return {
980
+ start: { row: startRow, col: 0 },
981
+ end: { row: endRow, col: colsRef.current - 1 },
982
+ };
983
+ }
984
+ if (selGranularity === 2) {
985
+ const wb = wordBoundsAt(cell.row, cell.col);
986
+ return {
987
+ start: { row: cell.row, col: wb.start },
988
+ end: { row: cell.row, col: wb.end },
989
+ };
990
+ }
991
+ return { start: cell, end: cell };
992
+ }
993
+ function applyGranularitySel(pos) {
994
+ const curScroll = scrollOffsetRef.current;
995
+ const viewRow = rowsRef.current - 1 - pos.tailOffset + curScroll;
996
+ const cell = { row: viewRow, col: pos.col };
997
+ const { start, end } = applyGranularity(cell);
998
+ return {
999
+ start: { ...start, tailOffset: curScroll + (rowsRef.current - 1 - start.row) },
1000
+ end: { ...end, tailOffset: curScroll + (rowsRef.current - 1 - end.row) },
1001
+ };
1002
+ }
1003
+ function selPosBefore(a, b) {
1004
+ return (a.tailOffset > b.tailOffset ||
1005
+ (a.tailOffset === b.tailOffset && a.col < b.col));
1006
+ }
1007
+ function copySelection() {
1008
+ if (!selStartRef.current || !selEndRef.current)
1009
+ return;
1010
+ const t = terminalRef.current;
1011
+ if (!t)
1012
+ return;
1013
+ let start = selStartRef.current;
1014
+ let end = selEndRef.current;
1015
+ if (selPosBefore(end, start)) {
1016
+ [start, end] = [end, start];
1017
+ }
1018
+ const curScroll = scrollOffsetRef.current;
1019
+ const rows = rowsRef.current;
1020
+ const startViewRow = rows - 1 - start.tailOffset + curScroll;
1021
+ const endViewRow = rows - 1 - end.tailOffset + curScroll;
1022
+ const inViewport = startViewRow >= 0 &&
1023
+ startViewRow < rows &&
1024
+ endViewRow >= 0 &&
1025
+ endViewRow < rows;
1026
+ if (inViewport) {
1027
+ const text = t.get_text(startViewRow, start.col, endViewRow, end.col);
1028
+ if (text)
1029
+ navigator.clipboard.writeText(text);
1030
+ }
1031
+ else if (blitConn && sessionId !== null && blitConn.supportsCopyRange()) {
1032
+ blitConn
1033
+ .copyRange(sessionId, start.tailOffset, start.col, end.tailOffset, end.col)
1034
+ .then((text) => {
1035
+ if (text)
1036
+ navigator.clipboard.writeText(text);
1037
+ })
1038
+ .catch(() => { });
1039
+ }
1040
+ }
1041
+ let mouseDownButton = -1;
1042
+ let lastMouseCell = { row: -1, col: -1 };
1043
+ const handleMouseDown = (e) => {
1044
+ // Scrollbar click/drag
1045
+ if (e.button === 0 && scrollbarGeoRef.current && isNearScrollbar(e)) {
1046
+ e.preventDefault();
1047
+ const geo = scrollbarGeoRef.current;
1048
+ const y = canvasYFromEvent(e);
1049
+ scrollDraggingRef.current = true;
1050
+ canvas.style.cursor = "grabbing";
1051
+ if (y >= geo.barY && y <= geo.barY + geo.barH) {
1052
+ // Clicked on thumb — anchor for relative drag
1053
+ scrollDragOffsetRef.current = y - geo.barY;
1054
+ }
1055
+ else {
1056
+ // Clicked on track — jump thumb center to click
1057
+ scrollDragOffsetRef.current = geo.barH / 2;
1058
+ scrollToCanvasY(y - geo.barH / 2);
1059
+ }
1060
+ return;
1061
+ }
1062
+ if (!e.shiftKey && sendMouseEvent("down", e, e.button)) {
1063
+ mouseDownButton = e.button;
1064
+ e.preventDefault();
1065
+ return;
1066
+ }
1067
+ if (e.button === 0) {
1068
+ clearSelection();
1069
+ selecting = true;
1070
+ selectingRef.current = true;
1071
+ const cell = mouseToCell(e);
1072
+ const sel = cellToSel(cell);
1073
+ const detail = Math.min(e.detail, 3);
1074
+ selGranularity = detail;
1075
+ if (detail >= 2) {
1076
+ const { start, end } = applyGranularitySel(sel);
1077
+ selStartRef.current = start;
1078
+ selEndRef.current = end;
1079
+ selAnchorStart = start;
1080
+ selAnchorEnd = end;
1081
+ drawSelection();
1082
+ }
1083
+ else {
1084
+ selStartRef.current = sel;
1085
+ selEndRef.current = sel;
1086
+ selAnchorStart = null;
1087
+ selAnchorEnd = null;
1088
+ }
1089
+ }
1090
+ };
1091
+ const handleMouseMove = (e) => {
1092
+ if (scrollDraggingRef.current) {
1093
+ scrollToCanvasY(canvasYFromEvent(e) - scrollDragOffsetRef.current);
1094
+ return;
1095
+ }
1096
+ // Only forward mouse events when a button is held (drag in progress)
1097
+ // or the cursor is actually over the terminal canvas.
1098
+ const overCanvas = mouseDownButton >= 0 || canvas.contains(e.target);
1099
+ if (!e.shiftKey && overCanvas) {
1100
+ const t = terminalRef.current;
1101
+ if (t) {
1102
+ const mode = t.mouse_mode();
1103
+ if (mode >= 3) {
1104
+ // Only report when the cell coordinate changes (like real terminals).
1105
+ const cell = mouseToCell(e);
1106
+ if (cell.row === lastMouseCell.row &&
1107
+ cell.col === lastMouseCell.col)
1108
+ return;
1109
+ lastMouseCell = cell;
1110
+ if (e.buttons) {
1111
+ const button = e.buttons & 1 ? 0 : e.buttons & 2 ? 2 : e.buttons & 4 ? 1 : 0;
1112
+ sendMouseEvent("move", e, button + 32);
1113
+ return;
1114
+ }
1115
+ else if (mode === 4) {
1116
+ sendMouseEvent("move", e, 35);
1117
+ return;
1118
+ }
1119
+ }
1120
+ }
1121
+ }
1122
+ if (selecting) {
1123
+ const rect = canvas.getBoundingClientRect();
1124
+ if (e.clientY < rect.top) {
1125
+ startAutoScroll(1);
1126
+ return;
1127
+ }
1128
+ else if (e.clientY > rect.bottom) {
1129
+ startAutoScroll(-1);
1130
+ return;
1131
+ }
1132
+ else {
1133
+ stopAutoScroll();
1134
+ }
1135
+ const cell = mouseToCell(e);
1136
+ const sel = cellToSel(cell);
1137
+ if (selGranularity >= 2 && selAnchorStart && selAnchorEnd) {
1138
+ const { start: dragStart, end: dragEnd } = applyGranularitySel(sel);
1139
+ const dragBefore = selPosBefore(dragStart, selAnchorStart);
1140
+ if (dragBefore) {
1141
+ selStartRef.current = dragStart;
1142
+ selEndRef.current = selAnchorEnd;
1143
+ }
1144
+ else {
1145
+ selStartRef.current = selAnchorStart;
1146
+ selEndRef.current = dragEnd;
1147
+ }
1148
+ }
1149
+ else {
1150
+ selEndRef.current = sel;
1151
+ }
1152
+ drawSelection();
1153
+ }
1154
+ };
1155
+ const handleMouseUp = (e) => {
1156
+ if (scrollDraggingRef.current) {
1157
+ scrollDraggingRef.current = false;
1158
+ canvas.style.cursor = "text";
1159
+ scheduleRender();
1160
+ return;
1161
+ }
1162
+ if (mouseDownButton >= 0) {
1163
+ sendMouseEvent("up", e, mouseDownButton);
1164
+ mouseDownButton = -1;
1165
+ return;
1166
+ }
1167
+ if (selecting) {
1168
+ stopAutoScroll();
1169
+ selecting = false;
1170
+ selectingRef.current = false;
1171
+ if (selGranularity === 1) {
1172
+ selEndRef.current = cellToSel(mouseToCell(e));
1173
+ }
1174
+ drawSelection();
1175
+ if (selStartRef.current &&
1176
+ selEndRef.current &&
1177
+ (selStartRef.current.tailOffset !== selEndRef.current.tailOffset ||
1178
+ selStartRef.current.col !== selEndRef.current.col)) {
1179
+ copySelection();
1180
+ }
1181
+ clearSelection();
1182
+ }
1183
+ if (canvas.contains(e.target)) {
1184
+ inputRef.current?.focus();
1185
+ }
1186
+ };
1187
+ const handleWheel = (e) => {
1188
+ const t = terminalRef.current;
1189
+ // Mouse-mode wheel: forward to the application.
1190
+ if (t && t.mouse_mode() > 0 && !e.shiftKey) {
1191
+ e.preventDefault();
1192
+ const button = e.deltaY < 0 ? 64 : 65;
1193
+ sendMouseEvent("down", e, button);
1194
+ }
1195
+ // Scrollback is handled by the container-level wheel handler above.
1196
+ };
1197
+ // --- URL detection ---
1198
+ const URL_RE = /https?:\/\/[^\s<>"'`)\]},;]+/g;
1199
+ function urlAt(row, col) {
1200
+ const text = getRowText(row);
1201
+ URL_RE.lastIndex = 0;
1202
+ let m;
1203
+ while ((m = URL_RE.exec(text)) !== null) {
1204
+ const startCol = m.index;
1205
+ const raw = m[0].replace(/[.),:;]+$/, "");
1206
+ const endCol = startCol + raw.length - 1;
1207
+ if (col >= startCol && col <= endCol) {
1208
+ return { url: raw, startCol, endCol };
1209
+ }
1210
+ }
1211
+ return null;
1212
+ }
1213
+ const handleContextMenu = (e) => {
1214
+ const t = terminalRef.current;
1215
+ if (t && t.mouse_mode() > 0)
1216
+ e.preventDefault();
1217
+ };
1218
+ const handleClick = (e) => {
1219
+ if (e.altKey && e.button === 0) {
1220
+ const cell = mouseToCell(e);
1221
+ const hit = urlAt(cell.row, cell.col);
1222
+ if (hit) {
1223
+ e.preventDefault();
1224
+ window.open(hit.url, "_blank", "noopener");
1225
+ return;
1226
+ }
1227
+ }
1228
+ inputRef.current?.focus();
1229
+ };
1230
+ let lastHoverUrl = null;
1231
+ const handleHoverMove = (e) => {
1232
+ // Scrollbar cursor
1233
+ if (scrollDraggingRef.current) {
1234
+ canvas.style.cursor = "grabbing";
1235
+ return;
1236
+ }
1237
+ if (scrollbarGeoRef.current && isNearScrollbar(e)) {
1238
+ canvas.style.cursor = "default";
1239
+ return;
1240
+ }
1241
+ if (selecting) {
1242
+ if (hoveredUrlRef.current) {
1243
+ hoveredUrlRef.current = null;
1244
+ scheduleRender();
1245
+ canvas.style.cursor = "text";
1246
+ lastHoverUrl = null;
1247
+ }
1248
+ return;
1249
+ }
1250
+ const cell = mouseToCell(e);
1251
+ const hit = urlAt(cell.row, cell.col);
1252
+ const url = hit?.url ?? null;
1253
+ if (url !== lastHoverUrl) {
1254
+ lastHoverUrl = url;
1255
+ canvas.style.cursor = hit ? "pointer" : "text";
1256
+ hoveredUrlRef.current = hit
1257
+ ? {
1258
+ row: cell.row,
1259
+ startCol: hit.startCol,
1260
+ endCol: hit.endCol,
1261
+ url: hit.url,
1262
+ }
1263
+ : null;
1264
+ scheduleRender();
1265
+ }
1266
+ };
1267
+ // Send a synthetic mouseup when the window loses focus, so apps
1268
+ // like zellij/tmux don't get stuck in a "button held" state.
1269
+ const handleBlur = () => {
1270
+ if (mouseDownButton >= 0) {
1271
+ // Synthetic release at (0,0) — server handles encoding + mode check
1272
+ if (sessionId !== null && status === "connected") {
1273
+ workspace.sendMouse(sessionId, MOUSE_UP, mouseDownButton, 0, 0);
1274
+ }
1275
+ mouseDownButton = -1;
1276
+ }
1277
+ if (selecting) {
1278
+ stopAutoScroll();
1279
+ selecting = false;
1280
+ selectingRef.current = false;
1281
+ clearSelection();
1282
+ }
1283
+ };
1284
+ canvas.addEventListener("mousedown", handleMouseDown);
1285
+ window.addEventListener("mousemove", handleMouseMove);
1286
+ canvas.addEventListener("mousemove", handleHoverMove);
1287
+ window.addEventListener("mouseup", handleMouseUp);
1288
+ window.addEventListener("blur", handleBlur);
1289
+ canvas.addEventListener("wheel", handleWheel, { passive: false });
1290
+ canvas.addEventListener("contextmenu", handleContextMenu);
1291
+ canvas.addEventListener("click", handleClick);
1292
+ return () => {
1293
+ canvas.removeEventListener("mousedown", handleMouseDown);
1294
+ window.removeEventListener("mousemove", handleMouseMove);
1295
+ canvas.removeEventListener("mousemove", handleHoverMove);
1296
+ window.removeEventListener("mouseup", handleMouseUp);
1297
+ window.removeEventListener("blur", handleBlur);
1298
+ canvas.removeEventListener("wheel", handleWheel);
1299
+ canvas.removeEventListener("contextmenu", handleContextMenu);
1300
+ canvas.removeEventListener("click", handleClick);
1301
+ if (scrollFadeTimerRef.current)
1302
+ clearTimeout(scrollFadeTimerRef.current);
1303
+ stopAutoScroll();
1304
+ };
1305
+ }, [sessionId, status, sendScroll, blitConn, workspace, readOnly]);
1306
+ // -----------------------------------------------------------------------
1307
+ // Render
1308
+ // -----------------------------------------------------------------------
1309
+ return (_jsxs("div", { ref: containerRef, className: className, style: {
1310
+ position: "relative",
1311
+ overflow: "hidden",
1312
+ ...style,
1313
+ }, children: [_jsx("canvas", { ref: glCanvasRef, style: readOnly
1314
+ ? {
1315
+ display: "block",
1316
+ width: "100%",
1317
+ height: "100%",
1318
+ objectFit: "contain",
1319
+ objectPosition: "center",
1320
+ }
1321
+ : {
1322
+ display: "block",
1323
+ position: "absolute",
1324
+ top: 0,
1325
+ left: 0,
1326
+ cursor: "text",
1327
+ } }), !readOnly && (_jsx("textarea", { ref: inputRef, "aria-label": "Terminal input", autoCapitalize: "off", autoComplete: "off", autoCorrect: "off", spellCheck: false, style: {
1328
+ position: "absolute",
1329
+ opacity: 0,
1330
+ width: 1,
1331
+ height: 1,
1332
+ top: 0,
1333
+ left: 0,
1334
+ padding: 0,
1335
+ border: "none",
1336
+ outline: "none",
1337
+ resize: "none",
1338
+ overflow: "hidden",
1339
+ }, tabIndex: 0 }))] }));
1340
+ }
1341
+ //# sourceMappingURL=BlitTerminal.js.map