Feat: Add AZERTY layout (virtual keyboard) and keyboard layout settings

This pull request introduces the following changes:

* **Added AZERTY Keyboard Layout:**
  Implemented support for the AZERTY keyboard layout in the virtual keyboard. This includes both the visual layout and the corresponding key code mappings.

* **Keyboard Layout Setting:**
  Added a new setting under *"Appearance"* to allow users to select their preferred keyboard layout (QWERTY Win, QWERTY Mac, AZERTY Win, Russian). The selected layout is saved to local storage and applied to the virtual keyboard.
This commit is contained in:
TPM-28
2025-05-10 12:25:21 +02:00
parent f242b5a378
commit 559e6e2556
9 changed files with 220 additions and 40 deletions

View File

@@ -103,3 +103,7 @@
.keyboard-header {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
.hg-candidate-box {
display: none !important;
}

View File

@@ -73,7 +73,8 @@ const en = {
placeholder: 'Please input',
submit: 'Submit',
virtual: 'Keyboard',
ctrlaltdel: 'Ctrl+Alt+Del'
ctrlaltdel: 'Ctrl+Alt+Del',
layout: 'Keyboard Layout'
},
mouse: {
title: 'Mouse',
@@ -210,7 +211,8 @@ const en = {
menuBar: 'Menu Bar',
menuBarDesc: 'Display icons in the menu bar',
webTitle: 'Web Title',
webTitleDesc: 'Customize the web page title'
webTitleDesc: 'Customize the web page title',
keyboardLayout: 'Keyboard Layout'
},
device: {
title: 'Device',

View File

@@ -143,8 +143,13 @@ export function setKeyboardLayout(layout: string) {
localStorage.setItem(KEYBOARD_LAYOUT_KEY, layout);
}
export function getKeyboardLayout() {
return localStorage.getItem(KEYBOARD_LAYOUT_KEY);
export function getKeyboardLayout(): string {
// Return 'azerty' or 'qwerty' (or 'qwerty' if not defined)
const layout = localStorage.getItem(KEYBOARD_LAYOUT_KEY);
if (layout === 'azerty' || layout === 'mac' || layout === 'rus') {
return layout;
}
return 'default';
}
export function setSkipModifyPassword(skip: boolean) {

View File

@@ -84,6 +84,7 @@ export const KeyboardCodes: Map<string, number> = new Map([
['BracketRight', 48],
['Backslash', 49],
['IntlBackslash', 49],
['Backquote_azerty', 100],
['Semicolon', 51],
['Quote', 52],

View File

@@ -1,9 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { AppleOutlined, WindowsOutlined } from '@ant-design/icons';
import { ConfigProvider, Segmented, theme } from 'antd';
import { XIcon } from 'lucide-react';
import clsx from 'clsx';
import { useAtom } from 'jotai';
import { XIcon } from 'lucide-react';
import Keyboard, { KeyboardButtonTheme } from 'react-simple-keyboard';
import { Drawer } from 'vaul';
@@ -12,7 +10,7 @@ import '@/assets/styles/keyboard.css';
import { useMediaQuery } from 'react-responsive';
import { getKeyboardLayout, setKeyboardLayout, getLanguage } from '@/lib/localstorage.ts';
import { getKeyboardLayout } from '@/lib/localstorage.ts';
import { client } from '@/lib/websocket.ts';
import { isKeyboardOpenAtom } from '@/jotai/keyboard.ts';
@@ -26,22 +24,50 @@ import {
specialKeyMap
} from './virtual-keys.ts';
// Helper function to map keys per active layout
function getKeyCode(key: string, layout: string) {
// AZERTY: map <> key (next to left-shift) to OEM_102 usage (100)
if (layout === 'azerty' && key === 'Backquote_azerty') {
return KeyboardCodes.get('Backquote_azerty');
}
// AZERTY: swap A↔Q and Z↔W on French physical positions
if (layout === 'azerty' && key.endsWith('_azerty')) {
const base = key.replace('_azerty', '');
if (base === 'KeyA') return KeyboardCodes.get('KeyQ');
if (base === 'KeyQ') return KeyboardCodes.get('KeyA');
if (base === 'KeyZ') return KeyboardCodes.get('KeyW');
if (base === 'KeyW') return KeyboardCodes.get('KeyZ');
// all other labels use their own code
return KeyboardCodes.get(base);
}
// default (QWERTY / Mac / Rus)
return KeyboardCodes.get(key);
}
export const VirtualKeyboard = () => {
const isBigScreen = useMediaQuery({ minWidth: 850 });
const [isKeyboardOpen, setIsKeyboardOpen] = useAtom(isKeyboardOpenAtom);
const [layout, setLayout] = useState('default');
const [layout, setLayout] = useState(getKeyboardLayout() || 'default');
const [activeModifierKeys, setActiveModifierKeys] = useState<string[]>([]);
const keyboardRef = useRef<any>(null);
// Update the layout when it changes in settings
useEffect(() => {
const keyboardLayout = getKeyboardLayout();
if (keyboardLayout && ['default', 'mac', 'rus'].includes(keyboardLayout)) {
setLayout(keyboardLayout);
// Function to check if the layout has changed
const checkLayout = () => {
const currentLayout = getKeyboardLayout() || 'default';
if (currentLayout !== layout) {
setLayout(currentLayout);
}
};
// Check for layout changes every time the keyboard is opened
if (isKeyboardOpen) {
checkLayout();
}
}, []);
}, [isKeyboardOpen, layout]);
function onKeyPress(key: string) {
if (modifierKeys.includes(key)) {
@@ -67,7 +93,8 @@ export const VirtualKeyboard = () => {
function sendKeydown(key: string) {
const specialKey = specialKeyMap.get(key);
const code = KeyboardCodes.get(specialKey ? specialKey : key);
const code = getKeyCode(specialKey ? specialKey : key, layout);
if (!code) {
console.log('unknown code: ', key);
return;
@@ -132,11 +159,6 @@ export const VirtualKeyboard = () => {
return theme;
}
function selectLayout(value: string) {
setKeyboardLayout(value);
setLayout(value);
}
return (
<Drawer.Root open={isKeyboardOpen} onOpenChange={setIsKeyboardOpen} modal={false}>
<Drawer.Portal>
@@ -148,22 +170,14 @@ export const VirtualKeyboard = () => {
>
{/* header */}
<div className="flex items-center justify-between px-3 py-1">
<div className="flex flex-row">
<ConfigProvider
theme={{
algorithm: theme.defaultAlgorithm
}}
>
<Segmented
options={[
{ label: 'Win', value: 'default', icon: <WindowsOutlined /> },
{ label: 'Mac', value: 'mac', icon: <AppleOutlined /> },
...(getLanguage() === 'ru' ? [{ label: 'Rus', value: 'rus', icon: <WindowsOutlined /> }] : [])
]}
value={layout}
onChange={selectLayout}
/>
</ConfigProvider>
<div className="keyboard-header text-sm font-medium px-2">
{layout === 'default'
? 'QWERTY (Win)'
: layout === 'mac'
? 'QWERTY (Mac)'
: layout === 'azerty'
? 'AZERTY (Win)'
: 'Russian'}
</div>
<div className="flex w-[100px] items-center justify-end">

View File

@@ -26,6 +26,14 @@ export const keyboardOptions = {
'{capslock} RusA RusS RusD RusF RusG RusH RusJ RusK RusL RusSemicolon RusQuote {enter}',
'{shiftleft} RusZ RusX RusC RusV RusB RusN RusM RusComma RusPeriod RusSlash {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
],
azerty: [
'{escape} F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12',
'Backquote_azerty Digit1_azerty Digit2_azerty Digit3_azerty Digit4_azerty Digit5_azerty Digit6_azerty Digit7_azerty Digit8_azerty Digit9_azerty Digit0_azerty Minus_azerty Equal_azerty {backspace}',
'{tab} KeyA_azerty KeyZ_azerty KeyE_azerty KeyR_azerty KeyT_azerty KeyY_azerty KeyU_azerty KeyI_azerty KeyO_azerty KeyP_azerty BracketLeft_azerty BracketRight_azerty Backslash_azerty',
'{capslock} KeyQ_azerty KeyS_azerty KeyD_azerty KeyF_azerty KeyG_azerty KeyH_azerty KeyJ_azerty KeyK_azerty KeyL_azerty Semicolon_azerty Quote_azerty {enter}',
'{shiftleft} KeyW_azerty KeyX_azerty KeyC_azerty KeyV_azerty KeyB_azerty KeyN_azerty KeyM_azerty Comma_azerty Period_azerty Slash_azerty {shiftright}',
'{controlleft} {winleft} {altleft} {space} {altright} {winright} {menu} {controlright}'
]
},
display: {
@@ -134,7 +142,71 @@ export const keyboardOptions = {
RusComma: 'Б',
RusPeriod: 'Ю',
RusSlash: ',<br/>.',
}
// AZERTY specific display keys
// Row 1
'Backquote_azerty': '&#60;<br/>&#62;',
'Digit1_azerty': '&<br/>1',
'Digit2_azerty': 'é<br/>2',
'Digit3_azerty': '"<br/>#',
'Digit4_azerty': '\'<br/>{',
'Digit5_azerty': '(<br/>[',
'Digit6_azerty': '-<br/>|',
'Digit7_azerty': 'è<br/>`',
'Digit8_azerty': '_<br/>\\',
'Digit9_azerty': 'ç<br/>^',
'Digit0_azerty': 'à<br/>@',
'Minus_azerty': ')<br/>]',
'Equal_azerty': '=<br/>}',
// Row 2
'KeyA_azerty': 'A',
'KeyZ_azerty': 'Z',
'KeyE_azerty': 'E<br/>€',
'KeyR_azerty': 'R',
'KeyT_azerty': 'T',
'KeyY_azerty': 'Y',
'KeyU_azerty': 'U',
'KeyI_azerty': 'I',
'KeyO_azerty': 'O',
'KeyP_azerty': 'P',
'BracketLeft_azerty': '¨<br/>^',
'BracketRight_azerty': '£<br/>$',
'Backslash_azerty': 'µ<br/>*',
// Row 3
'KeyQ_azerty': 'Q',
'KeyS_azerty': 'S',
'KeyD_azerty': 'D',
'KeyF_azerty': 'F',
'KeyG_azerty': 'G',
'KeyH_azerty': 'H',
'KeyJ_azerty': 'J',
'KeyK_azerty': 'K',
'KeyL_azerty': 'L',
'Semicolon_azerty': 'M',
'Quote_azerty': '%<br/>ù',
// Row 4
'KeyW_azerty': 'W',
'KeyX_azerty': 'X',
'KeyC_azerty': 'C',
'KeyV_azerty': 'V',
'KeyB_azerty': 'B',
'KeyN_azerty': 'N',
'KeyM_azerty': '?<br/>,',
'Comma_azerty': '.<br/>;',
'Period_azerty': '/<br/>:',
'Slash_azerty': '§<br/>!',
},
// Enable layout-specific display
mergeDisplay: true,
layoutCandidates: {
default: 'default',
shift: 'shift',
azerty: 'azerty'
},
// ...remaining options...
};
// control keys
@@ -241,4 +313,4 @@ export const doubleKeys = [
'Comma',
'Period',
'Slash'
];
];

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Language } from './language.tsx';
import { MenuBar } from './menu-bar.tsx';
import { WebTitle } from './web-title.tsx';
import { KeyboardLayout } from './keyboard-layout.tsx';
export const Appearance = () => {
const { t } = useTranslation();
@@ -16,6 +17,7 @@ export const Appearance = () => {
<div className="flex flex-col space-y-6">
<Language />
<WebTitle />
<KeyboardLayout />
</div>
<Divider />

View File

@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react';
import { Select } from 'antd';
import { KeyboardIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as ls from '@/lib/localstorage.ts';
export const KeyboardLayout = () => {
const { t } = useTranslation();
const [layout, setLayout] = useState<string>('default');
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const savedLayout = ls.getKeyboardLayout();
if (savedLayout) {
setLayout(savedLayout);
}
setIsLoading(false);
}, []);
const options = [
{ value: 'default', label: 'QWERTY (US)' },
{ value: 'azerty', label: 'AZERTY (FR)' },
{ value: 'mac', label: 'QWERTY (Mac)' },
{ value: 'rus', label: 'Russian' }
];
function changeLayout(value: string) {
if (layout === value) return;
setLayout(value);
ls.setKeyboardLayout(value);
}
return (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-1">
<KeyboardIcon size={16} />
<span>{t('settings.appearance.keyboardLayout', { defaultValue: 'Keyboard Layout' })}</span>
</div>
<Select
value={layout}
style={{ width: 180 }}
options={options}
loading={isLoading}
onChange={changeLayout}
/>
</div>
);
};

View File

@@ -0,0 +1,30 @@
import { useState } from 'react';
import { Select } from 'antd';
import { useTranslation } from 'react-i18next';
import { getKeyboardLayout, setKeyboardLayout } from '@/lib/localstorage.ts';
export const KeyboardLayoutSetting = () => {
const { t } = useTranslation();
const [layout, setLayout] = useState(getKeyboardLayout() || 'default');
function handleChange(value: string) {
setLayout(value);
setKeyboardLayout(value);
}
return (
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium">{t('settings.keyboard.layout')}</label>
<Select
value={layout}
onChange={handleChange}
options={[
{ value: 'default', label: 'QWERTY (Win)' },
{ value: 'mac', label: 'QWERTY (Mac)' },
{ value: 'azerty', label: 'AZERTY (Win)' }
]}
/>
</div>
);
};