2026-01-06 09:47:03 +00:00
|
|
|
|
import {
|
|
|
|
|
|
customElement,
|
|
|
|
|
|
DeesElement,
|
|
|
|
|
|
type TemplateResult,
|
|
|
|
|
|
html,
|
|
|
|
|
|
property,
|
|
|
|
|
|
css,
|
|
|
|
|
|
cssManager,
|
|
|
|
|
|
state,
|
|
|
|
|
|
} from '@design.estate/dees-element';
|
|
|
|
|
|
import { DeesIcon } from '@design.estate/dees-catalog';
|
|
|
|
|
|
import { demo } from './eco-applauncher-keyboard.demo.js';
|
|
|
|
|
|
|
|
|
|
|
|
// Ensure components are registered
|
|
|
|
|
|
DeesIcon;
|
|
|
|
|
|
|
|
|
|
|
|
declare global {
|
|
|
|
|
|
interface HTMLElementTagNameMap {
|
|
|
|
|
|
'eco-applauncher-keyboard': EcoApplauncherKeyboard;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type TKeyboardLayout = 'qwerty' | 'numbers' | 'symbols';
|
|
|
|
|
|
|
|
|
|
|
|
export interface IKeyConfig {
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
display?: string;
|
|
|
|
|
|
width?: number; // multiplier, default 1
|
|
|
|
|
|
type?: 'char' | 'special' | 'modifier' | 'space' | 'layout';
|
|
|
|
|
|
action?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Long-press alternatives map
|
|
|
|
|
|
const alternativesMap: Record<string, string[]> = {
|
|
|
|
|
|
'a': ['à', 'á', 'â', 'ä', 'æ', 'ã', 'å', 'ā'],
|
|
|
|
|
|
'c': ['ç', 'ć', 'č'],
|
|
|
|
|
|
'e': ['è', 'é', 'ê', 'ë', 'ē', 'ė', 'ę'],
|
|
|
|
|
|
'i': ['î', 'ï', 'í', 'ī', 'į', 'ì'],
|
|
|
|
|
|
'n': ['ñ', 'ń'],
|
|
|
|
|
|
'o': ['ô', 'ö', 'ò', 'ó', 'œ', 'ø', 'ō', 'õ'],
|
|
|
|
|
|
's': ['ß', 'ś', 'š'],
|
|
|
|
|
|
'u': ['û', 'ü', 'ù', 'ú', 'ū'],
|
|
|
|
|
|
'y': ['ÿ'],
|
|
|
|
|
|
'z': ['ž', 'ź', 'ż'],
|
|
|
|
|
|
// Numbers
|
|
|
|
|
|
'0': ['°', '⁰'],
|
|
|
|
|
|
'1': ['¹', '½', '⅓'],
|
|
|
|
|
|
'2': ['²', '⅔'],
|
|
|
|
|
|
'3': ['³', '¾'],
|
|
|
|
|
|
// Punctuation
|
|
|
|
|
|
'-': ['–', '—', '•'],
|
|
|
|
|
|
'/': ['\\'],
|
|
|
|
|
|
'$': ['€', '£', '¥', '¢'],
|
|
|
|
|
|
'&': ['§'],
|
|
|
|
|
|
'"': ['"', '"', '«', '»'],
|
|
|
|
|
|
'.': ['…'],
|
|
|
|
|
|
'?': ['¿'],
|
|
|
|
|
|
'!': ['¡'],
|
|
|
|
|
|
"'": ['\u2018', '\u2019', '`'],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Keyboard layouts
|
|
|
|
|
|
const qwertyLayout: IKeyConfig[][] = [
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: 'q' }, { key: 'w' }, { key: 'e' }, { key: 'r' }, { key: 't' },
|
|
|
|
|
|
{ key: 'y' }, { key: 'u' }, { key: 'i' }, { key: 'o' }, { key: 'p' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: 'a' }, { key: 's' }, { key: 'd' }, { key: 'f' }, { key: 'g' },
|
|
|
|
|
|
{ key: 'h' }, { key: 'j' }, { key: 'k' }, { key: 'l' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: 'shift', display: '⇧', width: 1.5, type: 'modifier' },
|
|
|
|
|
|
{ key: 'z' }, { key: 'x' }, { key: 'c' }, { key: 'v' },
|
|
|
|
|
|
{ key: 'b' }, { key: 'n' }, { key: 'm' },
|
|
|
|
|
|
{ key: 'backspace', display: '⌫', width: 1.5, type: 'special' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: '123', display: '123', width: 1.5, type: 'layout', action: 'numbers' },
|
|
|
|
|
|
{ key: 'globe', display: '🌐', type: 'special' },
|
2026-01-06 09:58:40 +00:00
|
|
|
|
{ key: 'space', display: '', width: 3, type: 'space' },
|
2026-01-06 09:47:03 +00:00
|
|
|
|
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
|
2026-01-06 09:58:40 +00:00
|
|
|
|
{ key: 'up', display: '↑', type: 'special', action: 'arrow-up' },
|
|
|
|
|
|
{ key: 'down', display: '↓', type: 'special', action: 'arrow-down' },
|
2026-01-06 09:47:03 +00:00
|
|
|
|
{ key: 'right', display: '→', type: 'special', action: 'arrow-right' },
|
|
|
|
|
|
{ key: 'enter', display: '↵', width: 1.5, type: 'special' },
|
|
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const numbersLayout: IKeyConfig[][] = [
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: '1' }, { key: '2' }, { key: '3' }, { key: '4' }, { key: '5' },
|
|
|
|
|
|
{ key: '6' }, { key: '7' }, { key: '8' }, { key: '9' }, { key: '0' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: '-' }, { key: '/' }, { key: ':' }, { key: ';' }, { key: '(' },
|
|
|
|
|
|
{ key: ')' }, { key: '$' }, { key: '&' }, { key: '@' }, { key: '"' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: '#+=', display: '#+=' , width: 1.5, type: 'layout', action: 'symbols' },
|
|
|
|
|
|
{ key: '.' }, { key: ',' }, { key: '?' }, { key: '!' }, { key: "'" },
|
|
|
|
|
|
{ key: 'backspace', display: '⌫', width: 2.5, type: 'special' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: 'ABC', display: 'ABC', width: 1.5, type: 'layout', action: 'qwerty' },
|
|
|
|
|
|
{ key: 'globe', display: '🌐', type: 'special' },
|
2026-01-06 09:58:40 +00:00
|
|
|
|
{ key: 'space', display: '', width: 3, type: 'space' },
|
2026-01-06 09:47:03 +00:00
|
|
|
|
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
|
2026-01-06 09:58:40 +00:00
|
|
|
|
{ key: 'up', display: '↑', type: 'special', action: 'arrow-up' },
|
|
|
|
|
|
{ key: 'down', display: '↓', type: 'special', action: 'arrow-down' },
|
2026-01-06 09:47:03 +00:00
|
|
|
|
{ key: 'right', display: '→', type: 'special', action: 'arrow-right' },
|
|
|
|
|
|
{ key: 'enter', display: '↵', width: 1.5, type: 'special' },
|
|
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const symbolsLayout: IKeyConfig[][] = [
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: '[' }, { key: ']' }, { key: '{' }, { key: '}' }, { key: '#' },
|
|
|
|
|
|
{ key: '%' }, { key: '^' }, { key: '*' }, { key: '+' }, { key: '=' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: '_' }, { key: '\\' }, { key: '|' }, { key: '~' }, { key: '<' },
|
|
|
|
|
|
{ key: '>' }, { key: '€' }, { key: '£' }, { key: '¥' }, { key: '•' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: '123', display: '123', width: 1.5, type: 'layout', action: 'numbers' },
|
|
|
|
|
|
{ key: '.' }, { key: ',' }, { key: '?' }, { key: '!' }, { key: "'" },
|
|
|
|
|
|
{ key: 'backspace', display: '⌫', width: 2.5, type: 'special' },
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
{ key: 'ABC', display: 'ABC', width: 1.5, type: 'layout', action: 'qwerty' },
|
|
|
|
|
|
{ key: 'globe', display: '🌐', type: 'special' },
|
2026-01-06 09:58:40 +00:00
|
|
|
|
{ key: 'space', display: '', width: 3, type: 'space' },
|
2026-01-06 09:47:03 +00:00
|
|
|
|
{ key: 'left', display: '←', type: 'special', action: 'arrow-left' },
|
2026-01-06 09:58:40 +00:00
|
|
|
|
{ key: 'up', display: '↑', type: 'special', action: 'arrow-up' },
|
|
|
|
|
|
{ key: 'down', display: '↓', type: 'special', action: 'arrow-down' },
|
2026-01-06 09:47:03 +00:00
|
|
|
|
{ key: 'right', display: '→', type: 'special', action: 'arrow-right' },
|
|
|
|
|
|
{ key: 'enter', display: '↵', width: 1.5, type: 'special' },
|
|
|
|
|
|
],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const layouts: Record<TKeyboardLayout, IKeyConfig[][]> = {
|
|
|
|
|
|
qwerty: qwertyLayout,
|
|
|
|
|
|
numbers: numbersLayout,
|
|
|
|
|
|
symbols: symbolsLayout,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
@customElement('eco-applauncher-keyboard')
|
|
|
|
|
|
export class EcoApplauncherKeyboard extends DeesElement {
|
|
|
|
|
|
public static demo = demo;
|
|
|
|
|
|
public static demoGroup = 'App Launcher';
|
|
|
|
|
|
|
|
|
|
|
|
public static styles = [
|
|
|
|
|
|
cssManager.defaultStyles,
|
|
|
|
|
|
css`
|
|
|
|
|
|
:host {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:host(:not([visible])) {
|
|
|
|
|
|
display: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.keyboard-container {
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 6% 12%)')};
|
|
|
|
|
|
padding: 8px 4px;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.keyboard-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.keyboard-row.offset {
|
|
|
|
|
|
padding-left: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
max-width: 40px;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 22%)')};
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
transition: background 0.1s ease, transform 0.05s ease;
|
|
|
|
|
|
box-shadow: 0 1px 3px ${cssManager.bdTheme('rgba(0, 0, 0, 0.15)', 'rgba(0, 0, 0, 0.4)')};
|
|
|
|
|
|
text-transform: none;
|
|
|
|
|
|
-webkit-tap-highlight-color: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key:active,
|
|
|
|
|
|
.key.pressed {
|
|
|
|
|
|
transform: scale(0.95);
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(220 15% 92%)', 'hsl(240 5% 28%)')};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key.special {
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key.modifier {
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key.modifier.active {
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key.layout {
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(220 10% 88%)', 'hsl(240 5% 16%)')};
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key.space {
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 22%)')};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key.wide-1-5 {
|
|
|
|
|
|
flex: 1.5;
|
|
|
|
|
|
max-width: 60px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key.wide-2 {
|
|
|
|
|
|
flex: 2;
|
|
|
|
|
|
max-width: 80px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.key.wide-2-5 {
|
|
|
|
|
|
flex: 2.5;
|
|
|
|
|
|
max-width: 100px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 09:58:40 +00:00
|
|
|
|
.key.wide-3 {
|
|
|
|
|
|
flex: 3;
|
|
|
|
|
|
max-width: 140px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-06 09:47:03 +00:00
|
|
|
|
.key.wide-4 {
|
|
|
|
|
|
flex: 4;
|
|
|
|
|
|
max-width: 180px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Alternatives popup */
|
|
|
|
|
|
.alternatives-popup {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 2px;
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 6% 18%)')};
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
padding: 6px;
|
|
|
|
|
|
box-shadow: 0 4px 20px ${cssManager.bdTheme('rgba(0, 0, 0, 0.25)', 'rgba(0, 0, 0, 0.6)')};
|
|
|
|
|
|
z-index: 10000;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.alternative-key {
|
|
|
|
|
|
min-width: 36px;
|
|
|
|
|
|
height: 44px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
|
|
|
|
|
transition: background 0.1s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.alternative-key.selected {
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(217 91% 50%)')};
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Key preview on press */
|
|
|
|
|
|
.key-preview {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
width: 48px;
|
|
|
|
|
|
height: 56px;
|
|
|
|
|
|
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(240 5% 25%)')};
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 95%)')};
|
|
|
|
|
|
box-shadow: 0 4px 16px ${cssManager.bdTheme('rgba(0, 0, 0, 0.2)', 'rgba(0, 0, 0, 0.5)')};
|
|
|
|
|
|
z-index: 10000;
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
`,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
@property({ type: Boolean, reflect: true })
|
|
|
|
|
|
accessor visible = false;
|
|
|
|
|
|
|
|
|
|
|
|
@property({ type: String })
|
|
|
|
|
|
accessor layout: TKeyboardLayout = 'qwerty';
|
|
|
|
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
|
accessor shiftActive = false;
|
|
|
|
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
|
accessor capsLock = false;
|
|
|
|
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
|
accessor alternativesPopup: {
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
alternatives: string[];
|
|
|
|
|
|
x: number;
|
|
|
|
|
|
y: number;
|
|
|
|
|
|
selectedIndex: number;
|
|
|
|
|
|
} | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
@state()
|
|
|
|
|
|
accessor keyPreview: { key: string; x: number; y: number } | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
private longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
|
private longPressStartX = 0;
|
|
|
|
|
|
private currentLongPressKey: string | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
public render(): TemplateResult {
|
|
|
|
|
|
const currentLayout = layouts[this.layout];
|
|
|
|
|
|
|
|
|
|
|
|
return html`
|
|
|
|
|
|
<div class="keyboard-container">
|
|
|
|
|
|
${currentLayout.map((row, rowIndex) => this.renderRow(row, rowIndex))}
|
|
|
|
|
|
${this.alternativesPopup ? this.renderAlternativesPopup() : ''}
|
|
|
|
|
|
${this.keyPreview ? this.renderKeyPreview() : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private renderRow(row: IKeyConfig[], rowIndex: number): TemplateResult {
|
|
|
|
|
|
const isSecondRow = rowIndex === 1 && this.layout === 'qwerty';
|
|
|
|
|
|
return html`
|
|
|
|
|
|
<div class="keyboard-row ${isSecondRow ? 'offset' : ''}">
|
|
|
|
|
|
${row.map((keyConfig) => this.renderKey(keyConfig))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private renderKey(config: IKeyConfig): TemplateResult {
|
|
|
|
|
|
const type = config.type || 'char';
|
|
|
|
|
|
const widthClass = config.width ? `wide-${String(config.width).replace('.', '-')}` : '';
|
|
|
|
|
|
const isShift = config.key === 'shift';
|
|
|
|
|
|
const isActive = isShift && (this.shiftActive || this.capsLock);
|
|
|
|
|
|
|
|
|
|
|
|
let displayValue = config.display ?? config.key;
|
|
|
|
|
|
if (type === 'char' && this.layout === 'qwerty') {
|
|
|
|
|
|
displayValue = (this.shiftActive || this.capsLock) ? displayValue.toUpperCase() : displayValue.toLowerCase();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return html`
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="key ${type} ${widthClass} ${isActive ? 'active' : ''}"
|
|
|
|
|
|
@pointerdown=${(e: PointerEvent) => this.handlePointerDown(e, config)}
|
|
|
|
|
|
@pointerup=${(e: PointerEvent) => this.handlePointerUp(e, config)}
|
|
|
|
|
|
@pointerleave=${(e: PointerEvent) => this.handlePointerLeave(e, config)}
|
|
|
|
|
|
@pointermove=${(e: PointerEvent) => this.handlePointerMove(e, config)}
|
|
|
|
|
|
>
|
|
|
|
|
|
${displayValue}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private renderAlternativesPopup(): TemplateResult {
|
|
|
|
|
|
if (!this.alternativesPopup) return html``;
|
|
|
|
|
|
|
|
|
|
|
|
const { alternatives, x, y, selectedIndex } = this.alternativesPopup;
|
|
|
|
|
|
const popupWidth = alternatives.length * 40;
|
|
|
|
|
|
|
|
|
|
|
|
return html`
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="alternatives-popup"
|
|
|
|
|
|
style="left: ${x - popupWidth / 2}px; top: ${y - 60}px;"
|
|
|
|
|
|
>
|
|
|
|
|
|
${alternatives.map((alt, index) => html`
|
|
|
|
|
|
<div class="alternative-key ${index === selectedIndex ? 'selected' : ''}">
|
|
|
|
|
|
${alt}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private renderKeyPreview(): TemplateResult {
|
|
|
|
|
|
if (!this.keyPreview) return html``;
|
|
|
|
|
|
|
|
|
|
|
|
const { key, x, y } = this.keyPreview;
|
|
|
|
|
|
return html`
|
|
|
|
|
|
<div class="key-preview" style="left: ${x - 24}px; top: ${y - 70}px;">
|
|
|
|
|
|
${(this.shiftActive || this.capsLock) ? key.toUpperCase() : key}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private handlePointerDown(e: PointerEvent, config: IKeyConfig): void {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const target = e.currentTarget as HTMLElement;
|
|
|
|
|
|
target.setPointerCapture(e.pointerId);
|
|
|
|
|
|
|
|
|
|
|
|
const type = config.type || 'char';
|
|
|
|
|
|
|
|
|
|
|
|
// Show key preview for character keys
|
|
|
|
|
|
if (type === 'char') {
|
|
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
|
|
|
|
const containerRect = this.shadowRoot!.querySelector('.keyboard-container')!.getBoundingClientRect();
|
|
|
|
|
|
this.keyPreview = {
|
|
|
|
|
|
key: config.key,
|
|
|
|
|
|
x: rect.left + rect.width / 2 - containerRect.left,
|
|
|
|
|
|
y: rect.top - containerRect.top,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Start long press timer for keys with alternatives
|
|
|
|
|
|
const keyLower = config.key.toLowerCase();
|
|
|
|
|
|
if (alternativesMap[keyLower] && type === 'char') {
|
|
|
|
|
|
this.longPressStartX = e.clientX;
|
|
|
|
|
|
this.currentLongPressKey = keyLower;
|
|
|
|
|
|
|
|
|
|
|
|
this.longPressTimer = setTimeout(() => {
|
|
|
|
|
|
const alternatives = alternativesMap[keyLower];
|
|
|
|
|
|
if (alternatives) {
|
|
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
|
|
|
|
const containerRect = this.shadowRoot!.querySelector('.keyboard-container')!.getBoundingClientRect();
|
|
|
|
|
|
this.alternativesPopup = {
|
|
|
|
|
|
key: keyLower,
|
|
|
|
|
|
alternatives,
|
|
|
|
|
|
x: rect.left + rect.width / 2 - containerRect.left,
|
|
|
|
|
|
y: rect.top - containerRect.top,
|
|
|
|
|
|
selectedIndex: -1,
|
|
|
|
|
|
};
|
|
|
|
|
|
this.keyPreview = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private handlePointerMove(e: PointerEvent, config: IKeyConfig): void {
|
|
|
|
|
|
if (this.alternativesPopup) {
|
|
|
|
|
|
// Calculate which alternative is being hovered based on x position
|
|
|
|
|
|
const deltaX = e.clientX - this.longPressStartX;
|
|
|
|
|
|
const alternatives = this.alternativesPopup.alternatives;
|
|
|
|
|
|
const keyWidth = 40;
|
|
|
|
|
|
const totalWidth = alternatives.length * keyWidth;
|
|
|
|
|
|
const startX = -totalWidth / 2;
|
|
|
|
|
|
|
|
|
|
|
|
// Map deltaX to an index
|
|
|
|
|
|
const relativeX = deltaX - startX;
|
|
|
|
|
|
const index = Math.floor(relativeX / keyWidth);
|
|
|
|
|
|
const clampedIndex = Math.max(-1, Math.min(alternatives.length - 1, index));
|
|
|
|
|
|
|
|
|
|
|
|
if (clampedIndex !== this.alternativesPopup.selectedIndex) {
|
|
|
|
|
|
this.alternativesPopup = {
|
|
|
|
|
|
...this.alternativesPopup,
|
|
|
|
|
|
selectedIndex: clampedIndex,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private handlePointerUp(e: PointerEvent, config: IKeyConfig): void {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
this.clearLongPressTimer();
|
|
|
|
|
|
this.keyPreview = null;
|
|
|
|
|
|
|
|
|
|
|
|
const type = config.type || 'char';
|
|
|
|
|
|
|
|
|
|
|
|
// Handle alternatives selection
|
|
|
|
|
|
if (this.alternativesPopup) {
|
|
|
|
|
|
const { selectedIndex, alternatives, key } = this.alternativesPopup;
|
|
|
|
|
|
if (selectedIndex >= 0 && selectedIndex < alternatives.length) {
|
|
|
|
|
|
this.emitKeyPress(alternatives[selectedIndex]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// No alternative selected, emit original key
|
|
|
|
|
|
this.emitKeyPress(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
this.alternativesPopup = null;
|
|
|
|
|
|
this.handleShiftAfterKeyPress();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Handle different key types
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 'char':
|
|
|
|
|
|
const char = (this.shiftActive || this.capsLock)
|
|
|
|
|
|
? config.key.toUpperCase()
|
|
|
|
|
|
: config.key.toLowerCase();
|
|
|
|
|
|
this.emitKeyPress(char);
|
|
|
|
|
|
this.handleShiftAfterKeyPress();
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'special':
|
|
|
|
|
|
this.handleSpecialKey(config);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'modifier':
|
|
|
|
|
|
this.handleModifierKey(config);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'space':
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('space', {
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('key-press', {
|
|
|
|
|
|
detail: { key: ' ', type: 'space' },
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'layout':
|
|
|
|
|
|
this.handleLayoutChange(config);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private handlePointerLeave(e: PointerEvent, config: IKeyConfig): void {
|
|
|
|
|
|
if (!this.alternativesPopup) {
|
|
|
|
|
|
this.clearLongPressTimer();
|
|
|
|
|
|
this.keyPreview = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private clearLongPressTimer(): void {
|
|
|
|
|
|
if (this.longPressTimer) {
|
|
|
|
|
|
clearTimeout(this.longPressTimer);
|
|
|
|
|
|
this.longPressTimer = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
this.currentLongPressKey = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private emitKeyPress(key: string): void {
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('key-press', {
|
|
|
|
|
|
detail: { key, type: 'char' },
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private handleSpecialKey(config: IKeyConfig): void {
|
|
|
|
|
|
switch (config.key) {
|
|
|
|
|
|
case 'backspace':
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('backspace', {
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('key-press', {
|
|
|
|
|
|
detail: { key: 'Backspace', type: 'special' },
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'enter':
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('enter', {
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('key-press', {
|
|
|
|
|
|
detail: { key: 'Enter', type: 'special' },
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'left':
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('arrow', {
|
|
|
|
|
|
detail: { direction: 'left' },
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'right':
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('arrow', {
|
|
|
|
|
|
detail: { direction: 'right' },
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'up':
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('arrow', {
|
|
|
|
|
|
detail: { direction: 'up' },
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'down':
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('arrow', {
|
|
|
|
|
|
detail: { direction: 'down' },
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'globe':
|
|
|
|
|
|
// Could be used for language switching
|
|
|
|
|
|
this.dispatchEvent(new CustomEvent('globe-press', {
|
|
|
|
|
|
bubbles: true,
|
|
|
|
|
|
composed: true,
|
|
|
|
|
|
}));
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private handleModifierKey(config: IKeyConfig): void {
|
|
|
|
|
|
if (config.key === 'shift') {
|
|
|
|
|
|
if (this.capsLock) {
|
|
|
|
|
|
// If caps lock is on, turn it off
|
|
|
|
|
|
this.capsLock = false;
|
|
|
|
|
|
this.shiftActive = false;
|
|
|
|
|
|
} else if (this.shiftActive) {
|
|
|
|
|
|
// Double tap shift = caps lock
|
|
|
|
|
|
this.capsLock = true;
|
|
|
|
|
|
this.shiftActive = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Single tap = shift
|
|
|
|
|
|
this.shiftActive = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private handleShiftAfterKeyPress(): void {
|
|
|
|
|
|
// Turn off shift after typing (unless caps lock is on)
|
|
|
|
|
|
if (this.shiftActive && !this.capsLock) {
|
|
|
|
|
|
this.shiftActive = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private handleLayoutChange(config: IKeyConfig): void {
|
|
|
|
|
|
const action = config.action as TKeyboardLayout;
|
|
|
|
|
|
if (action && layouts[action]) {
|
|
|
|
|
|
this.layout = action;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|