SPB Git

Repository Public

mobile-toolbar.tsx 6.6 KB 155 lines tsx

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
Files main
Menu

Theme