feat(simulator): map pushbuttons to keyboard keys
Any pushbutton (pushbutton / pushbutton-6mm) can now be driven from the keyboard. Assign a key from the component property dialog — a keycap control captures the next keypress (Escape cancels, modifiers alone are rejected) — and a keycap badge next to the component label shows the mapping on the canvas. Several buttons may share one key on purpose; the dialog shows a hint when that happens. At runtime a global bridge translates keydown/keyup into the same button-press / button-release DOM events the mouse fires on the wokwi element, so every simulation path (avr8js pin logic, SPICE-driven inputs, the QEMU GPIO bridge, the pressed visual) behaves identically to a mouse click. Guards: ignored while typing in inputs or the code editor, ignored with Ctrl/Alt/Meta held, auto-repeat collapses into one long press, and window blur releases everything so no button sticks after Alt-Tab. The binding is stored as the component's 'key' property, so it round-trips through project saves and .vlx exports and is undoable like any other property edit. Strings added to all 9 locales.
This commit is contained in:
parent
e72413a13f
commit
5218f7314b
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isKeyBindable,
|
||||
normalizeKey,
|
||||
formatKeyLabel,
|
||||
isModifierKey,
|
||||
buildKeyBindingMap,
|
||||
} from '../utils/keyButtonBindings';
|
||||
|
||||
const comp = (id: string, metadataId: string, key?: unknown) => ({
|
||||
id,
|
||||
metadataId,
|
||||
properties: key === undefined ? {} : { key },
|
||||
});
|
||||
|
||||
describe('keyButtonBindings', () => {
|
||||
it('marks both pushbutton variants as bindable, other parts not', () => {
|
||||
expect(isKeyBindable('pushbutton')).toBe(true);
|
||||
expect(isKeyBindable('pushbutton-6mm')).toBe(true);
|
||||
expect(isKeyBindable('slide-switch')).toBe(false);
|
||||
expect(isKeyBindable('led')).toBe(false);
|
||||
});
|
||||
|
||||
it('normalizes single characters case-insensitively, keeps named keys', () => {
|
||||
expect(normalizeKey('A')).toBe('a');
|
||||
expect(normalizeKey('a')).toBe('a');
|
||||
expect(normalizeKey('ArrowUp')).toBe('ArrowUp');
|
||||
expect(normalizeKey(' ')).toBe(' ');
|
||||
});
|
||||
|
||||
it('formats key labels for the keycap badge', () => {
|
||||
expect(formatKeyLabel('a')).toBe('A');
|
||||
expect(formatKeyLabel(' ')).toBe('Space');
|
||||
expect(formatKeyLabel('ArrowUp')).toBe('Up');
|
||||
expect(formatKeyLabel('Enter')).toBe('Enter');
|
||||
});
|
||||
|
||||
it('rejects lone modifiers', () => {
|
||||
expect(isModifierKey('Shift')).toBe(true);
|
||||
expect(isModifierKey('Meta')).toBe(true);
|
||||
expect(isModifierKey('a')).toBe(false);
|
||||
expect(isModifierKey('Enter')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds the key → component-ids map from component properties', () => {
|
||||
const map = buildKeyBindingMap([
|
||||
comp('b1', 'pushbutton', 'a'),
|
||||
comp('b2', 'pushbutton-6mm', 'A'), // same key, different case
|
||||
comp('b3', 'pushbutton', 'ArrowUp'),
|
||||
comp('b4', 'pushbutton'), // unbound
|
||||
comp('b5', 'pushbutton', ''), // cleared binding
|
||||
comp('led1', 'led', 'a'), // not bindable — ignored
|
||||
comp('b6', 'pushbutton', 42), // junk value — ignored
|
||||
]);
|
||||
expect(map.get('a')).toEqual(['b1', 'b2']);
|
||||
expect(map.get('ArrowUp')).toEqual(['b3']);
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,7 @@ import { useEditorStore } from '../store/useEditorStore';
|
|||
import { buildProjectSdImage, decodeSdFiles } from '../utils/sdCardFiles';
|
||||
import { PartSimulationRegistry } from '../simulation/parts';
|
||||
import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping';
|
||||
import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings';
|
||||
import {
|
||||
createDefaultPinResolver,
|
||||
createSpiceResolvedPinResolver,
|
||||
|
|
@ -684,6 +685,25 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|||
}}
|
||||
>
|
||||
{properties.pin !== undefined ? `Pin ${properties.pin}` : metadata.name}
|
||||
{isKeyBindable(metadata.id) && typeof properties.key === 'string' && properties.key && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '9px',
|
||||
padding: '1px 5px',
|
||||
borderRadius: '3px',
|
||||
backgroundColor: '#2d2d2d',
|
||||
color: '#ddd',
|
||||
border: '1px solid #555',
|
||||
borderBottomWidth: '2px',
|
||||
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.3',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{formatKeyLabel(properties.key)}
|
||||
</span>
|
||||
)}
|
||||
{properties.protocol && (
|
||||
<span
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -418,3 +418,72 @@
|
|||
color: #aaa;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Keyboard binding (pushbuttons) ────────────────────────────────── */
|
||||
|
||||
.keybind-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Keycap-styled chip: shows the bound key, or the capture prompt. */
|
||||
.keybind-chip {
|
||||
min-width: 44px;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
|
||||
color: #fff;
|
||||
background-color: #3d3d3d;
|
||||
border: 1px solid #555;
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.keybind-chip:hover {
|
||||
border-color: var(--color-action-primary);
|
||||
}
|
||||
|
||||
.keybind-chip--empty {
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: #aaa;
|
||||
border-style: dashed;
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.keybind-chip--capturing {
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
color: var(--color-action-primary);
|
||||
border-color: var(--color-action-primary);
|
||||
box-shadow: 0 0 0 2px rgba(0, 113, 227, 0.25);
|
||||
}
|
||||
|
||||
.keybind-clear {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: #aaa;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.keybind-clear:hover {
|
||||
color: #fff;
|
||||
background-color: #3d3d3d;
|
||||
}
|
||||
|
||||
.keybind-hint {
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: #d19a66;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ import { useTranslation } from 'react-i18next';
|
|||
import type { ComponentMetadata } from '../../types/component-metadata';
|
||||
import { SdCardPanel } from './SdCardPanel';
|
||||
import type { UploadedSdFile } from '../../utils/sdCardFiles';
|
||||
import { useSimulatorStore } from '../../store/useSimulatorStore';
|
||||
import {
|
||||
isKeyBindable,
|
||||
isModifierKey,
|
||||
normalizeKey,
|
||||
formatKeyLabel,
|
||||
} from '../../utils/keyButtonBindings';
|
||||
import './ComponentPropertyDialog.css';
|
||||
|
||||
function isEditable(prop: any): boolean {
|
||||
|
|
@ -64,6 +71,47 @@ export const ComponentPropertyDialog: React.FC<ComponentPropertyDialogProps> = (
|
|||
// window.confirm() call which was visually jarring on mobile.
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
// Keyboard binding capture (pushbuttons): while true, the next keydown
|
||||
// becomes the component's `key` property. Escape cancels the capture.
|
||||
const [capturingKey, setCapturingKey] = useState(false);
|
||||
const allComponents = useSimulatorStore((s) => s.components);
|
||||
const boundKey =
|
||||
typeof componentProperties.key === 'string' && componentProperties.key
|
||||
? componentProperties.key
|
||||
: '';
|
||||
const keyInUseElsewhere =
|
||||
!!boundKey &&
|
||||
allComponents.some(
|
||||
(c) =>
|
||||
c.id !== componentId &&
|
||||
isKeyBindable(c.metadataId) &&
|
||||
typeof c.properties.key === 'string' &&
|
||||
!!c.properties.key &&
|
||||
normalizeKey(c.properties.key) === normalizeKey(boundKey),
|
||||
);
|
||||
|
||||
useEffect(() => setCapturingKey(false), [componentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capturingKey) return;
|
||||
const onCaptureKey = (e: KeyboardEvent) => {
|
||||
// Capture-phase + stopPropagation: the pressed key must not reach the
|
||||
// dialog's Escape-close handler, the canvas delete handler, or the
|
||||
// runtime key→button bridge while we're recording it.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Escape') {
|
||||
setCapturingKey(false);
|
||||
return;
|
||||
}
|
||||
if (isModifierKey(e.key)) return; // keep waiting for a real key
|
||||
onPropertyChange?.(componentId, 'key', normalizeKey(e.key));
|
||||
setCapturingKey(false);
|
||||
};
|
||||
window.addEventListener('keydown', onCaptureKey, true);
|
||||
return () => window.removeEventListener('keydown', onCaptureKey, true);
|
||||
}, [capturingKey, componentId, onPropertyChange]);
|
||||
|
||||
// Calculate dialog position on mount — clamp within canvas viewport
|
||||
useEffect(() => {
|
||||
if (!dialogRef.current) return;
|
||||
|
|
@ -248,6 +296,45 @@ export const ComponentPropertyDialog: React.FC<ComponentPropertyDialogProps> = (
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard binding — drive this pushbutton with a keyboard key */}
|
||||
{isKeyBindable(componentMetadata.id) && (
|
||||
<div className="property-edit-section">
|
||||
<div className="property-edit-row">
|
||||
<label className="property-edit-label">
|
||||
{t('editor.componentProps.keyboardKey')}
|
||||
</label>
|
||||
<div className="keybind-controls">
|
||||
<button
|
||||
type="button"
|
||||
className={`keybind-chip${capturingKey ? ' keybind-chip--capturing' : ''}${
|
||||
!boundKey && !capturingKey ? ' keybind-chip--empty' : ''
|
||||
}`}
|
||||
onClick={() => setCapturingKey((c) => !c)}
|
||||
>
|
||||
{capturingKey
|
||||
? t('editor.componentProps.pressAKey')
|
||||
: boundKey
|
||||
? formatKeyLabel(boundKey)
|
||||
: t('editor.componentProps.assignKey')}
|
||||
</button>
|
||||
{boundKey && !capturingKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="keybind-clear"
|
||||
title={t('editor.componentProps.clearKey')}
|
||||
onClick={() => onPropertyChange?.(componentId, 'key', '')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{keyInUseElsewhere && (
|
||||
<div className="keybind-hint">{t('editor.componentProps.keyInUse')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* microSD card — upload your own files (paid). Free auto-copy of the
|
||||
project's files happens at simulation start, not here. */}
|
||||
{componentMetadata.id === 'microsd-card' && (
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import {
|
|||
import { SelectionActionBar } from './SelectionActionBar';
|
||||
import { WireModeBanner } from './WireModeBanner';
|
||||
import { PinPickerDialog } from './PinPickerDialog';
|
||||
import { useButtonKeyBindings } from '../../hooks/useButtonKeyBindings';
|
||||
import './SimulatorCanvas.css';
|
||||
|
||||
/** World-units of tolerance for alignment snap (scales with zoom). */
|
||||
|
|
@ -137,6 +138,9 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
// Active board (for WiFi/BLE status display)
|
||||
const activeBoard = boards.find((b) => b.id === activeBoardId) ?? null;
|
||||
|
||||
// Keyboard → pushbutton bindings (component `key` property).
|
||||
useButtonKeyBindings(components);
|
||||
|
||||
// Legacy derived values for components that still use them
|
||||
const boardType = useSimulatorStore((s) => s.boardType);
|
||||
const boardPosition = useSimulatorStore((s) => s.boardPosition);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Global keyboard bridge for pushbuttons mapped to keys.
|
||||
*
|
||||
* keydown presses every button bound to that key, keyup releases it. The
|
||||
* bridge dispatches synthetic `button-press` / `button-release` events on
|
||||
* the component's wokwi element — the exact events its mouse interaction
|
||||
* fires — so every consumer (BasicParts pin logic, SPICE-driven inputs,
|
||||
* the QEMU GPIO bridge, the pressed visual) behaves identically to a
|
||||
* mouse click with zero per-simulator wiring.
|
||||
*
|
||||
* Guards:
|
||||
* - ignored while typing in inputs / textareas / selects / Monaco
|
||||
* (contentEditable) so mapped letters don't fire while editing code
|
||||
* - ignored with Ctrl/Alt/Meta held (browser & app shortcuts win)
|
||||
* - auto-repeat keydowns are ignored (a held key = one long press)
|
||||
* - window blur releases everything (no stuck buttons after Alt-Tab)
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { buildKeyBindingMap, normalizeKey } from '../utils/keyButtonBindings';
|
||||
|
||||
interface BoundComponent {
|
||||
id: string;
|
||||
metadataId: string;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function componentElement(componentId: string): Element | null {
|
||||
return (
|
||||
document.querySelector(
|
||||
`[data-component-id="${componentId}"] .web-component-container > *`,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function isTypingTarget(target: EventTarget | null): boolean {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el || !el.tagName) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
|
||||
}
|
||||
|
||||
export function useButtonKeyBindings(components: BoundComponent[]): void {
|
||||
// Rebuilt on every render pass where components changed; the listeners
|
||||
// read through the ref so they never go stale without re-binding.
|
||||
const bindingsRef = useRef<Map<string, string[]>>(new Map());
|
||||
bindingsRef.current = buildKeyBindingMap(components);
|
||||
|
||||
// key → component ids currently held down via that key.
|
||||
const downRef = useRef<Map<string, string[]>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const dispatch = (componentId: string, type: 'button-press' | 'button-release') => {
|
||||
componentElement(componentId)?.dispatchEvent(new Event(type));
|
||||
};
|
||||
|
||||
const releaseKey = (norm: string) => {
|
||||
const held = downRef.current.get(norm);
|
||||
if (!held) return;
|
||||
downRef.current.delete(norm);
|
||||
for (const id of held) dispatch(id, 'button-release');
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.repeat || e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (isTypingTarget(e.target)) return;
|
||||
const norm = normalizeKey(e.key);
|
||||
const ids = bindingsRef.current.get(norm);
|
||||
if (!ids || ids.length === 0 || downRef.current.has(norm)) return;
|
||||
e.preventDefault();
|
||||
downRef.current.set(norm, [...ids]);
|
||||
for (const id of ids) dispatch(id, 'button-press');
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
releaseKey(normalizeKey(e.key));
|
||||
};
|
||||
|
||||
const onBlur = () => {
|
||||
for (const norm of Array.from(downRef.current.keys())) releaseKey(norm);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', onBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
onBlur();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -239,7 +239,12 @@
|
|||
"rotate90": "90° drehen",
|
||||
"rotate": "Drehen",
|
||||
"delete": "Löschen",
|
||||
"cancel": "Abbrechen"
|
||||
"cancel": "Abbrechen",
|
||||
"keyboardKey": "Tastaturtaste",
|
||||
"assignKey": "Taste zuweisen",
|
||||
"pressAKey": "Taste drücken…",
|
||||
"clearKey": "Tastenzuweisung entfernen",
|
||||
"keyInUse": "Auch einem anderen Button zugewiesen — beide werden zusammen gedrückt."
|
||||
},
|
||||
"selectionBar": {
|
||||
"label": "Auswahlaktionen",
|
||||
|
|
|
|||
|
|
@ -241,7 +241,12 @@
|
|||
"rotate90": "Rotate 90°",
|
||||
"rotate": "Rotate",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"keyboardKey": "Keyboard key",
|
||||
"assignKey": "Assign key",
|
||||
"pressAKey": "Press a key…",
|
||||
"clearKey": "Remove key binding",
|
||||
"keyInUse": "Also assigned to another button — both will press together."
|
||||
},
|
||||
"selectionBar": {
|
||||
"label": "Selection actions",
|
||||
|
|
|
|||
|
|
@ -241,7 +241,12 @@
|
|||
"rotate90": "Rotar 90°",
|
||||
"rotate": "Rotar",
|
||||
"delete": "Eliminar",
|
||||
"cancel": "Cancelar"
|
||||
"cancel": "Cancelar",
|
||||
"keyboardKey": "Tecla del teclado",
|
||||
"assignKey": "Asignar tecla",
|
||||
"pressAKey": "Pulsa una tecla…",
|
||||
"clearKey": "Quitar tecla",
|
||||
"keyInUse": "También asignada a otro botón: se pulsarán a la vez."
|
||||
},
|
||||
"selectionBar": {
|
||||
"label": "Acciones de selección",
|
||||
|
|
|
|||
|
|
@ -239,7 +239,12 @@
|
|||
"rotate90": "Rotation 90°",
|
||||
"rotate": "Pivoter",
|
||||
"delete": "Supprimer",
|
||||
"cancel": "Annuler"
|
||||
"cancel": "Annuler",
|
||||
"keyboardKey": "Touche du clavier",
|
||||
"assignKey": "Assigner une touche",
|
||||
"pressAKey": "Appuyez sur une touche…",
|
||||
"clearKey": "Supprimer le raccourci",
|
||||
"keyInUse": "Aussi assignée à un autre bouton — les deux seront pressés ensemble."
|
||||
},
|
||||
"selectionBar": {
|
||||
"label": "Actions de sélection",
|
||||
|
|
|
|||
|
|
@ -239,7 +239,12 @@
|
|||
"rotate90": "Ruota 90°",
|
||||
"rotate": "Ruota",
|
||||
"delete": "Elimina",
|
||||
"cancel": "Annulla"
|
||||
"cancel": "Annulla",
|
||||
"keyboardKey": "Tasto della tastiera",
|
||||
"assignKey": "Assegna tasto",
|
||||
"pressAKey": "Premi un tasto…",
|
||||
"clearKey": "Rimuovi il tasto",
|
||||
"keyInUse": "Assegnato anche a un altro pulsante: verranno premuti insieme."
|
||||
},
|
||||
"selectionBar": {
|
||||
"label": "Azioni di selezione",
|
||||
|
|
|
|||
|
|
@ -239,7 +239,12 @@
|
|||
"rotate90": "90°回転",
|
||||
"rotate": "回転",
|
||||
"delete": "削除",
|
||||
"cancel": "キャンセル"
|
||||
"cancel": "キャンセル",
|
||||
"keyboardKey": "キーボードキー",
|
||||
"assignKey": "キーを割り当て",
|
||||
"pressAKey": "キーを押してください…",
|
||||
"clearKey": "キー割り当てを解除",
|
||||
"keyInUse": "他のボタンにも割り当てられています。同時に押されます。"
|
||||
},
|
||||
"selectionBar": {
|
||||
"label": "選択アクション",
|
||||
|
|
|
|||
|
|
@ -239,7 +239,12 @@
|
|||
"rotate90": "Girar 90°",
|
||||
"rotate": "Girar",
|
||||
"delete": "Excluir",
|
||||
"cancel": "Cancelar"
|
||||
"cancel": "Cancelar",
|
||||
"keyboardKey": "Tecla do teclado",
|
||||
"assignKey": "Atribuir tecla",
|
||||
"pressAKey": "Pressione uma tecla…",
|
||||
"clearKey": "Remover tecla",
|
||||
"keyInUse": "Também atribuída a outro botão — os dois serão pressionados juntos."
|
||||
},
|
||||
"selectionBar": {
|
||||
"label": "Ações de seleção",
|
||||
|
|
|
|||
|
|
@ -239,7 +239,12 @@
|
|||
"rotate90": "Повернуть на 90°",
|
||||
"rotate": "Повернуть",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена"
|
||||
"cancel": "Отмена",
|
||||
"keyboardKey": "Клавиша клавиатуры",
|
||||
"assignKey": "Назначить клавишу",
|
||||
"pressAKey": "Нажмите клавишу…",
|
||||
"clearKey": "Убрать привязку клавиши",
|
||||
"keyInUse": "Также назначена другой кнопке — обе будут нажаты одновременно."
|
||||
},
|
||||
"selectionBar": {
|
||||
"label": "Действия с выделением",
|
||||
|
|
|
|||
|
|
@ -239,7 +239,12 @@
|
|||
"rotate90": "旋转 90°",
|
||||
"rotate": "旋转",
|
||||
"delete": "删除",
|
||||
"cancel": "取消"
|
||||
"cancel": "取消",
|
||||
"keyboardKey": "键盘按键",
|
||||
"assignKey": "分配按键",
|
||||
"pressAKey": "请按一个键…",
|
||||
"clearKey": "移除按键绑定",
|
||||
"keyInUse": "也已分配给另一个按钮,两个按钮会同时按下。"
|
||||
},
|
||||
"selectionBar": {
|
||||
"label": "选择操作",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Keyboard-to-button bindings.
|
||||
*
|
||||
* Any pushbutton on the canvas can be mapped to a keyboard key via the
|
||||
* component property dialog. The mapping is stored as the component's
|
||||
* `key` property (so it persists inside components_json / .vlx exports)
|
||||
* and is consumed at runtime by `useButtonKeyBindings`, which translates
|
||||
* keydown/keyup into the same `button-press` / `button-release` DOM
|
||||
* events the mouse fires on the wokwi element — every simulation path
|
||||
* (avr8js, SPICE-driven inputs, the QEMU GPIO bridge) already listens to
|
||||
* those events, so keyboard presses behave exactly like mouse presses.
|
||||
*/
|
||||
|
||||
/** Structural subset of the canvas component shape this module needs. */
|
||||
interface BindableComponent {
|
||||
id: string;
|
||||
metadataId: string;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Component metadata ids that support a keyboard binding. */
|
||||
const KEY_BINDABLE = new Set(['pushbutton', 'pushbutton-6mm']);
|
||||
|
||||
export function isKeyBindable(metadataId: string): boolean {
|
||||
return KEY_BINDABLE.has(metadataId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form of a KeyboardEvent.key for storing and matching.
|
||||
* Single characters are case-folded so a mapping made with CapsLock on
|
||||
* (or matched while Shift is held) still works; named keys ("ArrowUp",
|
||||
* "Enter", " ") are kept verbatim.
|
||||
*/
|
||||
export function normalizeKey(key: string): string {
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
|
||||
/** Compact human label for a stored key (keycap badge / dialog chip). */
|
||||
export function formatKeyLabel(key: string): string {
|
||||
switch (key) {
|
||||
case ' ':
|
||||
return 'Space';
|
||||
case 'ArrowUp':
|
||||
return 'Up';
|
||||
case 'ArrowDown':
|
||||
return 'Down';
|
||||
case 'ArrowLeft':
|
||||
return 'Left';
|
||||
case 'ArrowRight':
|
||||
return 'Right';
|
||||
default:
|
||||
return key.length === 1 ? key.toUpperCase() : key;
|
||||
}
|
||||
}
|
||||
|
||||
/** Modifier and lock keys can't be bound on their own. */
|
||||
export function isModifierKey(key: string): boolean {
|
||||
return ['Shift', 'Control', 'Alt', 'Meta', 'CapsLock', 'NumLock', 'ScrollLock'].includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* normalized key → component ids bound to it. Several buttons may share
|
||||
* one key on purpose (e.g. one physical key closing two parallel
|
||||
* branches), so the value is a list.
|
||||
*/
|
||||
export function buildKeyBindingMap(
|
||||
components: BindableComponent[],
|
||||
): Map<string, string[]> {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const c of components) {
|
||||
const key = c.properties?.key;
|
||||
if (!isKeyBindable(c.metadataId) || typeof key !== 'string' || !key) continue;
|
||||
const norm = normalizeKey(key);
|
||||
const list = map.get(norm);
|
||||
if (list) list.push(c.id);
|
||||
else map.set(norm, [c.id]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
Loading…
Reference in New Issue