Repository Public
1'use client';2import { useRef, useState } from 'react';3import { ClipboardPaste, Ellipsis, KeyboardOff } from 'lucide-react';4import { useTerminals } from '@/stores/terminals';5import { useVv } from '@/hooks/use-viewport';6import { cn } from '@/lib/cn';7import { haptic } from '@/lib/haptic';8import { toast } from '@/stores/toasts';9import { getController } from './registry';10import { toolbarKey, type ModName } from './keys';1112/** Extra keys behind "⋯": literal control codes and navigation keys. */13const MORE: { id: string; label: string; aria: string; seq?: string }[] = [14 { id: '^C', label: '^C', aria: 'Control C, interrupt', seq: '\x03' },15 { id: '^D', label: '^D', aria: 'Control D, end of input', seq: '\x04' },16 { id: '^Z', label: '^Z', aria: 'Control Z, suspend', seq: '\x1a' },17 { id: '^L', label: '^L', aria: 'Control L, clear screen', seq: '\x0c' },18 { id: 'home', label: 'home', aria: 'Home' },19 { id: 'end', label: 'end', aria: 'End' },20 { id: 'pgup', label: 'pg↑', aria: 'Page up' },21 { id: 'pgdn', label: 'pg↓', aria: 'Page down' },22];23const ROW1 = 9;24const pill =25 'relative flex h-11 min-w-0 flex-1 select-none items-center justify-center rounded-[12px] bg-surface font-mono text-[13px] font-medium text-text shadow-[0_1px_0_var(--color-line-3),0_1px_3px_var(--color-line-2)] transition-[transform,background-color] duration-100 active:scale-[0.93] active:bg-surface-2';2627/**28 * Key toolbar shown directly above the software keyboard: two rows when there is room, one29 * scrolling row otherwise. Buttons act on pointerdown with preventDefault and are not30 * focusable, so the terminal never loses focus (the keyboard stays open).31 */32export function MobileToolbar({ sessionId, keys }: { sessionId: string | null; keys: string[] }) {33 const mods = useTerminals((s) => s.mods);34 const tap = useTerminals((s) => s.tapMod);35 const height = useVv((s) => s.height);36 const [more, setMore] = useState(false);37 const lastTap = useRef<{ id: string; at: number }>({ id: '', at: 0 });38 const repeat = useRef<{ t?: ReturnType<typeof setTimeout>; i?: ReturnType<typeof setInterval> }>({});39 const stopRepeat = () => {40 clearTimeout(repeat.current.t);41 clearInterval(repeat.current.i);42 repeat.current = {};43 };44 const press = (id: string, canRepeat: boolean) => {45 const c = getController(sessionId);46 if (!c) return;47 haptic(6);48 c.sendKey(id);49 stopRepeat();50 if (canRepeat) repeat.current.t = setTimeout(() => (repeat.current.i = setInterval(() => c.sendKey(id), 70)), 420);51 };52 const guard = {53 tabIndex: -1,54 onMouseDown: (e: React.MouseEvent) => e.preventDefault(),55 onTouchEnd: (e: React.TouchEvent) => e.preventDefault(),56 onContextMenu: (e: React.MouseEvent) => e.preventDefault(),57 };58 const twoRows = height === 0 || height >= 420;59 const rows = twoRows ? [keys.slice(0, ROW1), keys.slice(ROW1)] : [keys];6061 const key = (id: string) => {62 const k = toolbarKey(id);63 if (k.kind === 'mod') {64 const state = mods[id as ModName];65 return (66 <button67 key={id}68 {...guard}69 aria-label={k.aria}70 aria-pressed={state > 0}71 data-key={id}72 data-state={state === 2 ? 'locked' : state === 1 ? 'once' : 'off'}73 onPointerDown={(e) => {74 e.preventDefault();75 haptic(state === 1 ? [6, 30, 10] : 6);76 const dbl = lastTap.current.id === id && e.timeStamp - lastTap.current.at < 320;77 lastTap.current = { id, at: e.timeStamp };78 tap(id as ModName, dbl);79 }}80 className={cn(81 pill,82 state === 1 && 'bg-accent/12 text-accent shadow-[0_0_0_1.5px_var(--color-accent),0_0_14px_-2px_var(--color-accent)]',83 state === 2 && 'bg-accent text-accent-fg shadow-[0_0_0_1.5px_var(--color-accent),0_0_18px_-1px_var(--color-accent)] active:bg-accent',84 )}85 >86 {k.label}87 {state === 2 && <span className="absolute right-1.5 top-1.5 size-1.5 rounded-full bg-accent-fg" aria-hidden />}88 </button>89 );90 }91 return (92 <button key={id} {...guard} aria-label={k.aria} data-key={id} onPointerDown={(e) => { e.preventDefault(); press(id, !!k.repeat); }} onPointerUp={stopRepeat} onPointerCancel={stopRepeat} onPointerLeave={stopRepeat} className={cn(pill, k.label.length === 1 && 'text-[15px]')}>93 {k.label}94 </button>95 );96 };97 const actions = (98 <>99 <button100 {...guard}101 aria-label="Paste from clipboard"102 data-key="paste"103 onPointerDown={(e) => e.preventDefault()}104 onPointerUp={async () => {105 haptic(6);106 const ok = await getController(sessionId)?.pasteFromClipboard();107 if (ok === false) toast.warning('Paste blocked by the browser', 'Use the system paste menu (long-press) instead.');108 }}109 className={cn(pill, 'text-accent')}110 >111 <ClipboardPaste className="size-[18px]" />112 </button>113 <button {...guard} aria-label="Hide keyboard" data-key="hide" onPointerDown={(e) => e.preventDefault()} onPointerUp={() => getController(sessionId)?.term.blur()} className={cn(pill, 'text-text-2')}>114 <KeyboardOff className="size-[18px]" />115 </button>116 <button {...guard} aria-label="More keys" aria-expanded={more} data-key="more" onPointerDown={(e) => { e.preventDefault(); haptic(6); setMore((m) => !m); }} className={cn(pill, more ? 'bg-accent/12 text-accent' : 'text-text-2')}>117 <Ellipsis className="size-[18px]" />118 </button>119 </>120 );121122 return (123 <div role="toolbar" aria-label="Terminal keys" data-testid="mobile-toolbar" data-rows={rows.length} className="shrink-0 bg-surface-2 px-1.5 pb-1.5 pt-2">124 {more && (125 <div className="anim-fade mb-1.5 flex gap-1" data-testid="toolbar-more">126 {MORE.map((m) => (127 <button128 key={m.id}129 {...guard}130 aria-label={m.aria}131 data-key={m.id}132 onPointerDown={(e) => {133 e.preventDefault();134 haptic(6);135 const c = getController(sessionId);136 if (m.seq) c?.send(m.seq);137 else c?.sendKey(m.id);138 }}139 className={cn(pill, 'text-xs')}140 >141 {m.label}142 </button>143 ))}144 </div>145 )}146 {rows.map((row, i) => (147 <div key={i} className={cn('flex gap-1', i > 0 && 'mt-1.5', !twoRows && 'no-scrollbar touch-pan-x overflow-x-auto [&>button]:min-w-11 [&>button]:flex-none [&>button]:px-3')}>148 {row.map(key)}149 {i === rows.length - 1 && actions}150 </div>151 ))}152 </div>153 );154}155