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
5 changes: 5 additions & 0 deletions browser/src/components/keyboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 Down Expand Up @@ -69,6 +70,10 @@ export const Keyboard = () => {
}

pressedKeys.current.add(code);

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

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

Expand Down
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
8 changes: 7 additions & 1 deletion browser/src/components/menu/settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Popover } from 'antd';
import { Divider, Popover } from 'antd';
import { BookIcon, DownloadIcon, SettingsIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';

import { KeyboardLayout } from './keyboard-layout';
import { Language } from './language.tsx';
import { PasteSpeedSetting } from './paste-speed';

export const Settings = () => {
const { t } = useTranslation();
Expand All @@ -14,6 +16,10 @@ export const Settings = () => {
const content = (
<div className="flex flex-col space-y-0.5">
<Language />
<KeyboardLayout />
<PasteSpeedSetting />

<Divider style={{ margin: '5px 0 5px 0' }} />

<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
38 changes: 38 additions & 0 deletions browser/src/components/menu/settings/keyboard-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Select } from 'antd';
import { useAtom } from 'jotai';
import { KeyboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';

import { targetKeyboardLayoutAtom } from '@/jotai/keyboard.ts';
import { LAYOUTS } from '@/libs/keyboard/layouts.ts';
import * as storage from '@/libs/storage';

export const KeyboardLayout = () => {
const { t } = useTranslation();
const [layout, setLayout] = useAtom(targetKeyboardLayoutAtom);

const options = Object.entries(LAYOUTS).map(([id, { name }]) => ({
value: id,
label: name,
}));

function handleChange(value: string) {
setLayout(value);
storage.setTargetKeyboardLayout(value);
}

return (
<div className="flex h-[32px] items-center space-x-2 rounded px-3 text-neutral-300">
<KeyboardIcon size={16} />
<span className="text-sm">{t('settings.keyboardLayout.title')}:</span>
<Select
value={layout}
onChange={handleChange}
options={options}
size="small"
className="w-[120px]"
popupMatchSelectWidth={false}
/>
</div>
);
};
86 changes: 86 additions & 0 deletions browser/src/components/menu/settings/paste-speed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useRef } from 'react';
import { InputNumber } from 'antd';
import { useAtom } from 'jotai';
import { ZapIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';

import { pasteSpeedAtom } from '@/jotai/keyboard.ts';
import * as storage from '@/libs/storage';

export const PasteSpeedSetting = () => {
const { t } = useTranslation();
const [speed, setSpeed] = useAtom(pasteSpeedAtom);
const holdStartTime = useRef<number>(0);
const holdInterval = useRef<ReturnType<typeof setInterval> | null>(null);

function handleChange(value: number | null) {
if (value !== null && value >= 1 && value <= 200) {
setSpeed(value);
storage.setPasteSpeed(value);
}
}

function getStep(): number {
const elapsed = Date.now() - holdStartTime.current;
if (elapsed > 5000) return 15;
if (elapsed > 3000) return 5;
return 1;
}

function startHold(direction: 'up' | 'down') {
holdStartTime.current = Date.now();

const update = () => {
const step = getStep();
setSpeed((prev) => {
const newValue = direction === 'up'
? Math.min(200, prev + step)
: Math.max(1, prev - step);
storage.setPasteSpeed(newValue);
return newValue;
});
};

update();
holdInterval.current = setInterval(update, 150);
}

function stopHold() {
if (holdInterval.current) {
clearInterval(holdInterval.current);
holdInterval.current = null;
}
}

return (
<div className="flex h-[32px] items-center space-x-2 rounded px-3 text-neutral-300">
<ZapIcon size={16} />
<span className="text-sm">{t('settings.pasteSpeed.title', 'Paste Speed')}:</span>
<div
className="paste-speed-input"
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (target.closest('.ant-input-number-handler-up')) {
e.preventDefault();
startHold('up');
} else if (target.closest('.ant-input-number-handler-down')) {
e.preventDefault();
startHold('down');
}
}}
onMouseUp={stopHold}
onMouseLeave={stopHold}
>
<InputNumber
value={speed}
onChange={handleChange}
min={1}
max={200}
size="small"
className="w-[125px]"
addonAfter="ms"
/>
</div>
</div>
);
};
6 changes: 6 additions & 0 deletions browser/src/jotai/keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { atom } from 'jotai';

import * as storage from '@/libs/storage';

export const isKeyboardEnableAtom = atom(true);

export const isKeyboardOpenAtom = atom(false);

export const targetKeyboardLayoutAtom = atom(storage.getTargetKeyboardLayout());

export const pasteSpeedAtom = atom<number>(storage.getPasteSpeed());
Loading