313313 padding : 8px 0 ;
314314 }
315315
316+ .timeline : focus-visible {
317+ outline : 2px solid color-mix (in srgb, var (--accent ) 45% , transparent);
318+ outline-offset : -2px ;
319+ border-radius : 14px ;
320+ }
321+
316322 .day-sep {
317323 position : sticky;
318324 top : 0 ;
@@ -637,7 +643,7 @@ <h1>Codex Timeline Viewer</h1>
637643 < div class ="big "> < span id ="stats-showing "> 0</ span > < span class ="muted "> showing</ span > < span class ="muted "> /</ span > < span id ="stats-total "> 0</ span > < span class ="muted "> events</ span > </ div >
638644 < div class ="muted " id ="stats-range "> —</ div >
639645 </ div >
640- < div id ="timeline " class ="timeline " role ="list "> </ div >
646+ < div id ="timeline " class ="timeline " role ="list " tabindex =" 0 " aria-label =" Events list " > </ div >
641647 </ section >
642648
643649 < aside class ="panel detail " aria-label ="Details ">
@@ -715,6 +721,7 @@ <h1>Codex Timeline Viewer</h1>
715721
716722 let lastActiveElementBeforeModal = null ;
717723 let lastBodyOverflow = null ;
724+ let suppressUrlStateSync = false ;
718725
719726 function setStatus ( message , kind = 'good' ) {
720727 if ( ! message ) {
@@ -974,6 +981,103 @@ <h1>Codex Timeline Viewer</h1>
974981 setOptions ( els . typeFilter , Array . from ( typeSet ) . sort ( ) , state . filters . type ) ;
975982 setOptions ( els . payloadFilter , Array . from ( payloadSet ) . sort ( ) , state . filters . payload ) ;
976983 setOptions ( els . roleFilter , Array . from ( roleSet ) . sort ( ) , state . filters . role ) ;
984+
985+ // Only clamp unknown filter values once we have real options (i.e. after loading a file).
986+ if ( events . length ) {
987+ state . filters . type = els . typeFilter . value || 'all' ;
988+ state . filters . payload = els . payloadFilter . value || 'all' ;
989+ state . filters . role = els . roleFilter . value || 'all' ;
990+ }
991+ }
992+
993+ function readHashParams ( ) {
994+ const raw = window . location . hash && window . location . hash . startsWith ( '#' )
995+ ? window . location . hash . slice ( 1 )
996+ : '' ;
997+ return new URLSearchParams ( raw ) ;
998+ }
999+
1000+ function writeHashParams ( params ) {
1001+ const base = window . location . href . split ( '#' ) [ 0 ] ;
1002+ const hash = params . toString ( ) ;
1003+ const next = base + ( hash ? `#${ hash } ` : '' ) ;
1004+ window . history . replaceState ( null , '' , next ) ;
1005+ }
1006+
1007+ function syncUrlHashFromControls ( { clearSel = false } = { } ) {
1008+ if ( suppressUrlStateSync ) return ;
1009+ const params = readHashParams ( ) ;
1010+ params . set ( 'tz' , state . tzMode ) ;
1011+ params . set ( 'q' , ( els . search . value || '' ) . trim ( ) ) ;
1012+ params . set ( 'type' , state . filters . type || 'all' ) ;
1013+ params . set ( 'payload' , state . filters . payload || 'all' ) ;
1014+ params . set ( 'role' , state . filters . role || 'all' ) ;
1015+ params . set ( 'hide' , state . filters . hideTokenCount ? '1' : '0' ) ;
1016+ params . set ( 'truncate' , els . truncateStrings . checked ? '1' : '0' ) ;
1017+ const selectedLine = state . selected ?. line ;
1018+ if ( typeof selectedLine === 'number' && Number . isFinite ( selectedLine ) ) {
1019+ params . set ( 'sel' , String ( selectedLine ) ) ;
1020+ } else if ( clearSel ) {
1021+ params . delete ( 'sel' ) ;
1022+ }
1023+ writeHashParams ( params ) ;
1024+ }
1025+
1026+ function applySelectionFromHash ( ) {
1027+ if ( ! state . events . length ) return ;
1028+ const params = readHashParams ( ) ;
1029+ const raw = params . get ( 'sel' ) ;
1030+ if ( ! raw ) return ;
1031+ const line = Number ( raw ) ;
1032+ if ( ! Number . isFinite ( line ) ) return ;
1033+ const found = state . events . find ( e => e . line === line ) ;
1034+ if ( ! found ) return ;
1035+ if ( state . selectedId === found . id ) return ;
1036+ suppressUrlStateSync = true ;
1037+ try {
1038+ selectEvent ( found . id , { scroll : true } ) ;
1039+ } finally {
1040+ suppressUrlStateSync = false ;
1041+ }
1042+ }
1043+
1044+ function applyControlsFromHash ( ) {
1045+ const params = readHashParams ( ) ;
1046+ suppressUrlStateSync = true ;
1047+ try {
1048+ const q = params . get ( 'q' ) ;
1049+ if ( q !== null ) {
1050+ els . search . value = q ;
1051+ state . filters . query = q . trim ( ) . toLowerCase ( ) ;
1052+ }
1053+
1054+ const type = params . get ( 'type' ) ;
1055+ if ( type !== null ) state . filters . type = type ;
1056+ const payload = params . get ( 'payload' ) ;
1057+ if ( payload !== null ) state . filters . payload = payload ;
1058+ const role = params . get ( 'role' ) ;
1059+ if ( role !== null ) state . filters . role = role ;
1060+
1061+ const hide = params . get ( 'hide' ) ;
1062+ if ( hide !== null ) {
1063+ els . hideTokenCount . checked = hide === '1' || hide === 'true' ;
1064+ state . filters . hideTokenCount = els . hideTokenCount . checked ;
1065+ }
1066+
1067+ const truncate = params . get ( 'truncate' ) ;
1068+ if ( truncate !== null ) {
1069+ els . truncateStrings . checked = truncate === '1' || truncate === 'true' ;
1070+ }
1071+
1072+ const tz = params . get ( 'tz' ) ;
1073+ if ( tz === 'utc' || tz === 'local' ) {
1074+ state . tzMode = tz ;
1075+ els . tzLocal . classList . toggle ( 'active' , tz === 'local' ) ;
1076+ els . tzUtc . classList . toggle ( 'active' , tz === 'utc' ) ;
1077+ }
1078+ } finally {
1079+ suppressUrlStateSync = false ;
1080+ }
9771081 }
9781082
9791083 function applyFilters ( ) {
@@ -1011,7 +1115,7 @@ <h1>Codex Timeline Viewer</h1>
10111115 renderTimeline ( ) ;
10121116 }
10131117
1014- function clearSelection ( ) {
1118+ function clearSelection ( { syncHash = true } = { } ) {
10151119 state . selectedId = null ;
10161120 state . selected = null ;
10171121 els . detailTitle . textContent = 'Select an event' ;
@@ -1023,6 +1127,7 @@ <h1>Codex Timeline Viewer</h1>
10231127 for ( const node of els . timeline . querySelectorAll ( '.event.selected' ) ) {
10241128 node . classList . remove ( 'selected' ) ;
10251129 }
1130+ if ( syncHash ) syncUrlHashFromControls ( { clearSel : true } ) ;
10261131 }
10271132
10281133 function renderTimeline ( ) {
@@ -1106,7 +1211,7 @@ <h1>Codex Timeline Viewer</h1>
11061211 row . appendChild ( time ) ;
11071212 row . appendChild ( body ) ;
11081213
1109- row . addEventListener ( 'click' , ( ) => selectEvent ( e . id ) ) ;
1214+ row . addEventListener ( 'click' , ( ) => selectEvent ( e . id , { focusTimeline : true } ) ) ;
11101215 fragment . appendChild ( row ) ;
11111216
11121217 if ( e . date instanceof Date && ! Number . isNaN ( e . date . getTime ( ) ) ) prevDate = e . date ;
@@ -1119,16 +1224,20 @@ <h1>Codex Timeline Viewer</h1>
11191224 if ( ! stillThere ) clearSelection ( ) ;
11201225 else selectEvent ( state . selectedId , { scroll : false } ) ;
11211226 } else {
1122- clearSelection ( ) ;
1227+ clearSelection ( { syncHash : false } ) ;
11231228 }
11241229 }
11251230
1126- function selectEvent ( id , { scroll = false } = { } ) {
1231+ function selectEvent ( id , { scroll = false , focusTimeline = false } = { } ) {
11271232 const e = state . filtered . find ( item => item . id === id ) || state . events . find ( item => item . id === id ) ;
11281233 if ( ! e ) return ;
11291234 state . selectedId = id ;
11301235 state . selected = e ;
11311236
1237+ if ( focusTimeline ) {
1238+ els . timeline . focus ( { preventScroll : true } ) ;
1239+ }
1240+
11321241 for ( const node of els . timeline . querySelectorAll ( '.event.selected' ) ) {
11331242 node . classList . remove ( 'selected' ) ;
11341243 }
@@ -1152,6 +1261,7 @@ <h1>Codex Timeline Viewer</h1>
11521261 els . detailJson . textContent = stringifyJson ( e . obj , shouldTruncate ) || '' ;
11531262 els . copyJson . disabled = false ;
11541263 els . copyRaw . disabled = false ;
1264+ syncUrlHashFromControls ( ) ;
11551265 }
11561266
11571267 function extractImageUrls ( obj ) {
@@ -1215,6 +1325,25 @@ <h1>Codex Timeline Viewer</h1>
12151325 els . detailImages . hidden = false ;
12161326 }
12171327
1328+ function isEditableElement ( el ) {
1329+ if ( ! ( el instanceof HTMLElement ) ) return false ;
1330+ if ( el . isContentEditable ) return true ;
1331+ const tag = el . tagName ;
1332+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' ;
1333+ }
1334+
1335+ function moveSelection ( delta ) {
1336+ if ( ! state . filtered . length ) return ;
1337+ const idx = state . filtered . findIndex ( e => e . id === state . selectedId ) ;
1338+ if ( idx === - 1 ) {
1339+ selectEvent ( state . filtered [ 0 ] . id , { scroll : true } ) ;
1340+ return ;
1341+ }
1342+ const nextIdx = Math . max ( 0 , Math . min ( state . filtered . length - 1 , idx + delta ) ) ;
1343+ if ( nextIdx === idx ) return ;
1344+ selectEvent ( state . filtered [ nextIdx ] . id , { scroll : true } ) ;
1345+ }
1346+
12181347 async function copyText ( text ) {
12191348 if ( typeof text !== 'string' ) return ;
12201349 try {
@@ -1239,6 +1368,7 @@ <h1>Codex Timeline Viewer</h1>
12391368 els . tzUtc . classList . toggle ( 'active' , mode === 'utc' ) ;
12401369 applyFilters ( ) ;
12411370 if ( state . selectedId !== null ) selectEvent ( state . selectedId ) ;
1371+ syncUrlHashFromControls ( ) ;
12421372 }
12431373
12441374 els . imageModalClose . addEventListener ( 'click' , closeImageModal ) ;
@@ -1249,6 +1379,17 @@ <h1>Codex Timeline Viewer</h1>
12491379 if ( event . key === 'Escape' ) closeImageModal ( ) ;
12501380 } ) ;
12511381
1382+ document . addEventListener ( 'keydown' , ( event ) => {
1383+ if ( els . imageModal . classList . contains ( 'open' ) ) return ;
1384+ if ( event . altKey || event . ctrlKey || event . metaKey || event . shiftKey ) return ;
1385+ if ( event . key !== 'ArrowUp' && event . key !== 'ArrowDown' ) return ;
1386+ if ( isEditableElement ( document . activeElement ) ) return ;
1387+ if ( document . activeElement !== els . timeline && ! els . timeline . contains ( document . activeElement ) ) return ;
1388+ if ( state . selectedId === null ) return ;
1389+ event . preventDefault ( ) ;
1390+ moveSelection ( event . key === 'ArrowUp' ? - 1 : 1 ) ;
1391+ } ) ;
1392+
12521393 function setTab ( tabName ) {
12531394 for ( const tab of document . querySelectorAll ( '.tab' ) ) {
12541395 const active = tab . dataset . tab === tabName ;
@@ -1289,7 +1430,7 @@ <h1>Codex Timeline Viewer</h1>
12891430 const started = performance . now ( ) ;
12901431 state . sourceLabel = sourceLabel || null ;
12911432 setStatus ( `Parsing ${ sourceLabel || 'content' } …` , 'warn' ) ;
1292- clearSelection ( ) ;
1433+ clearSelection ( { syncHash : false } ) ;
12931434
12941435 const lines = text . split ( / \r ? \n / ) ;
12951436 const events = [ ] ;
@@ -1329,6 +1470,8 @@ <h1>Codex Timeline Viewer</h1>
13291470 state . events = events ;
13301471 updateFilterOptions ( events ) ;
13311472 applyFilters ( ) ;
1473+ applySelectionFromHash ( ) ;
1474+ syncUrlHashFromControls ( ) ;
13321475
13331476 const durationMs = Math . round ( performance . now ( ) - started ) ;
13341477 if ( errors . length ) {
@@ -1399,25 +1542,31 @@ <h1>Codex Timeline Viewer</h1>
13991542 els . search . addEventListener ( 'input' , ( ) => {
14001543 state . filters . query = els . search . value . trim ( ) . toLowerCase ( ) ;
14011544 applyFilters ( ) ;
1545+ syncUrlHashFromControls ( ) ;
14021546 } ) ;
14031547 els . typeFilter . addEventListener ( 'change' , ( ) => {
14041548 state . filters . type = els . typeFilter . value ;
14051549 applyFilters ( ) ;
1550+ syncUrlHashFromControls ( ) ;
14061551 } ) ;
14071552 els . payloadFilter . addEventListener ( 'change' , ( ) => {
14081553 state . filters . payload = els . payloadFilter . value ;
14091554 applyFilters ( ) ;
1555+ syncUrlHashFromControls ( ) ;
14101556 } ) ;
14111557 els . roleFilter . addEventListener ( 'change' , ( ) => {
14121558 state . filters . role = els . roleFilter . value ;
14131559 applyFilters ( ) ;
1560+ syncUrlHashFromControls ( ) ;
14141561 } ) ;
14151562 els . hideTokenCount . addEventListener ( 'change' , ( ) => {
14161563 state . filters . hideTokenCount = els . hideTokenCount . checked ;
14171564 applyFilters ( ) ;
1565+ syncUrlHashFromControls ( ) ;
14181566 } ) ;
14191567 els . truncateStrings . addEventListener ( 'change' , ( ) => {
14201568 if ( state . selectedId !== null ) selectEvent ( state . selectedId ) ;
1569+ syncUrlHashFromControls ( ) ;
14211570 } ) ;
14221571
14231572 els . clearFilters . addEventListener ( 'click' , ( ) => {
@@ -1430,6 +1579,7 @@ <h1>Codex Timeline Viewer</h1>
14301579 els . hideTokenCount . checked = true ;
14311580 updateFilterOptions ( state . events ) ;
14321581 applyFilters ( ) ;
1582+ syncUrlHashFromControls ( ) ;
14331583 } ) ;
14341584
14351585 els . copyJson . addEventListener ( 'click' , ( ) => {
@@ -1450,7 +1600,15 @@ <h1>Codex Timeline Viewer</h1>
14501600 }
14511601
14521602 // Initialize select options for the empty state.
1603+ applyControlsFromHash ( ) ;
14531604 updateFilterOptions ( [ ] ) ;
1605+ syncUrlHashFromControls ( ) ;
1606+ window . addEventListener ( 'hashchange' , ( ) => {
1607+ applyControlsFromHash ( ) ;
1608+ updateFilterOptions ( state . events ) ;
1609+ applyFilters ( ) ;
1610+ applySelectionFromHash ( ) ;
1611+ } ) ;
14541612 void initFromQuery ( ) ;
14551613 </ script >
14561614</ body >
0 commit comments