11import { createControlsToolApiRepository } from "../toolbox/controls/controls-api-client.js" ;
22import InputService from "../src/engine/input/InputService.js" ;
3+ import InputCaptureService from "../src/engine/input/InputCaptureService.js" ;
34import { gamepadProfileInputNames } from "../src/engine/input/GamepadInputClassifier.js" ;
45import {
56 normalizeProfileInputMappings ,
@@ -9,8 +10,10 @@ import {
910} from "../src/engine/input/NormalizedInputRegistry.js" ;
1011
1112const DEVICE_POLL_INTERVAL_MS = 1200 ;
13+ const INPUT_CAPTURE_TIMEOUT_MS = 5000 ;
1214const KEYBOARD_INPUTS = Object . freeze ( [ "KeyW" , "KeyA" , "KeyS" , "KeyD" , "Space" , "Enter" , "Escape" , "KeyP" ] ) ;
1315const MOUSE_INPUTS = Object . freeze ( [ "MouseButton0" , "MouseButton2" , "MouseX" , "MouseY" ] ) ;
16+ const KEYBOARD_MOUSE_EXCLUDED_NORMALIZED_PREFIXES = Object . freeze ( [ "dpad." , "trigger." ] ) ;
1417const SUPPORTED_CONTROL_TYPES = Object . freeze ( [
1518 "Keyboard Key" ,
1619 "Mouse Button" ,
@@ -126,9 +129,11 @@ export class AccountUserControlsPage {
126129 this . root = root ;
127130 this . repository = createControlsToolApiRepository ( ) ;
128131 this . inputService = new InputService ( { target : window } ) ;
132+ this . captureService = new InputCaptureService ( { inputService : this . inputService } ) ;
129133 this . profiles = [ ] ;
130134 this . editingProfile = null ;
131135 this . devicePollingTimer = null ;
136+ this . inputCapture = null ;
132137 this . elements = {
133138 addProfile : root . querySelector ( "[data-account-user-controls-add-profile]" ) ,
134139 defaultLists : [ ...root . querySelectorAll ( "[data-account-user-controls-default-list]" ) ] ,
@@ -398,6 +403,31 @@ export class AccountUserControlsPage {
398403 return "No game controller profiles saved yet." ;
399404 }
400405
406+ tableColumnCountForFamily ( family ) {
407+ return family === "Keyboard" ? 4 : 7 ;
408+ }
409+
410+ normalizedOptionsForProfile ( profile ) {
411+ const allOptions = this . inputService . getNormalizedInputOptions ( ) ;
412+ const normalizedFamily = this . profileListFamily ( profile ) ;
413+ const filteredOptions = normalizedFamily === "Keyboard" || normalizedFamily === "Mouse"
414+ ? allOptions . filter ( ( option ) =>
415+ ! KEYBOARD_MOUSE_EXCLUDED_NORMALIZED_PREFIXES . some ( ( prefix ) => option . value . startsWith ( prefix ) ) ,
416+ )
417+ : allOptions ;
418+ return [
419+ { label : "Unassigned" , value : "" } ,
420+ ...filteredOptions ,
421+ ] ;
422+ }
423+
424+ generatedInputHeadings ( profile ) {
425+ if ( this . profileListFamily ( profile ) === "Keyboard" ) {
426+ return [ "Physical Input" , "Normalized Control" ] ;
427+ }
428+ return [ "Physical Input" , "Normalized Control" , "Deadzone" , "Invert" , "Sensitivity" ] ;
429+ }
430+
401431 renderProfiles ( ) {
402432 if ( ! this . elements . lists . length ) {
403433 return ;
@@ -423,7 +453,7 @@ export class AccountUserControlsPage {
423453 if ( ! rows . length ) {
424454 const row = document . createElement ( "tr" ) ;
425455 const cell = document . createElement ( "td" ) ;
426- cell . colSpan = 7 ;
456+ cell . colSpan = this . tableColumnCountForFamily ( family ) ;
427457 cell . textContent = this . emptyProfileMessage ( family ) ;
428458 row . append ( cell ) ;
429459 rows . push ( row ) ;
@@ -475,14 +505,23 @@ export class AccountUserControlsPage {
475505 renderProfileRow ( profile ) {
476506 const row = document . createElement ( "tr" ) ;
477507 row . dataset . accountUserControlsProfileRow = profile . id ;
478- row . append (
479- tableCell ( profile . controllerName ) ,
480- tableCell ( `${ profile . inputMappings . length } Physical Inputs` ) ,
481- tableCell ( this . profileInputSummary ( profile ) ) ,
482- tableCell ( this . profileAnalogSummary ( profile ) ) ,
483- tableCell ( profile . inputMappings . some ( ( mapping ) => mapping . invert ) ? "Invert configured" : "No invert" ) ,
484- tableCell ( this . profileSensitivitySummary ( profile ) ) ,
485- ) ;
508+ const family = this . profileListFamily ( profile ) ;
509+ if ( family === "Keyboard" ) {
510+ row . append (
511+ tableCell ( profile . controllerName ) ,
512+ tableCell ( `${ profile . inputMappings . length } Physical Inputs` ) ,
513+ tableCell ( this . profileInputSummary ( profile ) ) ,
514+ ) ;
515+ } else {
516+ row . append (
517+ tableCell ( profile . controllerName ) ,
518+ tableCell ( `${ profile . inputMappings . length } Physical Inputs` ) ,
519+ tableCell ( this . profileInputSummary ( profile ) ) ,
520+ tableCell ( this . profileAnalogSummary ( profile ) ) ,
521+ tableCell ( profile . inputMappings . some ( ( mapping ) => mapping . invert ) ? "Invert configured" : "No invert" ) ,
522+ tableCell ( this . profileSensitivitySummary ( profile ) ) ,
523+ ) ;
524+ }
486525 const actions = document . createElement ( "td" ) ;
487526 const group = document . createElement ( "div" ) ;
488527 group . className = "action-group action-group--tight" ;
@@ -498,26 +537,27 @@ export class AccountUserControlsPage {
498537 inputControl ( profile , inputMapping , index ) {
499538 const row = document . createElement ( "tr" ) ;
500539 row . dataset . accountUserControlsInputPair = "true" ;
501- const editablePhysicalInput = profile . deviceType === "Keyboard" || profile . deviceType === "Mouse" ;
540+ const family = this . profileListFamily ( profile ) ;
541+ const editablePhysicalInput = family === "Keyboard" || family === "Mouse" ;
502542 const physicalInputControl = editablePhysicalInput
503543 ? document . createElement ( "input" )
504544 : document . createElement ( "strong" ) ;
505545 const physicalInputCell = document . createElement ( "td" ) ;
506546 if ( editablePhysicalInput ) {
507547 physicalInputControl . type = "text" ;
508548 physicalInputControl . value = inputMapping . physicalInput ;
549+ physicalInputControl . readOnly = true ;
509550 physicalInputControl . dataset . accountUserControlsPhysicalInput = String ( index ) ;
510- physicalInputControl . setAttribute ( "aria-label" , `${ profile . deviceType } physical input` ) ;
551+ physicalInputControl . dataset . accountUserControlsCaptureDevice = family ;
552+ physicalInputControl . title = `Click to capture the next ${ family . toLowerCase ( ) } input.` ;
553+ physicalInputControl . setAttribute ( "aria-label" , `${ family } physical input` ) ;
511554 } else {
512555 physicalInputControl . textContent = inputMapping . physicalInput ;
513556 }
514557 physicalInputCell . append ( physicalInputControl ) ;
515558 const stack = document . createElement ( "div" ) ;
516559 stack . className = "content-stack content-stack--compact" ;
517- const normalizedOptions = [
518- { label : "Unassigned" , value : "" } ,
519- ...this . inputService . getNormalizedInputOptions ( ) ,
520- ] ;
560+ const normalizedOptions = this . normalizedOptionsForProfile ( profile ) ;
521561 if ( physicalInputIsAnalog ( inputMapping . physicalInput ) ) {
522562 const negativeSelect = selectControl ( {
523563 ariaLabel : `${ inputMapping . physicalInput } negative normalized control` ,
@@ -532,6 +572,13 @@ export class AccountUserControlsPage {
532572 } ) ;
533573 positiveSelect . dataset . accountUserControlsInputPositive = String ( index ) ;
534574 stack . append ( labeledControl ( "Negative" , negativeSelect ) , labeledControl ( "Positive" , positiveSelect ) ) ;
575+ } else if ( family === "Keyboard" ) {
576+ const value = normalizeText ( inputMapping . normalizedInput ) || "Unassigned" ;
577+ const readonlyControl = document . createElement ( "span" ) ;
578+ readonlyControl . className = "status" ;
579+ readonlyControl . dataset . accountUserControlsInputNormalizedReadonly = String ( index ) ;
580+ readonlyControl . textContent = value ;
581+ stack . append ( readonlyControl ) ;
535582 } else {
536583 const select = selectControl ( {
537584 ariaLabel : `${ inputMapping . physicalInput } normalized control` ,
@@ -546,6 +593,11 @@ export class AccountUserControlsPage {
546593 validation . dataset . accountUserControlsInputValidation = String ( index ) ;
547594 stack . append ( validation ) ;
548595
596+ if ( family === "Keyboard" ) {
597+ row . append ( physicalInputCell , controlCell ( stack ) ) ;
598+ return row ;
599+ }
600+
549601 const deadzoneCell = document . createElement ( "td" ) ;
550602 if ( physicalInputSupportsTuning ( inputMapping . physicalInput ) ) {
551603 const deadzone = document . createElement ( "input" ) ;
@@ -598,6 +650,7 @@ export class AccountUserControlsPage {
598650
599651 renderEditingRows ( values = { } ) {
600652 const profile = this . normalizeProfile ( values ) ;
653+ const family = this . profileListFamily ( profile ) ;
601654 const row = document . createElement ( "tr" ) ;
602655 row . dataset . accountUserControlsEditingRow = "true" ;
603656 const controllerName = document . createElement ( "input" ) ;
@@ -621,20 +674,29 @@ export class AccountUserControlsPage {
621674 actionButton ( "Cancel" , "accountUserControlsCancel" ) ,
622675 ) ;
623676 actions . append ( group ) ;
624- row . append (
625- controllerCell ,
626- tableCell ( `${ profile . inputMappings . length } Physical Inputs` ) ,
627- tableCell ( this . profileInputSummary ( profile ) ) ,
628- tableCell ( this . profileAnalogSummary ( profile ) ) ,
629- tableCell ( profile . inputMappings . some ( ( mapping ) => mapping . invert ) ? "Invert configured" : "No invert" ) ,
630- tableCell ( this . profileSensitivitySummary ( profile ) ) ,
631- actions ,
632- ) ;
677+ if ( family === "Keyboard" ) {
678+ row . append (
679+ controllerCell ,
680+ tableCell ( `${ profile . inputMappings . length } Physical Inputs` ) ,
681+ tableCell ( this . profileInputSummary ( profile ) ) ,
682+ actions ,
683+ ) ;
684+ } else {
685+ row . append (
686+ controllerCell ,
687+ tableCell ( `${ profile . inputMappings . length } Physical Inputs` ) ,
688+ tableCell ( this . profileInputSummary ( profile ) ) ,
689+ tableCell ( this . profileAnalogSummary ( profile ) ) ,
690+ tableCell ( profile . inputMappings . some ( ( mapping ) => mapping . invert ) ? "Invert configured" : "No invert" ) ,
691+ tableCell ( this . profileSensitivitySummary ( profile ) ) ,
692+ actions ,
693+ ) ;
694+ }
633695
634696 const detailsRow = document . createElement ( "tr" ) ;
635697 detailsRow . dataset . accountUserControlsEditingDetails = "true" ;
636698 const detailsCell = document . createElement ( "td" ) ;
637- detailsCell . colSpan = 7 ;
699+ detailsCell . colSpan = this . tableColumnCountForFamily ( family ) ;
638700 const wrapper = document . createElement ( "div" ) ;
639701 wrapper . className = "table-wrapper" ;
640702 const table = document . createElement ( "table" ) ;
@@ -643,7 +705,7 @@ export class AccountUserControlsPage {
643705 table . setAttribute ( "aria-label" , `${ profile . controllerName } generated controller inputs` ) ;
644706 const head = document . createElement ( "thead" ) ;
645707 const headRow = document . createElement ( "tr" ) ;
646- [ "Physical Input" , "Normalized Control" , "Deadzone" , "Invert" , "Sensitivity" ] . forEach ( ( heading ) => {
708+ this . generatedInputHeadings ( profile ) . forEach ( ( heading ) => {
647709 const cell = document . createElement ( "th" ) ;
648710 cell . textContent = heading ;
649711 headRow . append ( cell ) ;
@@ -799,7 +861,83 @@ export class AccountUserControlsPage {
799861 } ) ;
800862 }
801863
864+ clearInputCapture ( { restore = false } = { } ) {
865+ if ( ! this . inputCapture ) {
866+ return ;
867+ }
868+ const { listeners, previousValue, target, timer } = this . inputCapture ;
869+ if ( timer ) {
870+ window . clearTimeout ( timer ) ;
871+ }
872+ listeners . forEach ( ( { handler, type } ) => {
873+ window . removeEventListener ( type , handler , true ) ;
874+ } ) ;
875+ if ( restore && target ?. isConnected ) {
876+ target . value = previousValue ;
877+ }
878+ this . inputCapture = null ;
879+ }
880+
881+ capturedInputBinding ( deviceType , event ) {
882+ if ( deviceType === "Keyboard" ) {
883+ return this . captureService . captureKeyboard ( event ) ?. binding || "" ;
884+ }
885+ if ( event . type === "wheel" ) {
886+ return this . captureService . captureWheel ( event ) ?. binding || "" ;
887+ }
888+ return this . captureService . captureMouse ( event ) ?. binding || "" ;
889+ }
890+
891+ beginPhysicalInputCapture ( target ) {
892+ const deviceType = target . dataset . accountUserControlsCaptureDevice || "" ;
893+ if ( deviceType !== "Keyboard" && deviceType !== "Mouse" ) {
894+ return ;
895+ }
896+ const previousValue = target . value ;
897+ this . clearInputCapture ( { restore : true } ) ;
898+ target . value = "" ;
899+ target . focus ( ) ;
900+ const captureLabel = deviceType === "Keyboard" ? "keyboard key" : "mouse input" ;
901+ this . setStatus ( `Waiting for ${ captureLabel } . Previous value returns after 5 seconds with no input.` ) ;
902+ const listeners = [ ] ;
903+ const finishCapture = ( event ) => {
904+ event . preventDefault ( ) ;
905+ event . stopPropagation ( ) ;
906+ const binding = this . capturedInputBinding ( deviceType , event ) ;
907+ if ( ! binding ) {
908+ return ;
909+ }
910+ this . clearInputCapture ( ) ;
911+ if ( target . isConnected ) {
912+ target . value = binding ;
913+ target . focus ( ) ;
914+ }
915+ this . setStatus ( `Captured ${ binding } . Save the profile to keep it.` ) ;
916+ } ;
917+ const timeout = window . setTimeout ( ( ) => {
918+ this . clearInputCapture ( { restore : true } ) ;
919+ this . setStatus ( `No ${ captureLabel } captured. Restored ${ previousValue } .` ) ;
920+ } , INPUT_CAPTURE_TIMEOUT_MS ) ;
921+ this . inputCapture = {
922+ listeners,
923+ previousValue,
924+ target,
925+ timer : timeout ,
926+ } ;
927+ window . setTimeout ( ( ) => {
928+ if ( ! this . inputCapture || this . inputCapture . target !== target ) {
929+ return ;
930+ }
931+ const types = deviceType === "Keyboard" ? [ "keydown" ] : [ "mousedown" , "wheel" ] ;
932+ types . forEach ( ( type ) => {
933+ window . addEventListener ( type , finishCapture , true ) ;
934+ listeners . push ( { handler : finishCapture , type } ) ;
935+ } ) ;
936+ } , 0 ) ;
937+ }
938+
802939 saveEditingProfile ( ) {
940+ this . clearInputCapture ( ) ;
803941 const profile = this . profileFromEditingRow ( ) ;
804942 const validation = this . validateProfile ( profile ) ;
805943 this . renderInputValidation ( validation ) ;
@@ -820,24 +958,34 @@ export class AccountUserControlsPage {
820958 }
821959
822960 handleListClick ( event ) {
961+ const physicalInputTarget = event . target instanceof Element
962+ ? event . target . closest ( "[data-account-user-controls-physical-input]" )
963+ : null ;
964+ if ( physicalInputTarget instanceof HTMLInputElement ) {
965+ this . beginPhysicalInputCapture ( physicalInputTarget ) ;
966+ return ;
967+ }
823968 const target = event . target instanceof Element ? event . target . closest ( "button" ) : null ;
824969 if ( ! target ) {
825970 return ;
826971 }
827972 if ( target . dataset . accountUserControlsSave !== undefined ) {
828973 this . saveEditingProfile ( ) ;
829974 } else if ( target . dataset . accountUserControlsCancel !== undefined ) {
975+ this . clearInputCapture ( { restore : true } ) ;
830976 this . editingProfile = null ;
831977 this . renderProfiles ( ) ;
832978 this . setStatus ( "Canceled user control profile edit." ) ;
833979 } else if ( target . dataset . accountUserControlsEdit !== undefined ) {
980+ this . clearInputCapture ( { restore : true } ) ;
834981 const profile = this . profiles . find ( ( candidate ) => candidate . id === target . dataset . accountUserControlsEdit ) ;
835982 if ( profile ) {
836983 this . editingProfile = { id : profile . id , values : profile } ;
837984 this . renderProfiles ( ) ;
838985 this . setStatus ( `Editing ${ profile . mappingProfile } .` ) ;
839986 }
840987 } else if ( target . dataset . accountUserControlsTrash !== undefined ) {
988+ this . clearInputCapture ( { restore : true } ) ;
841989 const profileId = target . dataset . accountUserControlsTrash || "" ;
842990 this . profiles = this . profiles . filter ( ( profile ) => profile . id !== profileId ) ;
843991 this . saveProfiles ( this . profiles ) ;
0 commit comments