Files
boocode/apps/web/src/hooks/terminal/useTerminalSelection.ts

344 lines
11 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import type { Terminal } from '@xterm/xterm';
import { toast } from 'sonner';
import {
chatInputsRegistry,
sendToChat,
terminalsRegistry,
type ChatInputRegistration,
} from '@/lib/events';
import { cellSize } from './useTerminalFit';
// useTerminalSelection — the touch long-press selection + word-range gesture
// subsystem, the document pointer/contextmenu handlers, clipboard custom keys,
// the terminal + chat-input registries, and the floating-menu actions. All of
// it is independent of the WS/fit path and was kept verbatim from v1.10.4.
const LONG_PRESS_MS = 500;
const LONG_PRESS_TOLERANCE_PX = 10;
function pointToCell(
term: Terminal,
container: HTMLElement,
clientX: number,
clientY: number,
): { col: number; bufferRow: number } {
const rect = container.getBoundingClientRect();
const { w, h } = cellSize(term, container);
const localX = Math.max(0, clientX - rect.left);
const localY = Math.max(0, clientY - rect.top);
const col = Math.min(term.cols - 1, Math.floor(localX / Math.max(w, 1)));
const screenRow = Math.min(term.rows - 1, Math.floor(localY / Math.max(h, 1)));
const bufferRow = term.buffer.active.viewportY + screenRow;
return { col, bufferRow };
}
const WORD_RE = /[\w.~$\-/]+/g;
function wordRangeAt(line: string, col: number): { start: number; end: number } | null {
for (const m of line.matchAll(WORD_RE)) {
const start = m.index ?? 0;
const end = start + m[0].length;
if (col >= start && col < end) return { start, end };
if (start > col) return null;
}
return null;
}
interface SelectionDeps {
termRef: React.MutableRefObject<Terminal | null>;
containerRef: React.MutableRefObject<HTMLDivElement | null>;
sessionId: string;
paneId: string;
label: string;
send: (text: string) => void;
}
export interface TerminalSelectionActions {
copy: () => void;
paste: () => void;
selectAll: () => void;
search: () => void;
sendToChat: (chatId: string) => void;
}
export interface TerminalSelection {
menu: { x: number; y: number } | null;
setMenu: React.Dispatch<React.SetStateAction<{ x: number; y: number } | null>>;
searchOpen: boolean;
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>;
chatInputs: ChatInputRegistration[];
hasSelection: boolean;
actions: TerminalSelectionActions;
}
export function useTerminalSelection({
termRef,
containerRef,
sessionId,
paneId,
label,
send,
}: SelectionDeps): TerminalSelection {
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const [searchOpen, setSearchOpen] = useState(false);
const [chatInputs, setChatInputs] = useState<ChatInputRegistration[]>([]);
const pasteFromClipboard = useCallback((): void => {
if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
toast.error('Paste blocked — long-press input area instead');
return;
}
navigator.clipboard
.readText()
.then((text) => {
if (!text) return;
send(text);
})
.catch(() => {
toast.error('Paste blocked — long-press input area instead');
});
}, [send]);
useEffect(() => {
const termInit = termRef.current;
const ctrInit = containerRef.current;
if (!termInit || !ctrInit) return;
// Non-null bindings so the gesture closures below keep the narrowing.
const term: Terminal = termInit;
const ctr: HTMLDivElement = ctrInit;
let disposed = false;
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
const mod = e.ctrlKey || e.metaKey;
if (!mod) return true;
const isC = e.key === 'c' || e.key === 'C';
const isV = e.key === 'v' || e.key === 'V';
const isF = e.key === 'f' || e.key === 'F';
if (isC) {
if (term.hasSelection()) {
const sel = term.getSelection();
if (sel) {
navigator.clipboard.writeText(sel).catch(() => {
toast.error('Clipboard write failed');
});
}
return false;
}
return !e.shiftKey;
}
if (isV) {
pasteFromClipboard();
return false;
}
if (isF) {
setSearchOpen(true);
return false;
}
return true;
});
// Long-press selection + floating menu (touch).
let lpTimer: ReturnType<typeof setTimeout> | null = null;
let lpStart: { x: number; y: number } | null = null;
let lpAnchor: { col: number; bufferRow: number } | null = null;
let inSelection = false;
function clearLp(): void {
if (lpTimer !== null) {
clearTimeout(lpTimer);
lpTimer = null;
}
lpStart = null;
}
function selectWord(col: number, bufferRow: number): boolean {
const line = term.buffer.active.getLine(bufferRow);
if (!line) return false;
const text = line.translateToString(true);
const range = wordRangeAt(text, col);
if (!range) return false;
term.select(range.start, bufferRow, range.end - range.start);
return true;
}
function extendSelection(
anchor: { col: number; bufferRow: number },
to: { col: number; bufferRow: number },
): void {
const a = anchor;
const b = to;
let s: { col: number; row: number };
let e: { col: number; row: number };
if (a.bufferRow < b.bufferRow || (a.bufferRow === b.bufferRow && a.col <= b.col)) {
s = { col: a.col, row: a.bufferRow };
e = { col: b.col, row: b.bufferRow };
} else {
s = { col: b.col, row: b.bufferRow };
e = { col: a.col, row: a.bufferRow };
}
const rowsBetween = e.row - s.row;
const length = rowsBetween * term.cols + (e.col - s.col) + 1;
term.select(s.col, s.row, length);
}
function onTouchStart(e: TouchEvent): void {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
if ((e.target as Element | null)?.closest('[data-term-menu]')) return;
lpStart = { x: t.clientX, y: t.clientY };
lpAnchor = pointToCell(term, ctr, t.clientX, t.clientY);
inSelection = false;
lpTimer = setTimeout(() => {
if (disposed || !lpAnchor) return;
const ok = selectWord(lpAnchor.col, lpAnchor.bufferRow);
if (!ok) return;
inSelection = true;
setMenu({ x: t.clientX, y: Math.max(t.clientY - 50, 8) });
}, LONG_PRESS_MS);
}
function onTouchMove(e: TouchEvent): void {
if (e.touches.length !== 1) return;
const t = e.touches[0]!;
if (inSelection && lpAnchor) {
e.preventDefault();
const to = pointToCell(term, ctr, t.clientX, t.clientY);
extendSelection(lpAnchor, to);
return;
}
if (!lpStart) return;
const dx = t.clientX - lpStart.x;
const dy = t.clientY - lpStart.y;
if (Math.abs(dx) > LONG_PRESS_TOLERANCE_PX || Math.abs(dy) > LONG_PRESS_TOLERANCE_PX) {
clearLp();
}
}
function onTouchEnd(): void {
if (!inSelection) clearLp();
inSelection = false;
}
function onTouchCancel(): void {
clearLp();
inSelection = false;
}
ctr.addEventListener('touchstart', onTouchStart, { passive: true });
ctr.addEventListener('touchmove', onTouchMove, { passive: false });
ctr.addEventListener('touchend', onTouchEnd, { passive: true });
ctr.addEventListener('touchcancel', onTouchCancel, { passive: true });
function onContextMenu(e: MouseEvent): void {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY });
}
ctr.addEventListener('contextmenu', onContextMenu);
function onDocPointerDown(e: PointerEvent): void {
const t = e.target as Element | null;
if (t && t.closest('[data-term-menu]')) return;
setMenu(null);
}
document.addEventListener('pointerdown', onDocPointerDown);
return () => {
disposed = true;
if (lpTimer !== null) clearTimeout(lpTimer);
document.removeEventListener('pointerdown', onDocPointerDown);
ctr.removeEventListener('touchstart', onTouchStart);
ctr.removeEventListener('touchmove', onTouchMove);
ctr.removeEventListener('touchend', onTouchEnd);
ctr.removeEventListener('touchcancel', onTouchCancel);
ctr.removeEventListener('contextmenu', onContextMenu);
// attachCustomKeyEventHandler has no disposable; term.dispose() drops it.
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, paneId, send, pasteFromClipboard]);
// Terminal registry: focus / openSearch / paste hooks for keyboard shortcuts
// (Session.tsx) and the pane-header paste button (Workspace.tsx). Keyed on
// [paneId, label] ONLY — NOT on sessionId — so a positional `label` renumber
// ("Terminal N" after a pane add/remove) refreshes the registry entry
// WITHOUT tearing down + reconnecting the live terminal. The focus closure
// reads termRef lazily, so it tracks a terminal recreated on a session change
// without needing this effect to re-run. (v2 Phase 9 latent-bug fix: `label`
// was previously in the xterm-init effect's dep array.)
useEffect(() => {
const unregister = terminalsRegistry.register(
paneId,
label,
() => {
try {
termRef.current?.focus();
} catch {
/* ignore */
}
},
() => setSearchOpen(true),
() => pasteFromClipboard(),
);
return () => unregister();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paneId, label, pasteFromClipboard]);
// Chat-input registry: populates the floating menu's "Send to chat" submenu.
// Independent of session/pane/term — subscribe once.
useEffect(() => {
setChatInputs(chatInputsRegistry.list());
const unsubChats = chatInputsRegistry.subscribe(() => {
setChatInputs(chatInputsRegistry.list());
});
return () => unsubChats();
}, []);
function actCopy(): void {
const term = termRef.current;
if (!term) return;
const sel = term.getSelection();
if (!sel) {
setMenu(null);
return;
}
navigator.clipboard.writeText(sel).catch(() => toast.error('Clipboard write failed'));
term.clearSelection();
setMenu(null);
}
function actPaste(): void {
const reg = terminalsRegistry.get(paneId);
reg?.paste();
setMenu(null);
}
function actSelectAll(): void {
termRef.current?.selectAll();
setMenu(null);
}
function actSearch(): void {
setSearchOpen(true);
setMenu(null);
}
function actSendToChat(chatId: string): void {
const term = termRef.current;
if (!term) return;
const sel = term.getSelection();
if (!sel) {
setMenu(null);
return;
}
sendToChat.emit({ chat_id: chatId, text: sel });
setMenu(null);
}
const hasSelection = !!termRef.current?.getSelection();
return {
menu,
setMenu,
searchOpen,
setSearchOpen,
chatInputs,
hasSelection,
actions: {
copy: actCopy,
paste: actPaste,
selectAll: actSelectAll,
search: actSearch,
sendToChat: actSendToChat,
},
};
}