Skip to content

File tree

1 file changed

+164
-6
lines changed

1 file changed

+164
-6
lines changed

codex-timeline.html

Lines changed: 164 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,12 @@
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

Comments
 (0)