@blit-sh/react 0.17.1 → 0.18.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.
@@ -1,6 +1,6 @@
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";
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useImperativeHandle, useRef } from "react";
3
+ import { BlitTerminalSurface } from "@blit-sh/core";
4
4
  import { useBlitContext, useRequiredBlitWorkspace } from "./BlitContext";
5
5
  import { useBlitConnection } from "./hooks/useBlitConnection";
6
6
  import { useBlitSession } from "./hooks/useBlitSession";
@@ -10,8 +10,9 @@ import { useBlitSession } from "./hooks/useBlitSession";
10
10
  /**
11
11
  * BlitTerminal renders a blit terminal inside a WebGL canvas.
12
12
  *
13
- * It handles WASM initialisation, server message processing, GL rendering,
14
- * keyboard/mouse input, and dynamic resizing through a ResizeObserver.
13
+ * This is a thin wrapper over `BlitTerminalSurface` from `@blit-sh/core`.
14
+ * It renders a container `<div>`, attaches the surface to it on mount,
15
+ * and forwards prop changes to the surface's setters.
15
16
  */
16
17
  export function BlitTerminal({ ref, ...props }) {
17
18
  const ctx = useBlitContext();
@@ -21,1321 +22,99 @@ export function BlitTerminal({ ref, ...props }) {
21
22
  const blitConn = session
22
23
  ? workspace.getConnection(session.connectionId)
23
24
  : 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.
25
+ const { sessionId, fontFamily = ctx.fontFamily, fontSize = ctx.fontSize, className, style, palette = ctx.palette, readOnly, showCursor, onRender, scrollbarColor, scrollbarWidth, advanceRatio = ctx.advanceRatio, } = props;
29
26
  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.
27
+ const surfaceRef = useRef(null);
28
+ // Create the surface once on mount.
80
29
  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);
30
+ const surface = new BlitTerminalSurface({
31
+ sessionId,
32
+ fontFamily,
33
+ fontSize,
34
+ palette,
35
+ readOnly,
36
+ showCursor,
37
+ onRender,
38
+ scrollbarColor,
39
+ scrollbarWidth,
40
+ advanceRatio,
143
41
  });
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);
42
+ surfaceRef.current = surface;
244
43
  return () => {
245
- mq?.removeEventListener("change", check);
246
- window.removeEventListener("resize", check);
44
+ surface.dispose();
45
+ surfaceRef.current = null;
247
46
  };
47
+ // Only create/destroy on mount/unmount. Props are forwarded via setters.
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps
248
49
  }, []);
50
+ // Attach/detach to the container div.
249
51
  useEffect(() => {
250
- if (!blitConn)
52
+ const surface = surfaceRef.current;
53
+ const container = containerRef.current;
54
+ if (!surface || !container)
251
55
  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
- // -----------------------------------------------------------------------
56
+ surface.attach(container);
57
+ return () => surface.detach();
58
+ }, []);
59
+ // Forward workspace + connection.
303
60
  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
- // -----------------------------------------------------------------------
61
+ surfaceRef.current?.setWorkspace(workspace);
62
+ }, [workspace]);
320
63
  useEffect(() => {
321
- if (!blitConn)
322
- return;
323
- const shared = blitConn.getSharedRenderer();
324
- if (shared)
325
- rendererRef.current = shared.renderer;
64
+ surfaceRef.current?.setConnection(blitConn);
326
65
  }, [blitConn]);
327
- // -----------------------------------------------------------------------
328
- // Terminal instance lifecycle
329
- // -----------------------------------------------------------------------
66
+ // Forward all prop changes.
330
67
  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
- // -----------------------------------------------------------------------
68
+ surfaceRef.current?.setSessionId(sessionId);
69
+ }, [sessionId]);
371
70
  useEffect(() => {
372
- paletteRef.current = palette;
373
- applyPaletteToTerminal(terminalRef.current);
374
- }, [palette, applyPaletteToTerminal]);
375
- // -----------------------------------------------------------------------
376
- // Resize observer
377
- // -----------------------------------------------------------------------
71
+ surfaceRef.current?.setPalette(palette);
72
+ }, [palette]);
378
73
  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.
74
+ surfaceRef.current?.setFontFamily(fontFamily);
75
+ }, [fontFamily]);
417
76
  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
- // -----------------------------------------------------------------------
77
+ surfaceRef.current?.setFontSize(fontSize);
78
+ }, [fontSize]);
433
79
  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
- // -----------------------------------------------------------------------
80
+ surfaceRef.current?.setShowCursor(showCursor);
81
+ }, [showCursor]);
658
82
  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
- // -----------------------------------------------------------------------
83
+ surfaceRef.current?.setOnRender(onRender);
84
+ }, [onRender]);
777
85
  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
- // -----------------------------------------------------------------------
86
+ surfaceRef.current?.setAdvanceRatio(advanceRatio);
87
+ }, [advanceRatio]);
814
88
  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;
89
+ surfaceRef.current?.setReadOnly(readOnly);
90
+ }, [readOnly]);
91
+ // Re-send dimensions when connection becomes ready.
92
+ const status = connection?.status ?? "disconnected";
93
+ useEffect(() => {
94
+ if (status === "connected" && sessionId !== null && !readOnly) {
95
+ surfaceRef.current?.resendSize();
1212
96
  }
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: {
97
+ }, [status, sessionId, readOnly]);
98
+ // Imperative handle.
99
+ useImperativeHandle(ref, () => ({
100
+ get terminal() {
101
+ return surfaceRef.current?.currentTerminal ?? null;
102
+ },
103
+ get rows() {
104
+ return surfaceRef.current?.rows ?? 24;
105
+ },
106
+ get cols() {
107
+ return surfaceRef.current?.cols ?? 80;
108
+ },
109
+ status,
110
+ focus() {
111
+ surfaceRef.current?.focus();
112
+ },
113
+ }), [status]);
114
+ return (_jsx("div", { ref: containerRef, className: className, style: {
1310
115
  position: "relative",
1311
116
  overflow: "hidden",
1312
117
  ...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 }))] }));
118
+ } }));
1340
119
  }
1341
120
  //# sourceMappingURL=BlitTerminal.js.map