Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion browser/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { DeviceModal } from '@/components/device-modal';
import { Keyboard } from '@/components/keyboard';
import { Menu } from '@/components/menu';
import { Mouse } from '@/components/mouse';
import { PasteDialog, pasteStateAtom } from '@/components/paste-dialog';
import { VirtualKeyboard } from '@/components/virtual-keyboard';
import {
resolutionAtom,
Expand All @@ -17,7 +18,7 @@ import {
videoScaleAtom,
videoStateAtom
} from '@/jotai/device.ts';
import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts';
import { isKeyboardEnableAtom, targetKeyboardLayoutAtom } from '@/jotai/keyboard.ts';
import { mouseStyleAtom } from '@/jotai/mouse.ts';
import { device } from '@/libs/device';
import { camera } from '@/libs/media/camera';
Expand All @@ -34,6 +35,8 @@ const App = () => {
const videoState = useAtomValue(videoStateAtom);
const serialState = useAtomValue(serialStateAtom);
const isKeyboardEnable = useAtomValue(isKeyboardEnableAtom);
const targetLayout = useAtomValue(targetKeyboardLayoutAtom);
const setPasteState = useSetAtom(pasteStateAtom);
const setResolution = useSetAtom(resolutionAtom);
const [videoRotation, setVideoRotation] = useAtom(videoRotationAtom);

Expand All @@ -55,6 +58,44 @@ const App = () => {
setShouldSwapDimensions(videoRotation === 90 || videoRotation === 270);
}, [videoRotation]);

// Global Ctrl+Shift+Insert handler for paste with preview
useEffect(() => {
if (serialState !== 'connected') return;

const handleKeyDown = async (e: KeyboardEvent) => {
const isPasteShortcut = e.ctrlKey && e.shiftKey && e.code === 'Insert';

if (isPasteShortcut) {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}

e.preventDefault();
e.stopPropagation();
try {
const text = await navigator.clipboard.readText();
if (text) {
setPasteState({
isOpen: true,
text,
layoutId: targetLayout,
isPasting: false,
progress: 0,
currentChar: 0,
totalChars: text.length
});
}
} catch (err) {
console.error('Failed to read clipboard:', err);
}
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [serialState, targetLayout]);

const videoStyle = useMemo(() => {
const baseStyle = {
transformOrigin: 'center',
Expand Down Expand Up @@ -160,6 +201,7 @@ const App = () => {
/>

<VirtualKeyboard isBigScreen={isBigScreen} />
<PasteDialog />
</>
);
};
Expand Down
41 changes: 36 additions & 5 deletions browser/src/components/keyboard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';

import { pasteStateAtom } from '@/components/paste-dialog';
import { isKeyboardEnableAtom } from '@/jotai/keyboard';
import { getOperatingSystem } from '@/libs/browser';
import { device } from '@/libs/device';
import { KeyboardReport } from '@/libs/keyboard/keyboard.ts';
import { isModifier } from '@/libs/keyboard/keymap.ts';
import { learnFromKeyEvent } from '@/libs/keyboard/layouts.ts';

interface AltGrState {
active: boolean;
Expand All @@ -16,20 +18,39 @@ const ALTGR_THRESHOLD_MS = 10;

export const Keyboard = () => {
const isKeyboardEnabled = useAtomValue(isKeyboardEnableAtom);
const pasteState = useAtomValue(pasteStateAtom);

const keyboardRef = useRef(new KeyboardReport());
const pressedKeys = useRef(new Set<string>());
const altGrState = useRef<AltGrState | null>(null);
const isComposing = useRef(false);

// Track whether keyboard should be active (use ref to avoid closure issues)
const shouldCaptureRef = useRef(true);
const wasCaptureDisabled = useRef(false);
const newShouldCapture = isKeyboardEnabled && !pasteState.isOpen;

// Detect when capture is re-enabled (dialog closed)
if (newShouldCapture && !shouldCaptureRef.current) {
wasCaptureDisabled.current = true;
}
shouldCaptureRef.current = newShouldCapture;

useEffect(() => {
if (getOperatingSystem() === 'Windows' && !altGrState.current) {
altGrState.current = { active: false, ctrlLeftTimestamp: 0 };
}

if (!isKeyboardEnabled) {
// Clear state when capture was disabled and is now re-enabled
if (wasCaptureDisabled.current) {
wasCaptureDisabled.current = false;
pressedKeys.current.clear();
keyboardRef.current.reset();
}

// Release keys when disabling
if (!shouldCaptureRef.current) {
releaseKeys();
return;
}

document.addEventListener('keydown', handleKeyDown);
Expand All @@ -41,7 +62,10 @@ export const Keyboard = () => {

// Key down event
async function handleKeyDown(event: KeyboardEvent): Promise<void> {
if (!isKeyboardEnabled) return;
// When capture is disabled (dialog open), let browser handle events naturally
if (!shouldCaptureRef.current) {
return;
}

// Skip during IME composition
if (isComposing.current || event.isComposing) return;
Expand Down Expand Up @@ -69,12 +93,19 @@ export const Keyboard = () => {
}

pressedKeys.current.add(code);

// Learn character mappings for paste feature
learnFromKeyEvent(event);

await handleKeyEvent({ type: 'keydown', code });
}

// Key up event
async function handleKeyUp(event: KeyboardEvent): Promise<void> {
if (!isKeyboardEnabled) return;
// When capture is disabled (dialog open), let browser handle events naturally
if (!shouldCaptureRef.current) {
return;
}

if (isComposing.current || event.isComposing) return;

Expand Down Expand Up @@ -161,7 +192,7 @@ export const Keyboard = () => {

releaseKeys();
};
}, [isKeyboardEnabled]);
}, [isKeyboardEnabled, pasteState.isOpen]);

// Keyboard handler
async function handleKeyEvent(event: { type: 'keydown' | 'keyup'; code: string }): Promise<void> {
Expand Down
2 changes: 2 additions & 0 deletions browser/src/components/menu/keyboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Popover } from 'antd';
import { KeyboardIcon } from 'lucide-react';

import { Paste } from './paste.tsx';
import { PasteWithDialog } from './paste-dialog.tsx';
import { Shortcuts } from './shortcuts';
import { VirtualKeyboard } from './virtual-keyboard.tsx';

Expand All @@ -12,6 +13,7 @@ export const Keyboard = () => {
const content = (
<div className="flex flex-col space-y-0.5">
<Paste />
<PasteWithDialog />
<VirtualKeyboard />
<Shortcuts />
</div>
Expand Down
42 changes: 42 additions & 0 deletions browser/src/components/menu/keyboard/paste-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ClipboardPasteIcon } from 'lucide-react';
import { useSetAtom } from 'jotai';
import { useTranslation } from 'react-i18next';

import { pasteStateAtom } from '@/components/paste-dialog';
import { getTargetKeyboardLayout } from '@/libs/storage';

export const PasteWithDialog = () => {
const { t } = useTranslation();
const setPasteState = useSetAtom(pasteStateAtom);

async function openPasteDialog(): Promise<void> {
try {
const text = await navigator.clipboard.readText();
if (!text) return;

const layoutId = getTargetKeyboardLayout();

setPasteState({
isOpen: true,
text,
layoutId,
isPasting: false,
progress: 0,
currentChar: 0,
totalChars: text.length
});
} catch (e) {
console.log(e);
}
}

return (
<div
className="flex h-[32px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/50"
onClick={openPasteDialog}
>
<ClipboardPasteIcon size={16} />
<span>{t('keyboard.pasteWithDialog', 'Paste with Preview')}</span>
</div>
);
};
80 changes: 57 additions & 23 deletions browser/src/components/menu/keyboard/paste.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,62 @@ import { ClipboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';

import { device } from '@/libs/device';
import { CharCodes, ShiftChars } from '@/libs/keyboard/charCodes.ts';
import { getModifierBit } from '@/libs/keyboard/keymap.ts';
import { getLayoutById, initLayoutDetection, LayoutMap } from '@/libs/keyboard/layouts.ts';
import { ModifierBits } from '@/libs/keyboard/keymap.ts';

// Initialize layout detection early
initLayoutDetection();

// Paste text as keystrokes using the specified keyboard layout
export async function pasteText(text: string, layoutId: string = 'auto'): Promise<void> {
const layout: LayoutMap = getLayoutById(layoutId);

// Release all keys first to ensure clean state
await device.sendKeyboardData([0, 0, 0, 0, 0, 0, 0, 0]);
await new Promise((r) => setTimeout(r, 50));

for (const char of text) {
const mapping = layout[char];
if (!mapping) {
console.warn(`No mapping for character: '${char}' (code ${char.charCodeAt(0)})`);
continue;
}

let modifier = 0;
if (mapping.shift) {
modifier |= ModifierBits.LeftShift;
}
if (mapping.altGr) {
// AltGr is typically Right Alt
modifier |= ModifierBits.RightAlt;
}

// For modified keys (Shift/AltGr), press modifier first, then key
// This is more compatible with Windows login screen
if (modifier !== 0) {
await device.sendKeyboardData([modifier, 0, 0, 0, 0, 0, 0, 0]);
await new Promise((r) => setTimeout(r, 20));
}

// Press key (with modifier held)
await device.sendKeyboardData([modifier, 0, mapping.code, 0, 0, 0, 0, 0]);
await new Promise((r) => setTimeout(r, 50));

// Release key (modifier still held)
if (modifier !== 0) {
await device.sendKeyboardData([modifier, 0, 0, 0, 0, 0, 0, 0]);
await new Promise((r) => setTimeout(r, 15));
}

// Release modifier
await device.sendKeyboardData([0, 0, 0, 0, 0, 0, 0, 0]);
if (mapping.altGr) {
await new Promise((r) => setTimeout(r, 20));
await device.sendKeyboardData([0, 0, 0, 0, 0, 0, 0, 0]);
}
await new Promise((r) => setTimeout(r, 30));
}
}

export const Paste = () => {
const { t } = useTranslation();
Expand All @@ -17,34 +71,14 @@ export const Paste = () => {
try {
const text = await navigator.clipboard.readText();
if (!text) return;

for (const char of text) {
const ascii = char.charCodeAt(0);

const code = CharCodes[ascii];
if (!code) continue;

let modifier = 0;
if ((ascii >= 65 && ascii <= 90) || ShiftChars[ascii]) {
modifier |= getModifierBit('ShiftLeft');
}

await send(modifier, code);
await new Promise((r) => setTimeout(r, 50));
await send(0, 0);
}
await pasteText(text);
} catch (e) {
console.log(e);
} finally {
setIsLoading(false);
}
}

async function send(modifier: number, code: number): Promise<void> {
const keys = [modifier, 0, code, 0, 0, 0, 0, 0];
await device.sendKeyboardData(keys);
}

return (
<div
className="flex h-[32px] cursor-pointer items-center space-x-2 rounded px-3 text-neutral-300 hover:bg-neutral-700/50"
Expand Down
Loading