* feat: enable local-origin desktop features and copy in terminal support - Enable arbitrary web loads in desktop web content for TAURI apps. - Guard directory dialog access behind local-origin origin check. - Add copy-to-clipboard support for terminal selections via common shortcuts. * fix: improve initial directory resolution and persistence in directory store
1510 lines
49 KiB
TypeScript
1510 lines
49 KiB
TypeScript
import React from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
|
|
|
|
import { isMobileDeviceViaCSS } from '@/lib/device';
|
|
import type { TerminalTheme } from '@/lib/terminalTheme';
|
|
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
|
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
|
import { cn } from '@/lib/utils';
|
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
|
|
|
let ghosttyPromise: Promise<Ghostty> | null = null;
|
|
|
|
function getGhostty(): Promise<Ghostty> {
|
|
if (!ghosttyPromise) {
|
|
ghosttyPromise = Ghostty.load();
|
|
}
|
|
return ghosttyPromise;
|
|
}
|
|
|
|
function findScrollableViewport(container: HTMLElement): HTMLElement | null {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const candidates = [container, ...Array.from(container.querySelectorAll<HTMLElement>('*'))];
|
|
let fallback: HTMLElement | null = null;
|
|
|
|
for (const element of candidates) {
|
|
const style = window.getComputedStyle(element);
|
|
const overflowY = style.overflowY;
|
|
if (overflowY !== 'auto' && overflowY !== 'scroll') {
|
|
continue;
|
|
}
|
|
|
|
// Prefer an element that is currently scrollable.
|
|
if (element.scrollHeight - element.clientHeight > 2) {
|
|
return element;
|
|
}
|
|
|
|
// Otherwise keep the first overflow container as a fallback so we can
|
|
// attach touch scroll before scrollback grows.
|
|
if (!fallback) {
|
|
fallback = element;
|
|
}
|
|
}
|
|
|
|
return fallback;
|
|
}
|
|
|
|
type TerminalController = {
|
|
focus: () => void;
|
|
clear: () => void;
|
|
fit: () => void;
|
|
};
|
|
|
|
interface TerminalViewportProps {
|
|
sessionKey: string;
|
|
chunks: TerminalChunk[];
|
|
onInput: (data: string) => void;
|
|
onResize: (cols: number, rows: number) => void;
|
|
theme: TerminalTheme;
|
|
fontFamily: string;
|
|
fontSize: number;
|
|
className?: string;
|
|
enableTouchScroll?: boolean;
|
|
autoFocus?: boolean;
|
|
}
|
|
|
|
const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportProps>(
|
|
(
|
|
{ sessionKey, chunks, onInput, onResize, theme, fontFamily, fontSize, className, enableTouchScroll, autoFocus = true },
|
|
ref
|
|
) => {
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
const viewportRef = React.useRef<HTMLElement | null>(null);
|
|
const terminalRef = React.useRef<GhosttyTerminal | null>(null);
|
|
const fitAddonRef = React.useRef<FitAddon | null>(null);
|
|
const inputHandlerRef = React.useRef<(data: string) => void>(onInput);
|
|
const resizeHandlerRef = React.useRef<(cols: number, rows: number) => void>(onResize);
|
|
const lastReportedSizeRef = React.useRef<{ cols: number; rows: number } | null>(null);
|
|
const pendingWriteRef = React.useRef('');
|
|
const writeScheduledRef = React.useRef<number | null>(null);
|
|
const isWritingRef = React.useRef(false);
|
|
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
|
|
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
|
|
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
|
|
const viewportDiscoveryAttemptsRef = React.useRef(0);
|
|
const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null);
|
|
const textInputRef = React.useRef<HTMLInputElement | null>(null);
|
|
const isComposingRef = React.useRef(false);
|
|
const ignoreNextInputRef = React.useRef(false);
|
|
const lastBeforeInputRef = React.useRef<{ type: string; at: number } | null>(null);
|
|
const lastInputEventAtRef = React.useRef<number | null>(null);
|
|
const refocusTimeoutRef = React.useRef<number | null>(null);
|
|
const keydownProbeTimeoutRef = React.useRef<number | null>(null);
|
|
const lastObservedValueRef = React.useRef('');
|
|
const cursorBlinkStateRef = React.useRef<boolean | null>(null);
|
|
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
|
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
|
|
|
|
inputHandlerRef.current = onInput;
|
|
resizeHandlerRef.current = onResize;
|
|
|
|
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
|
|
const prefersTouchOnlyPointer = typeof window !== 'undefined'
|
|
&& typeof window.matchMedia === 'function'
|
|
&& window.matchMedia('(pointer: coarse)').matches
|
|
&& window.matchMedia('(hover: none)').matches;
|
|
const useHiddenInputOverlay = Boolean(enableTouchScroll)
|
|
&& (isAndroid || isMobileDeviceViaCSS() || prefersTouchOnlyPointer);
|
|
|
|
const disableTerminalTextareas = React.useCallback(() => {
|
|
const container = containerRef.current;
|
|
const hiddenInput = hiddenInputRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const editableNodes = Array.from(container.querySelectorAll<HTMLElement>('[contenteditable="true"]'));
|
|
editableNodes.forEach((node) => {
|
|
node.style.setProperty('caret-color', 'transparent');
|
|
node.style.outline = 'none';
|
|
});
|
|
|
|
const textareas = Array.from(container.querySelectorAll('textarea')) as HTMLTextAreaElement[];
|
|
textareas.forEach((textarea) => {
|
|
textarea.style.setProperty('caret-color', 'transparent');
|
|
textarea.style.color = 'transparent';
|
|
textarea.style.setProperty('-webkit-text-fill-color', 'transparent');
|
|
textarea.style.background = 'transparent';
|
|
textarea.style.border = '0';
|
|
textarea.style.outline = 'none';
|
|
textarea.style.boxShadow = 'none';
|
|
textarea.style.textShadow = 'none';
|
|
textarea.style.fontSize = '0';
|
|
textarea.style.lineHeight = '0';
|
|
|
|
if (!useHiddenInputOverlay) {
|
|
return;
|
|
}
|
|
|
|
if (textarea === hiddenInput) {
|
|
return;
|
|
}
|
|
if (textarea.getAttribute('data-terminal-disabled-input') === 'true') {
|
|
return;
|
|
}
|
|
textarea.setAttribute('data-terminal-disabled-input', 'true');
|
|
textarea.setAttribute('aria-hidden', 'true');
|
|
textarea.tabIndex = -1;
|
|
textarea.disabled = true;
|
|
textarea.style.position = 'absolute';
|
|
textarea.style.opacity = '0';
|
|
textarea.style.width = '0px';
|
|
textarea.style.height = '0px';
|
|
textarea.style.pointerEvents = 'none';
|
|
textarea.style.zIndex = '-1';
|
|
});
|
|
}, [useHiddenInputOverlay]);
|
|
|
|
const setTerminalCursorBlink = React.useCallback((enabled: boolean) => {
|
|
if (cursorBlinkStateRef.current === enabled) {
|
|
return;
|
|
}
|
|
|
|
const terminal = terminalRef.current as unknown as {
|
|
setOption?: (key: string, value: unknown) => void;
|
|
options?: { cursorBlink?: boolean };
|
|
} | null;
|
|
|
|
if (!terminal) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (typeof terminal.setOption === 'function') {
|
|
terminal.setOption('cursorBlink', enabled);
|
|
cursorBlinkStateRef.current = enabled;
|
|
return;
|
|
}
|
|
|
|
if (terminal.options) {
|
|
terminal.options.cursorBlink = enabled;
|
|
cursorBlinkStateRef.current = enabled;
|
|
}
|
|
} catch {
|
|
// ignored
|
|
}
|
|
}, []);
|
|
|
|
const useTextInput = useHiddenInputOverlay && isAndroid;
|
|
|
|
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
|
|
const input = (useTextInput ? textInputRef.current : hiddenInputRef.current) as HTMLElement | null;
|
|
const container = containerRef.current;
|
|
if (!input || !container) {
|
|
return;
|
|
}
|
|
|
|
const rect = container.getBoundingClientRect();
|
|
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : rect.width;
|
|
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : rect.height;
|
|
const fallbackX = rect.left + rect.width / 2;
|
|
const fallbackY = rect.top + rect.height - 12;
|
|
const x = typeof clientX === 'number' ? clientX : fallbackX;
|
|
const y = typeof clientY === 'number' ? clientY : fallbackY;
|
|
|
|
const padding = 8;
|
|
const left = Math.max(padding, Math.min(viewportWidth - padding, x));
|
|
const top = Math.max(padding, Math.min(viewportHeight - padding, y));
|
|
|
|
input.style.left = `${left}px`;
|
|
input.style.top = `${top}px`;
|
|
input.style.bottom = '';
|
|
|
|
if (input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement) {
|
|
input.disabled = false;
|
|
input.readOnly = false;
|
|
input.removeAttribute('disabled');
|
|
input.removeAttribute('readonly');
|
|
}
|
|
|
|
try {
|
|
input.focus({ preventScroll: true });
|
|
} catch {
|
|
try {
|
|
input.focus();
|
|
} catch { /* ignored */ }
|
|
}
|
|
}, [useTextInput]);
|
|
|
|
const focusTerminalInput = React.useCallback(() => {
|
|
if (useHiddenInputOverlay) {
|
|
focusHiddenInput();
|
|
setTerminalCursorBlink(true);
|
|
return;
|
|
}
|
|
terminalRef.current?.focus();
|
|
setTerminalCursorBlink(true);
|
|
}, [focusHiddenInput, setTerminalCursorBlink, useHiddenInputOverlay]);
|
|
|
|
const readEditableValue = React.useCallback((target: HTMLElement) => {
|
|
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
|
|
return target.value;
|
|
}
|
|
return target.textContent ?? '';
|
|
}, []);
|
|
|
|
const clearEditableValue = React.useCallback((target: HTMLElement) => {
|
|
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
|
|
target.value = '';
|
|
return;
|
|
}
|
|
target.textContent = '';
|
|
}, []);
|
|
|
|
const scheduleKeyProbe = React.useCallback((target: HTMLElement) => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
if (useTextInput) {
|
|
return;
|
|
}
|
|
|
|
if (keydownProbeTimeoutRef.current !== null) {
|
|
window.clearTimeout(keydownProbeTimeoutRef.current);
|
|
keydownProbeTimeoutRef.current = null;
|
|
}
|
|
|
|
let attempt = 0;
|
|
const maxAttempts = 3;
|
|
|
|
const runProbe = () => {
|
|
keydownProbeTimeoutRef.current = window.setTimeout(() => {
|
|
keydownProbeTimeoutRef.current = null;
|
|
const value = readEditableValue(target);
|
|
if (!value) {
|
|
attempt += 1;
|
|
if (attempt < maxAttempts) {
|
|
runProbe();
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
const previous = lastObservedValueRef.current;
|
|
lastObservedValueRef.current = value;
|
|
const delta = value.startsWith(previous) ? value.slice(previous.length) : value;
|
|
if (delta) {
|
|
inputHandlerRef.current(delta.replace(/\r\n|\r|\n/g, '\r'));
|
|
}
|
|
clearEditableValue(target);
|
|
lastObservedValueRef.current = '';
|
|
}, attempt === 0 ? 0 : 24);
|
|
};
|
|
|
|
runProbe();
|
|
}, [clearEditableValue, readEditableValue, useTextInput]);
|
|
|
|
React.useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!useHiddenInputOverlay || !container) {
|
|
return;
|
|
}
|
|
|
|
const handleContainerFocusIn = (event: FocusEvent) => {
|
|
const target = event.target as HTMLElement | null;
|
|
if (!target) {
|
|
return;
|
|
}
|
|
if (target.getAttribute('data-terminal-hidden-input') === 'true') {
|
|
return;
|
|
}
|
|
if (!container.contains(target)) {
|
|
return;
|
|
}
|
|
try {
|
|
target.blur();
|
|
} catch { /* ignored */ }
|
|
focusHiddenInput();
|
|
};
|
|
|
|
container.addEventListener('focusin', handleContainerFocusIn, true);
|
|
return () => {
|
|
container.removeEventListener('focusin', handleContainerFocusIn, true);
|
|
};
|
|
}, [useHiddenInputOverlay, focusHiddenInput]);
|
|
|
|
const copySelectionToClipboard = React.useCallback(async () => {
|
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
const selection = window.getSelection();
|
|
if (!selection) {
|
|
return;
|
|
}
|
|
const text = selection.toString();
|
|
if (!text.trim()) {
|
|
return;
|
|
}
|
|
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
const anchorNode = selection.anchorNode;
|
|
const focusNode = selection.focusNode;
|
|
if (anchorNode && !container.contains(anchorNode)) {
|
|
return;
|
|
}
|
|
if (focusNode && !container.contains(focusNode)) {
|
|
return;
|
|
}
|
|
|
|
if (navigator.clipboard?.writeText) {
|
|
const copied = await navigator.clipboard
|
|
.writeText(text)
|
|
.then(() => true)
|
|
.catch(() => false);
|
|
if (copied) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.style.position = 'fixed';
|
|
textarea.style.opacity = '0';
|
|
document.body.appendChild(textarea);
|
|
textarea.focus();
|
|
textarea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
} catch {
|
|
return;
|
|
}
|
|
}, []);
|
|
|
|
const hasCopyableSelectionInViewport = React.useCallback((): boolean => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
const selection = window.getSelection();
|
|
if (!selection) {
|
|
return false;
|
|
}
|
|
const text = selection.toString();
|
|
if (!text.trim()) {
|
|
return false;
|
|
}
|
|
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return false;
|
|
}
|
|
|
|
const anchorNode = selection.anchorNode;
|
|
const focusNode = selection.focusNode;
|
|
if (anchorNode && !container.contains(anchorNode)) {
|
|
return false;
|
|
}
|
|
if (focusNode && !container.contains(focusNode)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}, []);
|
|
|
|
const resetWriteState = React.useCallback(() => {
|
|
pendingWriteRef.current = '';
|
|
if (writeScheduledRef.current !== null && typeof window !== 'undefined') {
|
|
window.cancelAnimationFrame(writeScheduledRef.current);
|
|
}
|
|
writeScheduledRef.current = null;
|
|
isWritingRef.current = false;
|
|
lastProcessedChunkIdRef.current = null;
|
|
}, []);
|
|
|
|
const fitTerminal = React.useCallback(() => {
|
|
const fitAddon = fitAddonRef.current;
|
|
const terminal = terminalRef.current;
|
|
const container = containerRef.current;
|
|
if (!fitAddon || !terminal || !container) {
|
|
return;
|
|
}
|
|
const rect = container.getBoundingClientRect();
|
|
if (rect.width < 24 || rect.height < 24) {
|
|
return;
|
|
}
|
|
try {
|
|
fitAddon.fit();
|
|
const next = { cols: terminal.cols, rows: terminal.rows };
|
|
const previous = lastReportedSizeRef.current;
|
|
if (!previous || previous.cols !== next.cols || previous.rows !== next.rows) {
|
|
lastReportedSizeRef.current = next;
|
|
resizeHandlerRef.current(next.cols, next.rows);
|
|
}
|
|
} catch { /* ignored */ }
|
|
}, []);
|
|
|
|
const flushWrites = React.useCallback(() => {
|
|
if (isWritingRef.current) {
|
|
return;
|
|
}
|
|
|
|
const term = terminalRef.current;
|
|
if (!term) {
|
|
resetWriteState();
|
|
return;
|
|
}
|
|
|
|
if (!pendingWriteRef.current) {
|
|
return;
|
|
}
|
|
|
|
const chunk = pendingWriteRef.current;
|
|
pendingWriteRef.current = '';
|
|
|
|
isWritingRef.current = true;
|
|
term.write(chunk, () => {
|
|
isWritingRef.current = false;
|
|
if (pendingWriteRef.current) {
|
|
if (typeof window !== 'undefined') {
|
|
writeScheduledRef.current = window.requestAnimationFrame(() => {
|
|
writeScheduledRef.current = null;
|
|
flushWrites();
|
|
});
|
|
} else {
|
|
flushWrites();
|
|
}
|
|
}
|
|
});
|
|
}, [resetWriteState]);
|
|
|
|
const scheduleFlushWrites = React.useCallback(() => {
|
|
if (writeScheduledRef.current !== null) {
|
|
return;
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
writeScheduledRef.current = window.requestAnimationFrame(() => {
|
|
writeScheduledRef.current = null;
|
|
flushWrites();
|
|
});
|
|
} else {
|
|
flushWrites();
|
|
}
|
|
}, [flushWrites]);
|
|
|
|
const enqueueWrite = React.useCallback(
|
|
(data: string) => {
|
|
if (!data) {
|
|
return;
|
|
}
|
|
pendingWriteRef.current += data;
|
|
scheduleFlushWrites();
|
|
},
|
|
[scheduleFlushWrites]
|
|
);
|
|
|
|
const setupTouchScroll = React.useCallback(() => {
|
|
touchScrollCleanupRef.current?.();
|
|
touchScrollCleanupRef.current = null;
|
|
|
|
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
|
|
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
|
|
viewportDiscoveryTimeoutRef.current = null;
|
|
}
|
|
|
|
if (!enableTouchScroll) {
|
|
viewportDiscoveryAttemptsRef.current = 0;
|
|
return;
|
|
}
|
|
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
// Ghostty scrollback is internal (canvas-based). On touch devices we need
|
|
// to translate touch deltas into terminal scroll calls.
|
|
const terminal = terminalRef.current;
|
|
if (!terminal) {
|
|
return;
|
|
}
|
|
|
|
viewportDiscoveryAttemptsRef.current = 0;
|
|
|
|
const baseScrollMultiplier = 2.2;
|
|
const maxScrollBoost = 2.8;
|
|
const boostDenominator = 25;
|
|
const velocityAlpha = 0.25;
|
|
const maxVelocity = 8;
|
|
const minVelocity = 0.05;
|
|
const deceleration = 0.015;
|
|
|
|
const state = {
|
|
lastY: null as number | null,
|
|
lastTime: null as number | null,
|
|
velocity: 0,
|
|
rafId: null as number | null,
|
|
startX: null as number | null,
|
|
startY: null as number | null,
|
|
didMove: false,
|
|
};
|
|
|
|
const nowMs = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
|
|
const lineHeightPx = Math.max(12, Math.round(fontSize * 1.35));
|
|
let remainderPx = 0;
|
|
|
|
const scrollByPixels = (deltaPixels: number) => {
|
|
if (!deltaPixels) {
|
|
return false;
|
|
}
|
|
|
|
const before = terminal.getViewportY();
|
|
|
|
const total = remainderPx + deltaPixels;
|
|
const lines = Math.trunc(total / lineHeightPx);
|
|
remainderPx = total - lines * lineHeightPx;
|
|
|
|
if (lines !== 0) {
|
|
// Touch delta is in pixels, convert to lines.
|
|
// Natural mobile scrolling: finger up scrolls down.
|
|
terminal.scrollLines(lines);
|
|
}
|
|
|
|
const after = terminal.getViewportY();
|
|
return after !== before;
|
|
};
|
|
|
|
const stopKinetic = () => {
|
|
if (state.rafId !== null && typeof window !== 'undefined') {
|
|
window.cancelAnimationFrame(state.rafId);
|
|
}
|
|
state.rafId = null;
|
|
};
|
|
|
|
const listenerOptions: AddEventListenerOptions = { passive: false, capture: false };
|
|
const supportsPointerEvents = typeof window !== 'undefined' && 'PointerEvent' in window;
|
|
|
|
if (supportsPointerEvents) {
|
|
const stateWithPointerId = Object.assign(state, {
|
|
pointerId: null as number | null,
|
|
startX: null as number | null,
|
|
startY: null as number | null,
|
|
moved: false,
|
|
});
|
|
|
|
const TAP_MOVE_THRESHOLD_PX = 6;
|
|
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
if (event.pointerType !== 'touch') {
|
|
return;
|
|
}
|
|
stopKinetic();
|
|
stateWithPointerId.pointerId = event.pointerId;
|
|
stateWithPointerId.startX = event.clientX;
|
|
stateWithPointerId.startY = event.clientY;
|
|
stateWithPointerId.moved = false;
|
|
stateWithPointerId.lastY = event.clientY;
|
|
stateWithPointerId.lastTime = nowMs();
|
|
stateWithPointerId.velocity = 0;
|
|
try {
|
|
container.setPointerCapture(event.pointerId);
|
|
} catch { /* ignored */ }
|
|
};
|
|
|
|
const handlePointerMove = (event: PointerEvent) => {
|
|
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
if (stateWithPointerId.startX !== null && stateWithPointerId.startY !== null && !stateWithPointerId.moved) {
|
|
const dx = event.clientX - stateWithPointerId.startX;
|
|
const dy = event.clientY - stateWithPointerId.startY;
|
|
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
|
|
stateWithPointerId.moved = true;
|
|
}
|
|
}
|
|
|
|
if (stateWithPointerId.lastY === null) {
|
|
stateWithPointerId.lastY = event.clientY;
|
|
stateWithPointerId.lastTime = nowMs();
|
|
return;
|
|
}
|
|
|
|
const previousY = stateWithPointerId.lastY;
|
|
const previousTime = stateWithPointerId.lastTime ?? nowMs();
|
|
const currentTime = nowMs();
|
|
stateWithPointerId.lastY = event.clientY;
|
|
stateWithPointerId.lastTime = currentTime;
|
|
|
|
const deltaY = previousY - event.clientY;
|
|
if (Math.abs(deltaY) < 1) {
|
|
return;
|
|
}
|
|
|
|
const dt = Math.max(currentTime - previousTime, 8);
|
|
const scrollMultiplier = baseScrollMultiplier + Math.min(maxScrollBoost, Math.abs(deltaY) / boostDenominator);
|
|
const deltaPixels = deltaY * scrollMultiplier;
|
|
const instantVelocity = deltaPixels / dt;
|
|
stateWithPointerId.velocity = stateWithPointerId.velocity * (1 - velocityAlpha) + instantVelocity * velocityAlpha;
|
|
|
|
if (stateWithPointerId.velocity > maxVelocity) {
|
|
stateWithPointerId.velocity = maxVelocity;
|
|
} else if (stateWithPointerId.velocity < -maxVelocity) {
|
|
stateWithPointerId.velocity = -maxVelocity;
|
|
}
|
|
|
|
// Only prevent default once we're actually scrolling.
|
|
if (stateWithPointerId.moved) {
|
|
if (event.cancelable) {
|
|
event.preventDefault();
|
|
}
|
|
event.stopPropagation();
|
|
}
|
|
|
|
scrollByPixels(deltaPixels);
|
|
};
|
|
|
|
const handlePointerUp = (event: PointerEvent) => {
|
|
if (event.pointerType !== 'touch' || stateWithPointerId.pointerId !== event.pointerId) {
|
|
return;
|
|
}
|
|
|
|
const wasTap = !stateWithPointerId.moved;
|
|
|
|
stateWithPointerId.pointerId = null;
|
|
stateWithPointerId.startX = null;
|
|
stateWithPointerId.startY = null;
|
|
stateWithPointerId.moved = false;
|
|
stateWithPointerId.lastY = null;
|
|
stateWithPointerId.lastTime = null;
|
|
try {
|
|
container.releasePointerCapture(event.pointerId);
|
|
} catch { /* ignored */ }
|
|
|
|
if (wasTap) {
|
|
if (useHiddenInputOverlay) {
|
|
focusHiddenInput(event.clientX, event.clientY);
|
|
} else {
|
|
terminalRef.current?.focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
if (Math.abs(stateWithPointerId.velocity) < minVelocity) {
|
|
stateWithPointerId.velocity = 0;
|
|
return;
|
|
}
|
|
|
|
let lastFrame = nowMs();
|
|
const step = () => {
|
|
const frameTime = nowMs();
|
|
const dt = Math.max(frameTime - lastFrame, 8);
|
|
lastFrame = frameTime;
|
|
|
|
const moved = scrollByPixels(stateWithPointerId.velocity * dt) ?? false;
|
|
|
|
const sign = Math.sign(stateWithPointerId.velocity);
|
|
const nextMagnitude = Math.max(0, Math.abs(stateWithPointerId.velocity) - deceleration * dt);
|
|
stateWithPointerId.velocity = nextMagnitude * sign;
|
|
|
|
if (!moved || nextMagnitude <= minVelocity) {
|
|
stopKinetic();
|
|
stateWithPointerId.velocity = 0;
|
|
return;
|
|
}
|
|
|
|
stateWithPointerId.rafId = window.requestAnimationFrame(step);
|
|
};
|
|
|
|
stateWithPointerId.rafId = window.requestAnimationFrame(step);
|
|
};
|
|
|
|
container.addEventListener('pointerdown', handlePointerDown, listenerOptions);
|
|
container.addEventListener('pointermove', handlePointerMove, listenerOptions);
|
|
container.addEventListener('pointerup', handlePointerUp, listenerOptions);
|
|
container.addEventListener('pointercancel', handlePointerUp, listenerOptions);
|
|
|
|
const previousTouchAction = container.style.touchAction;
|
|
container.style.touchAction = 'manipulation';
|
|
|
|
touchScrollCleanupRef.current = () => {
|
|
stopKinetic();
|
|
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
|
|
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
|
|
viewportDiscoveryTimeoutRef.current = null;
|
|
}
|
|
viewportDiscoveryAttemptsRef.current = 0;
|
|
container.removeEventListener('pointerdown', handlePointerDown, listenerOptions);
|
|
container.removeEventListener('pointermove', handlePointerMove, listenerOptions);
|
|
container.removeEventListener('pointerup', handlePointerUp, listenerOptions);
|
|
container.removeEventListener('pointercancel', handlePointerUp, listenerOptions);
|
|
container.style.touchAction = previousTouchAction;
|
|
};
|
|
|
|
return;
|
|
}
|
|
|
|
const TAP_MOVE_THRESHOLD_PX = 6;
|
|
|
|
const handleTouchStart = (event: TouchEvent) => {
|
|
if (event.touches.length !== 1) {
|
|
return;
|
|
}
|
|
stopKinetic();
|
|
state.lastY = event.touches[0].clientY;
|
|
state.lastTime = nowMs();
|
|
state.velocity = 0;
|
|
state.startX = event.touches[0].clientX;
|
|
state.startY = event.touches[0].clientY;
|
|
state.didMove = false;
|
|
};
|
|
|
|
const handleTouchMove = (event: TouchEvent) => {
|
|
if (event.touches.length !== 1) {
|
|
state.lastY = null;
|
|
state.lastTime = null;
|
|
state.velocity = 0;
|
|
state.startX = null;
|
|
state.startY = null;
|
|
state.didMove = false;
|
|
stopKinetic();
|
|
return;
|
|
}
|
|
|
|
const currentX = event.touches[0].clientX;
|
|
const currentY = event.touches[0].clientY;
|
|
|
|
if (state.startX !== null && state.startY !== null && !state.didMove) {
|
|
const dx = currentX - state.startX;
|
|
const dy = currentY - state.startY;
|
|
if (Math.hypot(dx, dy) >= TAP_MOVE_THRESHOLD_PX) {
|
|
state.didMove = true;
|
|
}
|
|
}
|
|
|
|
if (state.lastY === null) {
|
|
state.lastY = currentY;
|
|
state.lastTime = nowMs();
|
|
return;
|
|
}
|
|
|
|
const previousY = state.lastY;
|
|
const previousTime = state.lastTime ?? nowMs();
|
|
const currentTime = nowMs();
|
|
state.lastY = currentY;
|
|
state.lastTime = currentTime;
|
|
|
|
const deltaY = previousY - currentY;
|
|
if (Math.abs(deltaY) < 1) {
|
|
return;
|
|
}
|
|
|
|
const dt = Math.max(currentTime - previousTime, 8);
|
|
const scrollMultiplier = baseScrollMultiplier + Math.min(maxScrollBoost, Math.abs(deltaY) / boostDenominator);
|
|
const deltaPixels = deltaY * scrollMultiplier;
|
|
const instantVelocity = deltaPixels / dt;
|
|
state.velocity = state.velocity * (1 - velocityAlpha) + instantVelocity * velocityAlpha;
|
|
|
|
if (state.velocity > maxVelocity) {
|
|
state.velocity = maxVelocity;
|
|
} else if (state.velocity < -maxVelocity) {
|
|
state.velocity = -maxVelocity;
|
|
}
|
|
|
|
if (state.didMove) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
|
|
scrollByPixels(deltaPixels);
|
|
};
|
|
|
|
const handleTouchEnd = (event: TouchEvent) => {
|
|
const wasTap = !state.didMove;
|
|
|
|
|
|
state.lastY = null;
|
|
state.lastTime = null;
|
|
|
|
const velocity = state.velocity;
|
|
state.startX = null;
|
|
state.startY = null;
|
|
state.didMove = false;
|
|
|
|
if (wasTap) {
|
|
const point = event.changedTouches?.[0];
|
|
if (useHiddenInputOverlay) {
|
|
focusHiddenInput(point?.clientX, point?.clientY);
|
|
} else {
|
|
terminalRef.current?.focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
if (Math.abs(velocity) < minVelocity) {
|
|
state.velocity = 0;
|
|
return;
|
|
}
|
|
|
|
let lastFrame = nowMs();
|
|
const step = () => {
|
|
const frameTime = nowMs();
|
|
const dt = Math.max(frameTime - lastFrame, 8);
|
|
lastFrame = frameTime;
|
|
|
|
const moved = scrollByPixels(state.velocity * dt) ?? false;
|
|
|
|
const sign = Math.sign(state.velocity);
|
|
const nextMagnitude = Math.max(0, Math.abs(state.velocity) - deceleration * dt);
|
|
state.velocity = nextMagnitude * sign;
|
|
|
|
if (!moved || nextMagnitude <= minVelocity) {
|
|
stopKinetic();
|
|
state.velocity = 0;
|
|
return;
|
|
}
|
|
|
|
state.rafId = window.requestAnimationFrame(step);
|
|
};
|
|
|
|
state.rafId = window.requestAnimationFrame(step);
|
|
};
|
|
|
|
container.addEventListener('touchstart', handleTouchStart, listenerOptions);
|
|
container.addEventListener('touchmove', handleTouchMove, listenerOptions);
|
|
container.addEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
|
|
container.addEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
|
|
|
|
const previousTouchAction = container.style.touchAction;
|
|
container.style.touchAction = 'manipulation';
|
|
|
|
touchScrollCleanupRef.current = () => {
|
|
stopKinetic();
|
|
if (viewportDiscoveryTimeoutRef.current !== null && typeof window !== 'undefined') {
|
|
window.clearTimeout(viewportDiscoveryTimeoutRef.current);
|
|
viewportDiscoveryTimeoutRef.current = null;
|
|
}
|
|
viewportDiscoveryAttemptsRef.current = 0;
|
|
container.removeEventListener('touchstart', handleTouchStart, listenerOptions);
|
|
container.removeEventListener('touchmove', handleTouchMove, listenerOptions);
|
|
container.removeEventListener('touchend', handleTouchEnd as unknown as EventListener, listenerOptions);
|
|
container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
|
|
container.style.touchAction = previousTouchAction;
|
|
};
|
|
}, [enableTouchScroll, useHiddenInputOverlay, focusHiddenInput, fontSize]);
|
|
|
|
React.useEffect(() => {
|
|
let disposed = false;
|
|
let localTerminal: GhosttyTerminal | null = null;
|
|
let localResizeObserver: ResizeObserver | null = null;
|
|
let localTextareaObserver: MutationObserver | null = null;
|
|
let localDisposables: Array<{ dispose: () => void }> = [];
|
|
|
|
const container = containerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
container.tabIndex = useHiddenInputOverlay ? -1 : 0;
|
|
|
|
const handleTerminalTextareaFocus = () => {
|
|
setTerminalCursorBlink(true);
|
|
};
|
|
|
|
const handleTerminalTextareaBlur = () => {
|
|
setTerminalCursorBlink(false);
|
|
};
|
|
|
|
const handleDocumentFocusIn = (event: FocusEvent) => {
|
|
const target = event.target as Node | null;
|
|
if (target && container.contains(target)) {
|
|
setTerminalCursorBlink(true);
|
|
return;
|
|
}
|
|
setTerminalCursorBlink(false);
|
|
};
|
|
|
|
const handleWindowBlur = () => {
|
|
setTerminalCursorBlink(false);
|
|
};
|
|
|
|
let localTerminalTextarea: HTMLTextAreaElement | null = null;
|
|
|
|
const initialize = async () => {
|
|
try {
|
|
const ghostty = await getGhostty();
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
|
|
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, useHiddenInputOverlay);
|
|
|
|
const terminal = new GhosttyTerminal(options);
|
|
|
|
const fitAddon = new FitAddon();
|
|
|
|
localTerminal = terminal;
|
|
terminalRef.current = terminal;
|
|
fitAddonRef.current = fitAddon;
|
|
|
|
terminal.loadAddon(fitAddon);
|
|
terminal.open(container);
|
|
bumpTerminalReady();
|
|
cursorBlinkStateRef.current = false;
|
|
|
|
localTerminalTextarea =
|
|
(terminal as unknown as { textarea?: HTMLTextAreaElement | null }).textarea
|
|
?? container.querySelector('textarea');
|
|
|
|
if (localTerminalTextarea) {
|
|
localTerminalTextarea.addEventListener('focus', handleTerminalTextareaFocus);
|
|
localTerminalTextarea.addEventListener('blur', handleTerminalTextareaBlur);
|
|
}
|
|
|
|
disableTerminalTextareas();
|
|
|
|
if (typeof MutationObserver !== 'undefined') {
|
|
localTextareaObserver = new MutationObserver(() => {
|
|
disableTerminalTextareas();
|
|
});
|
|
localTextareaObserver.observe(container, { childList: true, subtree: true });
|
|
}
|
|
|
|
const viewport = findScrollableViewport(container);
|
|
if (viewport) {
|
|
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
|
|
viewportRef.current = viewport;
|
|
forceRender();
|
|
} else {
|
|
viewportRef.current = null;
|
|
}
|
|
|
|
fitTerminal();
|
|
setupTouchScroll();
|
|
localDisposables = [
|
|
terminal.onData((data: string) => {
|
|
inputHandlerRef.current(data);
|
|
}),
|
|
];
|
|
|
|
localResizeObserver = new ResizeObserver(() => {
|
|
fitTerminal();
|
|
});
|
|
localResizeObserver.observe(container);
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.setTimeout(() => {
|
|
fitTerminal();
|
|
}, 0);
|
|
}
|
|
} catch {
|
|
// ignored
|
|
}
|
|
};
|
|
|
|
void initialize();
|
|
|
|
document.addEventListener('focusin', handleDocumentFocusIn, true);
|
|
window.addEventListener('blur', handleWindowBlur);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
touchScrollCleanupRef.current?.();
|
|
touchScrollCleanupRef.current = null;
|
|
|
|
document.removeEventListener('focusin', handleDocumentFocusIn, true);
|
|
window.removeEventListener('blur', handleWindowBlur);
|
|
|
|
localDisposables.forEach((disposable) => disposable.dispose());
|
|
if (localTerminalTextarea) {
|
|
localTerminalTextarea.removeEventListener('focus', handleTerminalTextareaFocus);
|
|
localTerminalTextarea.removeEventListener('blur', handleTerminalTextareaBlur);
|
|
}
|
|
localResizeObserver?.disconnect();
|
|
localTextareaObserver?.disconnect();
|
|
|
|
localTerminal?.dispose();
|
|
terminalRef.current = null;
|
|
fitAddonRef.current = null;
|
|
viewportRef.current = null;
|
|
lastReportedSizeRef.current = null;
|
|
cursorBlinkStateRef.current = null;
|
|
resetWriteState();
|
|
};
|
|
}, [disableTerminalTextareas, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState, setTerminalCursorBlink, useHiddenInputOverlay]);
|
|
|
|
|
|
React.useEffect(() => {
|
|
const terminal = terminalRef.current;
|
|
if (!terminal) {
|
|
return;
|
|
}
|
|
terminal.reset();
|
|
resetWriteState();
|
|
lastReportedSizeRef.current = null;
|
|
fitTerminal();
|
|
}, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
|
|
|
|
React.useEffect(() => {
|
|
if (!autoFocus) {
|
|
return;
|
|
}
|
|
|
|
const terminal = terminalRef.current;
|
|
if (!terminal) {
|
|
return;
|
|
}
|
|
|
|
focusTerminalInput();
|
|
}, [autoFocus, focusTerminalInput, sessionKey, terminalReadyVersion]);
|
|
|
|
React.useEffect(() => {
|
|
setupTouchScroll();
|
|
return () => {
|
|
touchScrollCleanupRef.current?.();
|
|
touchScrollCleanupRef.current = null;
|
|
};
|
|
}, [setupTouchScroll, sessionKey]);
|
|
|
|
React.useEffect(() => {
|
|
const terminal = terminalRef.current;
|
|
if (!terminal) {
|
|
return;
|
|
}
|
|
|
|
if (chunks.length === 0) {
|
|
if (lastProcessedChunkIdRef.current !== null) {
|
|
terminal.reset();
|
|
resetWriteState();
|
|
fitTerminal();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const lastProcessedId = lastProcessedChunkIdRef.current;
|
|
let pending: TerminalChunk[];
|
|
|
|
if (lastProcessedId === null) {
|
|
pending = chunks;
|
|
} else {
|
|
const lastProcessedIndex = chunks.findIndex((chunk) => chunk.id === lastProcessedId);
|
|
pending = lastProcessedIndex >= 0 ? chunks.slice(lastProcessedIndex + 1) : chunks;
|
|
}
|
|
|
|
if (pending.length > 0) {
|
|
enqueueWrite(pending.map((chunk) => chunk.data).join(''));
|
|
}
|
|
|
|
lastProcessedChunkIdRef.current = chunks[chunks.length - 1].id;
|
|
}, [chunks, terminalReadyVersion, enqueueWrite, fitTerminal, resetWriteState]);
|
|
|
|
React.useImperativeHandle(
|
|
ref,
|
|
(): TerminalController => ({
|
|
focus: () => {
|
|
focusTerminalInput();
|
|
},
|
|
clear: () => {
|
|
const terminal = terminalRef.current;
|
|
if (!terminal) {
|
|
return;
|
|
}
|
|
terminal.reset();
|
|
resetWriteState();
|
|
fitTerminal();
|
|
},
|
|
fit: () => {
|
|
fitTerminal();
|
|
},
|
|
}),
|
|
[focusTerminalInput, fitTerminal, resetWriteState]
|
|
);
|
|
|
|
const handleHiddenInputBlur = React.useCallback(
|
|
(event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
if (!useHiddenInputOverlay) {
|
|
return;
|
|
}
|
|
|
|
const related = event.relatedTarget as HTMLElement | null;
|
|
const relatedTag = related?.tagName;
|
|
const isInput = relatedTag === 'INPUT' || relatedTag === 'TEXTAREA' || related?.isContentEditable;
|
|
const isHiddenInput = related?.getAttribute('data-terminal-hidden-input') === 'true';
|
|
if (isInput && !isHiddenInput) {
|
|
if (refocusTimeoutRef.current !== null && typeof window !== 'undefined') {
|
|
window.clearTimeout(refocusTimeoutRef.current);
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
refocusTimeoutRef.current = window.setTimeout(() => {
|
|
refocusTimeoutRef.current = null;
|
|
focusHiddenInput();
|
|
}, 0);
|
|
}
|
|
}
|
|
},
|
|
[useHiddenInputOverlay, focusHiddenInput]
|
|
);
|
|
|
|
const handleHiddenBeforeInput = React.useCallback(
|
|
(event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
const nativeEvent = event.nativeEvent as InputEvent | undefined;
|
|
const inputType = nativeEvent?.inputType ?? '';
|
|
const data = typeof nativeEvent?.data === 'string' ? nativeEvent.data : '';
|
|
|
|
lastInputEventAtRef.current = typeof performance !== 'undefined'
|
|
? performance.now()
|
|
: Date.now();
|
|
|
|
if (inputType === 'insertCompositionText') {
|
|
isComposingRef.current = true;
|
|
return;
|
|
}
|
|
|
|
if (!inputType && data) {
|
|
if (isComposingRef.current) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
inputHandlerRef.current(data);
|
|
lastBeforeInputRef.current = {
|
|
type: 'insertText',
|
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
};
|
|
ignoreNextInputRef.current = true;
|
|
return;
|
|
}
|
|
|
|
if (inputType === 'insertText' && data) {
|
|
if (isComposingRef.current) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
inputHandlerRef.current(data);
|
|
lastBeforeInputRef.current = {
|
|
type: inputType,
|
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
};
|
|
ignoreNextInputRef.current = true;
|
|
return;
|
|
}
|
|
|
|
if (inputType === 'insertLineBreak') {
|
|
if (isComposingRef.current) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
inputHandlerRef.current('\r');
|
|
lastBeforeInputRef.current = {
|
|
type: inputType,
|
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
};
|
|
ignoreNextInputRef.current = true;
|
|
return;
|
|
}
|
|
|
|
if (inputType === 'deleteContentBackward') {
|
|
if (isComposingRef.current) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
inputHandlerRef.current('\x7f');
|
|
lastBeforeInputRef.current = {
|
|
type: inputType,
|
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
};
|
|
ignoreNextInputRef.current = true;
|
|
}
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleHiddenInput = React.useCallback(
|
|
(event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
const target = event.currentTarget as HTMLElement;
|
|
lastInputEventAtRef.current = typeof performance !== 'undefined'
|
|
? performance.now()
|
|
: Date.now();
|
|
if (isComposingRef.current) {
|
|
return;
|
|
}
|
|
if (ignoreNextInputRef.current) {
|
|
const lastBeforeInput = lastBeforeInputRef.current;
|
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
if (lastBeforeInput && now - lastBeforeInput.at < 50) {
|
|
ignoreNextInputRef.current = false;
|
|
clearEditableValue(target);
|
|
return;
|
|
}
|
|
ignoreNextInputRef.current = false;
|
|
}
|
|
const raw = readEditableValue(target);
|
|
if (!raw) {
|
|
return;
|
|
}
|
|
|
|
lastObservedValueRef.current = raw;
|
|
|
|
const value = raw.replace(/\r\n|\r|\n/g, '\r');
|
|
inputHandlerRef.current(value);
|
|
clearEditableValue(target);
|
|
lastObservedValueRef.current = '';
|
|
},
|
|
[clearEditableValue, readEditableValue]
|
|
);
|
|
|
|
const handleHiddenKeyDown = React.useCallback(
|
|
(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
event.stopPropagation();
|
|
|
|
const normalizedKey = event.key.toLowerCase();
|
|
const isMacCopyShortcut = event.metaKey && !event.ctrlKey && !event.altKey && normalizedKey === 'c';
|
|
const isWindowsLinuxCopyShortcut =
|
|
event.ctrlKey && event.shiftKey && !event.metaKey && !event.altKey && normalizedKey === 'c';
|
|
|
|
if ((isMacCopyShortcut || isWindowsLinuxCopyShortcut) && hasCopyableSelectionInViewport()) {
|
|
event.preventDefault();
|
|
void copySelectionToClipboard();
|
|
return;
|
|
}
|
|
|
|
const target = event.currentTarget as HTMLElement;
|
|
const nativeEvent = event.nativeEvent as KeyboardEvent | undefined;
|
|
if (nativeEvent?.isComposing) {
|
|
return;
|
|
}
|
|
const lastBeforeInput = lastBeforeInputRef.current;
|
|
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
const recent = Boolean(lastBeforeInput && now - lastBeforeInput.at < 50);
|
|
if (event.key === 'Enter') {
|
|
if (recent && lastBeforeInput?.type === 'insertLineBreak') {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
inputHandlerRef.current('\r');
|
|
clearEditableValue(target);
|
|
return;
|
|
}
|
|
if (event.key === 'Backspace') {
|
|
event.preventDefault();
|
|
if (recent && lastBeforeInput?.type === 'deleteContentBackward') {
|
|
return;
|
|
}
|
|
if (!readEditableValue(target)) {
|
|
inputHandlerRef.current('\x7f');
|
|
}
|
|
}
|
|
|
|
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
const lastInputAt = lastInputEventAtRef.current;
|
|
const sawInputRecently = Boolean(lastInputAt && now - lastInputAt < 50);
|
|
if (!sawInputRecently) {
|
|
event.preventDefault();
|
|
inputHandlerRef.current(event.key);
|
|
ignoreNextInputRef.current = true;
|
|
lastBeforeInputRef.current = {
|
|
type: 'keydown-text',
|
|
at: now,
|
|
};
|
|
}
|
|
}
|
|
|
|
scheduleKeyProbe(target);
|
|
},
|
|
[clearEditableValue, copySelectionToClipboard, hasCopyableSelectionInViewport, readEditableValue, scheduleKeyProbe]
|
|
);
|
|
|
|
const handleHiddenKeyUp = React.useCallback(
|
|
(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
event.stopPropagation();
|
|
const target = event.currentTarget as HTMLElement;
|
|
const nativeEvent = event.nativeEvent as KeyboardEvent | undefined;
|
|
if (nativeEvent?.isComposing) {
|
|
return;
|
|
}
|
|
scheduleKeyProbe(target);
|
|
},
|
|
[scheduleKeyProbe]
|
|
);
|
|
|
|
const handleHiddenCompositionEnd = React.useCallback(
|
|
(event: React.CompositionEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
const target = event.currentTarget as HTMLElement;
|
|
isComposingRef.current = false;
|
|
const data = event.data || readEditableValue(target);
|
|
lastInputEventAtRef.current = typeof performance !== 'undefined'
|
|
? performance.now()
|
|
: Date.now();
|
|
if (!data) {
|
|
return;
|
|
}
|
|
const value = data.replace(/\r\n|\r|\n/g, '\r');
|
|
inputHandlerRef.current(value);
|
|
clearEditableValue(target);
|
|
lastBeforeInputRef.current = {
|
|
type: 'compositionend',
|
|
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
|
|
};
|
|
ignoreNextInputRef.current = true;
|
|
},
|
|
[clearEditableValue, readEditableValue]
|
|
);
|
|
|
|
const handleHiddenPaste = React.useCallback(
|
|
(event: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
event.stopPropagation();
|
|
const text = event.clipboardData?.getData('text') ?? '';
|
|
if (!text) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const terminal = terminalRef.current;
|
|
const payload = terminal?.hasBracketedPaste?.()
|
|
? `\x1b[200~${text}\x1b[201~`
|
|
: text;
|
|
inputHandlerRef.current(payload);
|
|
},
|
|
[]
|
|
);
|
|
|
|
const hiddenInputStyle: React.CSSProperties = {
|
|
position: 'fixed',
|
|
left: 0,
|
|
top: 0,
|
|
width: 1,
|
|
height: 1,
|
|
opacity: 0,
|
|
zIndex: -1,
|
|
background: 'transparent',
|
|
color: 'transparent',
|
|
WebkitTextFillColor: 'transparent',
|
|
caretColor: 'transparent',
|
|
textShadow: 'none',
|
|
WebkitAppearance: 'none',
|
|
appearance: 'none',
|
|
resize: 'none',
|
|
overflow: 'hidden',
|
|
whiteSpace: 'nowrap',
|
|
border: '0',
|
|
padding: 0,
|
|
margin: 0,
|
|
outline: 'none',
|
|
outlineOffset: 0,
|
|
fontSize: 16,
|
|
fontWeight: 400,
|
|
pointerEvents: 'none',
|
|
WebkitUserSelect: 'none',
|
|
userSelect: 'none',
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn('relative h-full w-full terminal-viewport-container', className)}
|
|
style={{ backgroundColor: theme.background }}
|
|
onTouchStart={(event) => {
|
|
if (useHiddenInputOverlay) {
|
|
const touch = event.touches?.[0];
|
|
focusHiddenInput(touch?.clientX, touch?.clientY);
|
|
}
|
|
}}
|
|
onClick={(event) => {
|
|
if (useHiddenInputOverlay) {
|
|
focusHiddenInput(event.clientX, event.clientY);
|
|
} else {
|
|
terminalRef.current?.focus();
|
|
}
|
|
}}
|
|
onMouseUp={() => {
|
|
if (!enableTouchScroll) {
|
|
void copySelectionToClipboard();
|
|
}
|
|
}}
|
|
onTouchEnd={() => {
|
|
if (!enableTouchScroll) {
|
|
void copySelectionToClipboard();
|
|
}
|
|
}}
|
|
>
|
|
{useHiddenInputOverlay && typeof document !== 'undefined'
|
|
? createPortal(
|
|
<>
|
|
<input
|
|
ref={textInputRef}
|
|
type="text"
|
|
inputMode="text"
|
|
autoCapitalize="off"
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
tabIndex={-1}
|
|
enterKeyHint="send"
|
|
data-terminal-hidden-input="true"
|
|
aria-label="Terminal input"
|
|
aria-hidden="true"
|
|
style={{
|
|
...hiddenInputStyle,
|
|
display: useTextInput ? 'block' : 'none',
|
|
}}
|
|
onBlur={handleHiddenInputBlur}
|
|
onBeforeInput={handleHiddenBeforeInput}
|
|
onCompositionStart={() => {
|
|
isComposingRef.current = true;
|
|
}}
|
|
onInput={handleHiddenInput}
|
|
onKeyDown={handleHiddenKeyDown}
|
|
onKeyUp={handleHiddenKeyUp}
|
|
onCompositionEnd={handleHiddenCompositionEnd}
|
|
onPaste={handleHiddenPaste}
|
|
/>
|
|
<textarea
|
|
ref={hiddenInputRef}
|
|
inputMode="text"
|
|
autoCapitalize="off"
|
|
autoComplete="off"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
tabIndex={-1}
|
|
enterKeyHint="send"
|
|
data-terminal-hidden-input="true"
|
|
aria-label="Terminal input"
|
|
aria-hidden="true"
|
|
style={{
|
|
...hiddenInputStyle,
|
|
display: useTextInput ? 'none' : 'block',
|
|
}}
|
|
onBlur={handleHiddenInputBlur}
|
|
onBeforeInput={handleHiddenBeforeInput}
|
|
onCompositionStart={() => {
|
|
isComposingRef.current = true;
|
|
}}
|
|
onInput={handleHiddenInput}
|
|
onKeyDown={handleHiddenKeyDown}
|
|
onKeyUp={handleHiddenKeyUp}
|
|
onCompositionEnd={handleHiddenCompositionEnd}
|
|
onPaste={handleHiddenPaste}
|
|
/>
|
|
</>,
|
|
document.body
|
|
)
|
|
: null}
|
|
{viewportRef.current && !enableTouchScroll ? (
|
|
<OverlayScrollbar
|
|
containerRef={viewportRef}
|
|
disableHorizontal
|
|
className="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
TerminalViewport.displayName = 'TerminalViewport';
|
|
|
|
export type { TerminalController };
|
|
export { TerminalViewport };
|