Extract main.ts controllers

This commit is contained in:
Jage9
2026-03-08 20:22:46 -04:00
parent 3d9405bff9
commit 50d97ae734
5 changed files with 1881 additions and 1431 deletions

View File

@@ -0,0 +1,606 @@
import { handleListControlKey } from './listController';
import { getEditSessionAction } from './editSession';
import { handleYesNoMenuInput, YES_NO_OPTIONS } from './yesNoMenu';
import type { IncomingMessage, OutgoingMessage } from '../network/protocol';
import type { GameMode } from '../state/gameState';
export type AdminMenuAction = {
id: string;
label: string;
tooltip?: string;
};
export type AdminRoleSummary = {
id: number;
name: string;
isSystem: boolean;
userCount: number;
permissions: string[];
};
export type AdminUserSummary = {
id: string;
username: string;
role: string;
status: 'active' | 'disabled';
};
export type AdminPendingUserMutation =
| { action: 'set_role'; username: string; role: string }
| { action: 'ban'; username: string }
| { action: 'unban'; username: string }
| { action: 'delete_account'; username: string };
type AdminPendingUserAction = 'set_role' | 'ban' | 'unban' | 'delete_account' | null;
type AdminControllerDeps = {
state: {
mode: GameMode;
nicknameInput: string;
cursorPos: number;
};
signalingSend: (message: OutgoingMessage) => void;
announceMenuEntry: (title: string, firstOption: string) => void;
updateStatus: (message: string) => void;
sfxUiBlip: () => void;
sfxUiCancel: () => void;
applyTextInputEdit: (code: string, key: string, maxLength: number, ctrlKey?: boolean, allowReplaceOnNextType?: boolean) => void;
setReplaceTextOnNextType: (value: boolean) => void;
};
/**
* Creates the admin menu/runtime controller so `main.ts` can treat admin flows as one subsystem.
*/
export function createAdminController(deps: AdminControllerDeps): {
setServerAdminMenuActions: (actions: Array<{ id: string; label: string; tooltip?: string }> | null | undefined) => void;
getAvailableAdminActions: () => AdminMenuAction[];
openAdminMenu: () => void;
handleAdminRolesList: (message: Extract<IncomingMessage, { type: 'admin_roles_list' }>) => void;
handleAdminUsersList: (message: Extract<IncomingMessage, { type: 'admin_users_list' }>) => void;
handleAdminActionResult: (message: Extract<IncomingMessage, { type: 'admin_action_result' }>) => void;
handleAdminMenuModeInput: (code: string, key: string) => void;
handleAdminRoleListModeInput: (code: string, key: string) => void;
handleAdminRolePermissionListModeInput: (code: string, key: string) => void;
handleAdminRoleDeleteReplacementModeInput: (code: string, key: string) => void;
handleAdminUserListModeInput: (code: string, key: string) => void;
handleAdminUserRoleSelectModeInput: (code: string, key: string) => void;
handleAdminUserDeleteConfirmModeInput: (code: string, key: string) => void;
handleAdminRoleNameEditModeInput: (code: string, key: string, ctrlKey: boolean) => void;
} {
const adminMenuActions: AdminMenuAction[] = [];
let serverAdminMenuActions: AdminMenuAction[] = [];
let adminMenuIndex = 0;
let adminRoles: AdminRoleSummary[] = [];
let adminRoleIndex = 0;
let adminPermissionKeys: string[] = [];
let adminPermissionTooltips: Record<string, string> = {};
let adminRolePermissionIndex = 0;
let adminRoleDeleteReplacementIndex = 0;
let adminUsers: AdminUserSummary[] = [];
let adminUserIndex = 0;
let adminPendingUserAction: AdminPendingUserAction = null;
let adminSelectedRoleName = '';
let adminSelectedUsername = '';
let adminPendingUserMutation: AdminPendingUserMutation | null = null;
let adminDeleteConfirmIndex = 0;
function setServerAdminMenuActions(actions: Array<{ id: string; label: string; tooltip?: string }> | null | undefined): void {
serverAdminMenuActions = (actions || [])
.map((entry) => ({
id: String(entry.id || '').trim(),
label: String(entry.label || '').trim(),
tooltip: typeof entry.tooltip === 'string' && entry.tooltip.trim().length > 0 ? entry.tooltip.trim() : undefined,
}))
.filter((entry) => entry.id.length > 0 && entry.label.length > 0);
}
function getAvailableAdminActions(): AdminMenuAction[] {
return [...serverAdminMenuActions];
}
function openAdminMenu(): void {
const actions = getAvailableAdminActions();
if (actions.length === 0) {
deps.updateStatus('No admin actions available.');
deps.sfxUiCancel();
return;
}
adminMenuActions.splice(0, adminMenuActions.length, ...actions);
adminMenuIndex = 0;
deps.state.mode = 'adminMenu';
deps.announceMenuEntry('Admin', adminMenuActions[0].label);
}
function handleAdminRolesList(message: Extract<IncomingMessage, { type: 'admin_roles_list' }>): void {
adminRoles = [...message.roles].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
adminPermissionKeys = [...message.permissionKeys].sort((a, b) => a.localeCompare(b));
adminPermissionTooltips = { ...(message.permissionTooltips ?? {}) };
if (adminPendingUserAction === 'set_role' && adminSelectedUsername) {
deps.state.mode = 'adminUserRoleSelect';
const selectedUser = adminUsers.find((entry) => entry.username === adminSelectedUsername);
const currentRoleIndex =
selectedUser ? adminRoles.findIndex((entry) => entry.name === selectedUser.role) : -1;
adminRoleIndex = currentRoleIndex >= 0 ? currentRoleIndex : 0;
const first = adminRoles[0];
if (first && adminRoles[adminRoleIndex]) {
deps.announceMenuEntry('Roles', adminRoles[adminRoleIndex].name);
} else {
deps.updateStatus('No roles available.');
deps.sfxUiCancel();
deps.state.mode = 'normal';
adminPendingUserAction = null;
adminSelectedUsername = '';
}
return;
}
deps.state.mode = 'adminRoleList';
adminRoleIndex = 0;
const first = adminRoles[0];
if (first) {
deps.announceMenuEntry('Roles', `${first.name}, ${first.userCount}`);
} else {
deps.updateStatus('No roles found.');
deps.sfxUiCancel();
}
}
function handleAdminUsersList(message: Extract<IncomingMessage, { type: 'admin_users_list' }>): void {
adminUsers = [...message.users].sort((a, b) => a.username.localeCompare(b.username, undefined, { sensitivity: 'base' }));
if (adminUsers.length === 0) {
deps.updateStatus('No users available.');
deps.sfxUiCancel();
deps.state.mode = 'normal';
adminPendingUserAction = null;
return;
}
deps.state.mode = 'adminUserList';
adminUserIndex = 0;
const first = adminUsers[0];
deps.announceMenuEntry('Users', `${first.username}, ${first.role}, ${first.status}`);
}
function handleAdminActionResult(message: Extract<IncomingMessage, { type: 'admin_action_result' }>): void {
if (message.action === 'role_update_permissions') {
return;
}
const suppressStatusMessage =
message.ok && message.action === 'user_set_role' && adminPendingUserMutation?.action === 'set_role';
if (!suppressStatusMessage) {
deps.updateStatus(message.message);
}
if (!message.ok) {
adminPendingUserMutation = null;
deps.sfxUiCancel();
return;
}
if (adminPendingUserMutation) {
if (adminPendingUserMutation.action === 'set_role') {
const target = adminUsers.find((entry) => entry.username === adminPendingUserMutation.username);
if (target) {
target.role = adminPendingUserMutation.role;
}
if (deps.state.mode === 'adminUserRoleSelect') {
deps.state.mode = 'adminUserList';
adminPendingUserAction = 'set_role';
const userIndex = adminUsers.findIndex((entry) => entry.username === adminPendingUserMutation.username);
if (userIndex >= 0) {
adminUserIndex = userIndex;
const selected = adminUsers[adminUserIndex];
deps.updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`);
}
}
} else if (adminPendingUserMutation.action === 'ban') {
adminUsers = adminUsers.filter((entry) => entry.username !== adminPendingUserMutation.username);
if (deps.state.mode === 'adminUserList' && adminPendingUserAction === 'ban') {
if (adminUsers.length > 0) {
adminUserIndex = Math.max(0, Math.min(adminUserIndex, adminUsers.length - 1));
} else {
deps.state.mode = 'adminMenu';
adminPendingUserAction = null;
}
}
} else if (adminPendingUserMutation.action === 'unban') {
adminUsers = adminUsers.filter((entry) => entry.username !== adminPendingUserMutation.username);
if (deps.state.mode === 'adminUserList' && adminPendingUserAction === 'unban') {
if (adminUsers.length > 0) {
adminUserIndex = Math.max(0, Math.min(adminUserIndex, adminUsers.length - 1));
} else {
deps.state.mode = 'adminMenu';
adminPendingUserAction = null;
}
}
} else if (adminPendingUserMutation.action === 'delete_account') {
adminUsers = adminUsers.filter((entry) => entry.username !== adminPendingUserMutation.username);
if (deps.state.mode === 'adminUserList' && adminPendingUserAction === 'delete_account') {
if (adminUsers.length > 0) {
adminUserIndex = Math.max(0, Math.min(adminUserIndex, adminUsers.length - 1));
} else {
deps.state.mode = 'adminMenu';
adminPendingUserAction = null;
}
}
}
adminPendingUserMutation = null;
}
if (message.action === 'role_create' && message.role) {
adminRoles.push({
id: message.role.id,
name: message.role.name,
isSystem: message.role.isSystem,
userCount: message.role.userCount,
permissions: [...message.role.permissions],
});
adminRoles.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
if (message.action === 'role_delete' && message.roleName) {
adminRoles = adminRoles.filter((entry) => entry.name !== message.roleName);
if (deps.state.mode === 'adminRoleList') {
adminRoleIndex = Math.max(0, Math.min(adminRoleIndex, Math.max(0, adminRoles.length)));
}
}
}
function handleAdminMenuModeInput(code: string, key: string): void {
if (adminMenuActions.length === 0) {
deps.state.mode = 'normal';
return;
}
const control = handleListControlKey(code, key, adminMenuActions, adminMenuIndex, (entry) => entry.label);
if (control.type === 'move') {
adminMenuIndex = control.index;
deps.updateStatus(adminMenuActions[adminMenuIndex].label);
deps.sfxUiBlip();
return;
}
if (code === 'Space') {
deps.updateStatus(adminMenuActions[adminMenuIndex]?.tooltip ?? 'No tooltip available.');
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
const selected = adminMenuActions[adminMenuIndex];
if (!selected) return;
if (selected.id === 'manage_roles') {
deps.signalingSend({ type: 'admin_roles_list' });
deps.updateStatus('Loading roles...');
return;
}
if (selected.id === 'change_user_role') {
adminPendingUserAction = 'set_role';
deps.signalingSend({ type: 'admin_users_list', action: 'set_role' });
deps.updateStatus('Loading users...');
return;
}
if (selected.id === 'ban_user') {
adminPendingUserAction = 'ban';
deps.signalingSend({ type: 'admin_users_list', action: 'ban' });
deps.updateStatus('Loading users...');
return;
}
if (selected.id === 'unban_user') {
adminPendingUserAction = 'unban';
deps.signalingSend({ type: 'admin_users_list', action: 'unban' });
deps.updateStatus('Loading users...');
return;
}
if (selected.id === 'delete_account') {
adminPendingUserAction = 'delete_account';
deps.signalingSend({ type: 'admin_users_list', action: 'delete_account' });
deps.updateStatus('Loading users...');
}
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'normal';
deps.updateStatus('Cancelled.');
deps.sfxUiCancel();
}
}
function handleAdminRoleListModeInput(code: string, key: string): void {
const entries: Array<{ label: string; role?: AdminRoleSummary }> = [
...adminRoles.map((role) => ({ label: `${role.name}, ${role.userCount}`, role })),
{ label: 'Add role' },
];
const control = handleListControlKey(code, key, entries, adminRoleIndex, (entry) => entry.label);
if (control.type === 'move') {
adminRoleIndex = control.index;
deps.updateStatus(entries[adminRoleIndex]?.label || '');
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
const selected = entries[adminRoleIndex];
if (!selected) return;
if (!selected.role) {
deps.state.mode = 'adminRoleNameEdit';
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.updateStatus('New role name.');
deps.sfxUiBlip();
return;
}
adminSelectedRoleName = selected.role.name;
adminRolePermissionIndex = 0;
deps.state.mode = 'adminRolePermissionList';
deps.updateStatus(`${adminSelectedRoleName} permissions.`);
deps.sfxUiBlip();
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'normal';
deps.updateStatus('Cancelled.');
deps.sfxUiCancel();
}
}
function handleAdminRolePermissionListModeInput(code: string, key: string): void {
const role = adminRoles.find((entry) => entry.name === adminSelectedRoleName);
if (!role) {
deps.state.mode = 'adminRoleList';
return;
}
const entries = [...adminPermissionKeys, '__delete_role__'];
const control = handleListControlKey(code, key, entries, adminRolePermissionIndex, (entry) =>
entry === '__delete_role__' ? `Delete role ${role.name}` : `${entry}: ${role.permissions.includes(entry) ? 'on' : 'off'}`,
);
if (control.type === 'move') {
adminRolePermissionIndex = control.index;
const value = entries[adminRolePermissionIndex];
if (value === '__delete_role__') {
deps.updateStatus(`Delete role ${role.name}.`);
} else {
deps.updateStatus(`${value}: ${role.permissions.includes(value) ? 'on' : 'off'}`);
}
deps.sfxUiBlip();
return;
}
if (code === 'Space') {
const value = entries[adminRolePermissionIndex];
if (value === '__delete_role__') {
deps.updateStatus('Delete the current role and reassign affected users.');
} else {
deps.updateStatus(adminPermissionTooltips[value] || 'No tooltip available.');
}
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
const value = entries[adminRolePermissionIndex];
if (value === '__delete_role__') {
if (role.name === 'admin' || role.name === 'user') {
deps.updateStatus('Admin and user roles cannot be deleted.');
deps.sfxUiCancel();
return;
}
const replacementCandidates = adminRoles.filter((entry) => entry.name !== role.name);
if (replacementCandidates.length === 0) {
deps.updateStatus('No replacement role available.');
deps.sfxUiCancel();
return;
}
adminRoleDeleteReplacementIndex = 0;
deps.state.mode = 'adminRoleDeleteReplacement';
deps.updateStatus(`Replacement role: ${replacementCandidates[0].name}.`);
deps.sfxUiBlip();
return;
}
const nextPermissions = new Set(role.permissions);
if (nextPermissions.has(value)) {
nextPermissions.delete(value);
} else {
nextPermissions.add(value);
}
role.permissions = [...nextPermissions].sort((a, b) => a.localeCompare(b));
deps.signalingSend({ type: 'admin_role_update_permissions', role: role.name, permissions: role.permissions });
deps.updateStatus(`${value}: ${role.permissions.includes(value) ? 'on' : 'off'}`);
deps.sfxUiBlip();
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'adminRoleList';
deps.updateStatus('Roles.');
deps.sfxUiCancel();
}
}
function handleAdminRoleDeleteReplacementModeInput(code: string, key: string): void {
const candidates = adminRoles.filter((entry) => entry.name !== adminSelectedRoleName);
if (candidates.length === 0) {
deps.state.mode = 'adminRolePermissionList';
return;
}
const control = handleListControlKey(code, key, candidates, adminRoleDeleteReplacementIndex, (entry) => entry.name);
if (control.type === 'move') {
adminRoleDeleteReplacementIndex = control.index;
deps.updateStatus(candidates[adminRoleDeleteReplacementIndex].name);
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
const replacement = candidates[adminRoleDeleteReplacementIndex];
deps.signalingSend({
type: 'admin_role_delete',
role: adminSelectedRoleName,
replacementRole: replacement.name,
});
deps.state.mode = 'adminRoleList';
deps.updateStatus(`Deleting ${adminSelectedRoleName}...`);
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'adminRolePermissionList';
deps.updateStatus(`${adminSelectedRoleName} permissions.`);
deps.sfxUiCancel();
}
}
function handleAdminUserListModeInput(code: string, key: string): void {
if (adminUsers.length === 0) {
deps.state.mode = 'normal';
adminPendingUserAction = null;
return;
}
const control = handleListControlKey(code, key, adminUsers, adminUserIndex, (entry) => `${entry.username}, ${entry.role}, ${entry.status}`);
if (control.type === 'move') {
adminUserIndex = control.index;
const selected = adminUsers[adminUserIndex];
deps.updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`);
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
const selected = adminUsers[adminUserIndex];
if (!selected) return;
adminSelectedUsername = selected.username;
if (adminPendingUserAction === 'set_role') {
deps.signalingSend({ type: 'admin_roles_list' });
deps.updateStatus(`Select new role for ${selected.username}.`);
return;
}
if (adminPendingUserAction === 'ban') {
adminPendingUserMutation = { action: 'ban', username: selected.username };
deps.signalingSend({ type: 'admin_user_ban', username: selected.username });
adminPendingUserAction = 'ban';
return;
}
if (adminPendingUserAction === 'unban') {
adminPendingUserMutation = { action: 'unban', username: selected.username };
deps.signalingSend({ type: 'admin_user_unban', username: selected.username });
adminPendingUserAction = 'unban';
return;
}
if (adminPendingUserAction === 'delete_account') {
adminDeleteConfirmIndex = 0;
deps.state.mode = 'adminUserDeleteConfirm';
deps.announceMenuEntry(`Delete account ${selected.username}?`, YES_NO_OPTIONS[adminDeleteConfirmIndex].label);
return;
}
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'adminMenu';
adminPendingUserAction = null;
deps.updateStatus('Admin menu.');
deps.sfxUiCancel();
}
}
function handleAdminUserRoleSelectModeInput(code: string, key: string): void {
if (adminRoles.length === 0) {
deps.state.mode = 'normal';
adminPendingUserAction = null;
return;
}
const control = handleListControlKey(code, key, adminRoles, adminRoleIndex, (entry) => entry.name);
if (control.type === 'move') {
adminRoleIndex = control.index;
deps.updateStatus(adminRoles[adminRoleIndex].name);
deps.sfxUiBlip();
return;
}
if (control.type === 'select') {
const selectedRole = adminRoles[adminRoleIndex];
adminPendingUserMutation = { action: 'set_role', username: adminSelectedUsername, role: selectedRole.name };
deps.signalingSend({ type: 'admin_user_set_role', username: adminSelectedUsername, role: selectedRole.name });
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'adminUserList';
deps.updateStatus('Select user.');
deps.sfxUiCancel();
}
}
function handleAdminUserDeleteConfirmModeInput(code: string, key: string): void {
if (!adminSelectedUsername || adminPendingUserAction !== 'delete_account') {
deps.state.mode = 'adminUserList';
return;
}
const control = handleYesNoMenuInput(code, key, adminDeleteConfirmIndex);
if (control.type === 'move') {
adminDeleteConfirmIndex = control.index;
deps.updateStatus(YES_NO_OPTIONS[adminDeleteConfirmIndex].label);
deps.sfxUiBlip();
return;
}
if (control.type === 'cancel') {
deps.state.mode = 'adminUserList';
const selected = adminUsers[adminUserIndex];
if (selected) {
deps.updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`);
} else {
deps.updateStatus('Select user.');
}
deps.sfxUiCancel();
return;
}
if (control.type === 'select') {
const choice = YES_NO_OPTIONS[adminDeleteConfirmIndex];
if (choice.id === 'no') {
deps.state.mode = 'adminUserList';
const selected = adminUsers[adminUserIndex];
if (selected) {
deps.updateStatus(`${selected.username}, ${selected.role}, ${selected.status}.`);
} else {
deps.updateStatus('Select user.');
}
deps.sfxUiCancel();
return;
}
deps.state.mode = 'adminUserList';
deps.updateStatus(`Deleting account ${adminSelectedUsername}...`);
adminPendingUserMutation = { action: 'delete_account', username: adminSelectedUsername };
deps.signalingSend({ type: 'admin_user_delete', username: adminSelectedUsername });
}
}
function handleAdminRoleNameEditModeInput(code: string, key: string, ctrlKey: boolean): void {
const editAction = getEditSessionAction(code);
if (editAction === 'submit') {
const name = deps.state.nicknameInput.trim().toLowerCase();
if (!name) {
deps.updateStatus('Role name required.');
deps.sfxUiCancel();
return;
}
deps.signalingSend({ type: 'admin_role_create', name });
deps.state.mode = 'adminRoleList';
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.updateStatus(`Creating role ${name}...`);
return;
}
if (editAction === 'cancel') {
deps.state.mode = 'adminRoleList';
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.updateStatus('Cancelled.');
deps.sfxUiCancel();
return;
}
deps.applyTextInputEdit(code, key, 32, ctrlKey, true);
}
return {
setServerAdminMenuActions,
getAvailableAdminActions,
openAdminMenu,
handleAdminRolesList,
handleAdminUsersList,
handleAdminActionResult,
handleAdminMenuModeInput,
handleAdminRoleListModeInput,
handleAdminRolePermissionListModeInput,
handleAdminRoleDeleteReplacementModeInput,
handleAdminUserListModeInput,
handleAdminUserRoleSelectModeInput,
handleAdminUserDeleteConfirmModeInput,
handleAdminRoleNameEditModeInput,
};
}

View File

@@ -0,0 +1,182 @@
import type { GameMode } from '../state/gameState';
import type { ModeInput } from './commandTypes';
type KeyboardControllerDeps = {
dom: {
settingsModal: HTMLDivElement;
canvas: HTMLCanvasElement;
};
state: {
running: boolean;
mode: GameMode;
keysPressed: Record<string, boolean>;
nicknameInput: string;
cursorPos: number;
};
isTextEditingMode: (mode: GameMode) => boolean;
closeSettings: () => void;
hasBlockedArrowTeleport: (code: string) => boolean;
handleModeInput: (input: ModeInput) => void;
canOpenCommandPaletteInMode: (mode: GameMode) => boolean;
openCommandPalette: () => void;
shouldForwardModeKeyUp: (mode: GameMode) => boolean;
onModeKeyUp: (input: Pick<ModeInput, 'code' | 'shiftKey'>) => void;
pasteIntoActiveTextInput: (text: string) => boolean;
updateStatus: (message: string) => void;
setReplaceTextOnNextType: (value: boolean) => void;
};
/**
* Wires global keyboard/paste input handlers and leaves mode-specific behavior to injected callbacks.
*/
export function setupKeyboardInputHandlers(deps: KeyboardControllerDeps): void {
let internalClipboardText = '';
function isTypingKey(code: string): boolean {
return code.startsWith('Key') || code === 'Space';
}
function codeFromKey(key: string, location: number): string | null {
if (key === 'Escape' || key === 'Esc') return 'Escape';
if (key === 'Enter' || key === 'Return') return 'Enter';
if (key === 'Backspace') return 'Backspace';
if (key === 'Delete' || key === 'Del') return 'Delete';
if (key === 'ArrowUp' || key === 'Up') return 'ArrowUp';
if (key === 'ArrowDown' || key === 'Down') return 'ArrowDown';
if (key === 'ArrowLeft' || key === 'Left') return 'ArrowLeft';
if (key === 'ArrowRight' || key === 'Right') return 'ArrowRight';
if (key === 'Home') return 'Home';
if (key === 'End') return 'End';
if (key === 'PageUp') return 'PageUp';
if (key === 'PageDown') return 'PageDown';
if (key === 'Tab') return 'Tab';
if (key === ' ' || key === 'Spacebar') return 'Space';
if (key.length === 1) {
if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
if (/^[0-9]$/.test(key)) return `Digit${key}`;
if (key === '!') return 'Digit1';
if (key === '@') return 'Digit2';
if (key === '#') return 'Digit3';
if (key === '$') return 'Digit4';
if (key === '%') return 'Digit5';
if (key === '^') return 'Digit6';
if (key === '&') return 'Digit7';
if (key === '*') return 'Digit8';
if (key === '(') return 'Digit9';
if (key === ')') return 'Digit0';
if (key === '+' && location === 3) return 'NumpadAdd';
if (key === '-' && location === 3) return 'NumpadSubtract';
if (key === '+' || key === '=') return 'Equal';
if (key === '-' || key === '_') return 'Minus';
if (key === '/' || key === '?') return 'Slash';
if (key === ',' || key === '<') return 'Comma';
if (key === '.' || key === '>') return 'Period';
if (key === ';' || key === ':') return 'Semicolon';
if (key === "'" || key === '"') return 'Quote';
if (key === '[' || key === '{') return 'BracketLeft';
if (key === ']' || key === '}') return 'BracketRight';
if (key === '\\' || key === '|') return 'Backslash';
}
return null;
}
function normalizeInputCode(event: KeyboardEvent): string {
if (event.code && event.code !== 'Unidentified') {
return event.code;
}
return codeFromKey(event.key, event.location) ?? event.code ?? '';
}
document.addEventListener('keydown', (event) => {
const code = normalizeInputCode(event);
if (!code) return;
const hasShortcutModifier = event.ctrlKey || event.metaKey;
const input: ModeInput = {
code,
key: event.key,
ctrlKey: hasShortcutModifier,
shiftKey: event.shiftKey,
};
if (!deps.dom.settingsModal.classList.contains('hidden') && code === 'Escape') {
deps.closeSettings();
return;
}
if (!deps.state.running) return;
if (document.activeElement !== deps.dom.canvas) return;
if (event.altKey) return;
if (hasShortcutModifier && !deps.isTextEditingMode(deps.state.mode)) return;
if (deps.hasBlockedArrowTeleport(code)) {
event.preventDefault();
return;
}
const isNativePasteShortcut = hasShortcutModifier && deps.isTextEditingMode(deps.state.mode) && code === 'KeyV';
if ((deps.state.mode !== 'normal' || !code.startsWith('Arrow')) && !isNativePasteShortcut) {
event.preventDefault();
}
if (hasShortcutModifier && deps.isTextEditingMode(deps.state.mode)) {
if (code === 'KeyV') {
return;
}
if (code === 'KeyC') {
const text = deps.state.nicknameInput;
internalClipboardText = text;
void navigator.clipboard?.writeText(text).catch(() => undefined);
deps.updateStatus('copied');
return;
}
if (code === 'KeyX') {
const text = deps.state.nicknameInput;
internalClipboardText = text;
void navigator.clipboard?.writeText(text).catch(() => undefined);
deps.state.nicknameInput = '';
deps.state.cursorPos = 0;
deps.setReplaceTextOnNextType(false);
deps.updateStatus('cut');
return;
}
}
if (isTypingKey(code) && deps.state.keysPressed[code]) return;
const opensCommandPalette =
deps.canOpenCommandPaletteInMode(deps.state.mode) &&
((code === 'KeyK' && event.shiftKey) || code === 'ContextMenu' || (code === 'F10' && event.shiftKey));
if (opensCommandPalette) {
deps.openCommandPalette();
deps.state.keysPressed[code] = true;
return;
}
deps.handleModeInput(input);
deps.state.keysPressed[code] = true;
});
document.addEventListener('keyup', (event) => {
const code = normalizeInputCode(event);
if (code && deps.shouldForwardModeKeyUp(deps.state.mode)) {
deps.onModeKeyUp({
code,
shiftKey: event.shiftKey,
});
}
if (code) {
deps.state.keysPressed[code] = false;
}
if (event.code && event.code !== code) {
deps.state.keysPressed[event.code] = false;
}
});
document.addEventListener('paste', (event) => {
if (document.activeElement !== deps.dom.canvas) return;
if (!deps.state.running) return;
const pasted = event.clipboardData?.getData('text') ?? internalClipboardText;
if (!deps.pasteIntoActiveTextInput(pasted)) return;
event.preventDefault();
deps.updateStatus('pasted');
});
}