fix(terminal): prevent native caret blink in ghostty focus inputs (#329)
This commit is contained in:
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
|
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
|
||||||
|
|
||||||
|
import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||||
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
||||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||||
@@ -99,17 +100,44 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
inputHandlerRef.current = onInput;
|
inputHandlerRef.current = onInput;
|
||||||
resizeHandlerRef.current = onResize;
|
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 disableTerminalTextareas = React.useCallback(() => {
|
||||||
if (!enableTouchScroll) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
const hiddenInput = hiddenInputRef.current;
|
const hiddenInput = hiddenInputRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
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[];
|
const textareas = Array.from(container.querySelectorAll('textarea')) as HTMLTextAreaElement[];
|
||||||
textareas.forEach((textarea) => {
|
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) {
|
if (textarea === hiddenInput) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -127,10 +155,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
textarea.style.pointerEvents = 'none';
|
textarea.style.pointerEvents = 'none';
|
||||||
textarea.style.zIndex = '-1';
|
textarea.style.zIndex = '-1';
|
||||||
});
|
});
|
||||||
}, [enableTouchScroll]);
|
}, [useHiddenInputOverlay]);
|
||||||
|
|
||||||
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
|
const useTextInput = useHiddenInputOverlay && isAndroid;
|
||||||
const useTextInput = enableTouchScroll && isAndroid;
|
|
||||||
|
|
||||||
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
|
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
|
||||||
const input = (useTextInput ? textInputRef.current : hiddenInputRef.current) as HTMLElement | null;
|
const input = (useTextInput ? textInputRef.current : hiddenInputRef.current) as HTMLElement | null;
|
||||||
@@ -230,7 +257,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!enableTouchScroll || !container) {
|
if (!useHiddenInputOverlay || !container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +282,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('focusin', handleContainerFocusIn, true);
|
container.removeEventListener('focusin', handleContainerFocusIn, true);
|
||||||
};
|
};
|
||||||
}, [enableTouchScroll, focusHiddenInput]);
|
}, [useHiddenInputOverlay, focusHiddenInput]);
|
||||||
|
|
||||||
const copySelectionToClipboard = React.useCallback(async () => {
|
const copySelectionToClipboard = React.useCallback(async () => {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
@@ -579,7 +606,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
} catch { /* ignored */ }
|
} catch { /* ignored */ }
|
||||||
|
|
||||||
if (wasTap) {
|
if (wasTap) {
|
||||||
focusHiddenInput(event.clientX, event.clientY);
|
if (useHiddenInputOverlay) {
|
||||||
|
focusHiddenInput(event.clientX, event.clientY);
|
||||||
|
} else {
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,7 +761,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
if (wasTap) {
|
if (wasTap) {
|
||||||
const point = event.changedTouches?.[0];
|
const point = event.changedTouches?.[0];
|
||||||
focusHiddenInput(point?.clientX, point?.clientY);
|
if (useHiddenInputOverlay) {
|
||||||
|
focusHiddenInput(point?.clientX, point?.clientY);
|
||||||
|
} else {
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +823,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
|
container.removeEventListener('touchcancel', handleTouchEnd as unknown as EventListener, listenerOptions);
|
||||||
container.style.touchAction = previousTouchAction;
|
container.style.touchAction = previousTouchAction;
|
||||||
};
|
};
|
||||||
}, [enableTouchScroll, focusHiddenInput, fontSize]);
|
}, [enableTouchScroll, useHiddenInputOverlay, focusHiddenInput, fontSize]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -802,7 +837,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.tabIndex = enableTouchScroll ? -1 : 0;
|
container.tabIndex = useHiddenInputOverlay ? -1 : 0;
|
||||||
|
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -811,7 +846,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, Boolean(enableTouchScroll));
|
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, useHiddenInputOverlay);
|
||||||
|
|
||||||
const terminal = new GhosttyTerminal(options);
|
const terminal = new GhosttyTerminal(options);
|
||||||
|
|
||||||
@@ -827,7 +862,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
disableTerminalTextareas();
|
disableTerminalTextareas();
|
||||||
|
|
||||||
if (enableTouchScroll && typeof MutationObserver !== 'undefined') {
|
if (typeof MutationObserver !== 'undefined') {
|
||||||
localTextareaObserver = new MutationObserver(() => {
|
localTextareaObserver = new MutationObserver(() => {
|
||||||
disableTerminalTextareas();
|
disableTerminalTextareas();
|
||||||
});
|
});
|
||||||
@@ -845,7 +880,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
|
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
setupTouchScroll();
|
setupTouchScroll();
|
||||||
if (!enableTouchScroll) {
|
if (!useHiddenInputOverlay) {
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,7 +923,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
resetWriteState();
|
resetWriteState();
|
||||||
};
|
};
|
||||||
}, [disableTerminalTextareas, enableTouchScroll, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
|
}, [disableTerminalTextareas, useHiddenInputOverlay, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
|
||||||
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -900,10 +935,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
resetWriteState();
|
resetWriteState();
|
||||||
lastReportedSizeRef.current = null;
|
lastReportedSizeRef.current = null;
|
||||||
fitTerminal();
|
fitTerminal();
|
||||||
if (!enableTouchScroll) {
|
if (!useHiddenInputOverlay) {
|
||||||
terminal.focus();
|
terminal.focus();
|
||||||
}
|
}
|
||||||
}, [enableTouchScroll, sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
|
}, [useHiddenInputOverlay, sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setupTouchScroll();
|
setupTouchScroll();
|
||||||
@@ -949,7 +984,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
ref,
|
ref,
|
||||||
(): TerminalController => ({
|
(): TerminalController => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
if (enableTouchScroll) {
|
if (useHiddenInputOverlay) {
|
||||||
focusHiddenInput();
|
focusHiddenInput();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -968,12 +1003,12 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
fitTerminal();
|
fitTerminal();
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
|
[useHiddenInputOverlay, focusHiddenInput, fitTerminal, resetWriteState]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHiddenInputBlur = React.useCallback(
|
const handleHiddenInputBlur = React.useCallback(
|
||||||
(event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
(event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
if (!enableTouchScroll) {
|
if (!useHiddenInputOverlay) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -993,7 +1028,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enableTouchScroll, focusHiddenInput]
|
[useHiddenInputOverlay, focusHiddenInput]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleHiddenBeforeInput = React.useCallback(
|
const handleHiddenBeforeInput = React.useCallback(
|
||||||
@@ -1215,7 +1250,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
zIndex: -1,
|
zIndex: -1,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'transparent',
|
color: 'transparent',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
caretColor: 'transparent',
|
caretColor: 'transparent',
|
||||||
|
textShadow: 'none',
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
appearance: 'none',
|
||||||
resize: 'none',
|
resize: 'none',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
@@ -1237,13 +1276,13 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
className={cn('relative h-full w-full terminal-viewport-container', className)}
|
className={cn('relative h-full w-full terminal-viewport-container', className)}
|
||||||
style={{ backgroundColor: theme.background }}
|
style={{ backgroundColor: theme.background }}
|
||||||
onTouchStart={(event) => {
|
onTouchStart={(event) => {
|
||||||
if (enableTouchScroll) {
|
if (useHiddenInputOverlay) {
|
||||||
const touch = event.touches?.[0];
|
const touch = event.touches?.[0];
|
||||||
focusHiddenInput(touch?.clientX, touch?.clientY);
|
focusHiddenInput(touch?.clientX, touch?.clientY);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
if (enableTouchScroll) {
|
if (useHiddenInputOverlay) {
|
||||||
focusHiddenInput(event.clientX, event.clientY);
|
focusHiddenInput(event.clientX, event.clientY);
|
||||||
} else {
|
} else {
|
||||||
terminalRef.current?.focus();
|
terminalRef.current?.focus();
|
||||||
@@ -1260,7 +1299,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{enableTouchScroll && typeof document !== 'undefined'
|
{useHiddenInputOverlay && typeof document !== 'undefined'
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -1271,10 +1310,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
tabIndex={0}
|
tabIndex={-1}
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
data-terminal-hidden-input="true"
|
data-terminal-hidden-input="true"
|
||||||
placeholder="Terminal input"
|
aria-label="Terminal input"
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
...hiddenInputStyle,
|
...hiddenInputStyle,
|
||||||
display: useTextInput ? 'block' : 'none',
|
display: useTextInput ? 'block' : 'none',
|
||||||
@@ -1297,10 +1337,11 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
tabIndex={0}
|
tabIndex={-1}
|
||||||
enterKeyHint="send"
|
enterKeyHint="send"
|
||||||
data-terminal-hidden-input="true"
|
data-terminal-hidden-input="true"
|
||||||
placeholder="Terminal input"
|
aria-label="Terminal input"
|
||||||
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
...hiddenInputStyle,
|
...hiddenInputStyle,
|
||||||
display: useTextInput ? 'none' : 'block',
|
display: useTextInput ? 'none' : 'block',
|
||||||
|
|||||||
@@ -700,10 +700,76 @@ html:not(.dark) .chat-scroll {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide system caret in terminal - targets both Ghostty internal input and our custom touch input */
|
/* Hide system caret in terminal - targets both Ghostty internal input and our custom touch input */
|
||||||
.terminal-viewport-container textarea {
|
.terminal-viewport-container textarea,
|
||||||
|
.terminal-viewport-container input {
|
||||||
caret-color: transparent !important;
|
caret-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-viewport-container [contenteditable="true"][aria-label="Terminal input"] {
|
||||||
|
caret-color: transparent !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[contenteditable="true"][aria-label="Terminal input"] {
|
||||||
|
caret-color: transparent !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep Ghostty's internal focus textarea fully non-painting on desktop. */
|
||||||
|
.terminal-viewport-container textarea:not([data-terminal-hidden-input="true"]) {
|
||||||
|
opacity: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
font-size: 0 !important;
|
||||||
|
line-height: 0 !important;
|
||||||
|
clip-path: inset(100%) !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea[aria-label="Terminal input"],
|
||||||
|
input[aria-label="Terminal input"] {
|
||||||
|
caret-color: transparent !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 0 !important;
|
||||||
|
clip-path: inset(100%) !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal touch input is portaled to document.body; keep it fully non-painting across engines. */
|
||||||
|
input[data-terminal-hidden-input="true"],
|
||||||
|
textarea[data-terminal-hidden-input="true"] {
|
||||||
|
caret-color: transparent !important;
|
||||||
|
color: transparent !important;
|
||||||
|
-webkit-text-fill-color: transparent !important;
|
||||||
|
background: transparent !important;
|
||||||
|
border: 0 !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
text-shadow: none !important;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[data-terminal-hidden-input="true"]::selection,
|
||||||
|
textarea[data-terminal-hidden-input="true"]::selection {
|
||||||
|
background: transparent;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[data-terminal-hidden-input="true"]::placeholder,
|
||||||
|
textarea[data-terminal-hidden-input="true"]::placeholder {
|
||||||
|
color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile Terminal Optimizations */
|
/* Mobile Terminal Optimizations */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.terminal-viewport-container {
|
.terminal-viewport-container {
|
||||||
|
|||||||
Reference in New Issue
Block a user