Index: src/default.css ================================================================== --- src/default.css +++ src/default.css @@ -51,10 +51,24 @@ } tr.timelineCurrent td { border-radius: 0; border-width: 0; } +.timelineFocused, +.timelineFocused a { + color: #fff1a8 !important; + background-color: #3a6ea5 !important; +} +.timelineFocused a:link, +.timelineFocused a:focus, +.timelineFocused a:active, +.timelineFocused a:visited { + text-decoration: underline; +} +.timelineFocused a:hover { + text-decoration: none; +} span.timelineLeaf { font-weight: bold; } span.timelineHistDsp { font-weight: bold; Index: src/graph.js ================================================================== --- src/graph.js +++ src/graph.js @@ -811,5 +811,413 @@ var txJson = dataObj.textContent || dataObj.innerText; var tx = JSON.parse(txJson); TimelineGraph(tx); } }()); + +/* +** Timeline keyboard navigation shortcuts: +** +** ### NOTE: The keyboard shortcuts are listed in the /timeline help screen. ### +** +** When navigating to a page with a timeline display, such as /timeline, /info, +** or /finfo, keyboard navigation mode needs to be "activated" first, i.e. if no +** timeline entry is focused yet, pressing any of the listed keys (except ESC) +** sets the visual focus indicator to the highlighted or current (check-out) +** entry if available, or to the topmost entry otherwise. A session cookie[0] is +** used to direct pages loaded in the future to enable keyboard navigation mode +** and automatically set the focus indicator to the highlighted, current, or +** topmost entry. Pressing N and M on the /timeline page while the topmost or +** bottommost entry is focused loads the next or previous page if available, +** similar to the [↑ More] and [↓ More] links. Pressing ESC disables keyboard +** navigation, i.e. removes the focus indicator and deletes the session cookie. +** When navigating backwards or forwards in browser history, the focused entry +** is restored using a hidden[1] input field. +** +** [0]: The lifetime and values of cookies can be tracked on the /cookies page. +** A session cookie is preferred over other storage APIs because Fossil already +** requires cookies to be enabled for reasonable functionality, and it's more +** likely that other storage APIs are blocked by users for privacy reasons, for +** example. +** [1]: This feature only works with a normal (text) input field hidden by CSS +** styles, instead of a true hidden (by type) input field, but according to MDN, +** screen readers should ignore it even without an aria-hidden="true" attribute +** (which is even discouraged for hidden by CSS elements). Also, this feature +** breaks if disabled[=true] or tabindex="-1" attributes are added to the input +** field, or (in FF) if page unload handlers are present. +** +** Ideas and TODOs: +** +** o kTMLN: ensure the correct page is opened when used from /finfo (it seems +** the tooltip also gets this "wrong", but maybe that's acceptable, because +** in order to be able to construct /file URLs, the information provided by +** the timeline-data-N blocks would have to be extended). +** o kFRST, kLAST: check if the previous/next page should be opened if focus is +** already at the top/bottom. UPDATE: the current behavior seems to be "more +** predictable", i.e. these shortcuts reliably focus the top/bottom item. +** o Shortcut(s) to (re)load /timeline with different View Style or Entry Limit +** by appending query parameters `&ss={ViewStyle}&n={EntryLimit±N}&udc=1' to +** the URL; alternatively set keyboard focus to the "View Style" field. +** o Auto-expand the hidden details (hash, user, tags) for focused entries in +** Compact View (by inheritance via CSS class `.timelineFocused'). +*/ +(function(){ + window.addEventListener('load',function(){ +// "Primary" (1) and "secondary" (2) selections swapped compared to CSS classes: +// (1) .timelineSecondary → +// /vdiff?to=, /timeline?sel2= +// (2) .timelineSelected:not(.timelineSecondary) → +// /vdiff?from=, /timeline?c=, /timeline?sel1= + function focusDefaultId(){ + var tn = document.querySelector('.timelineSecondary .tl-nodemark') + || document.querySelector( + '.timelineSelected:not(.timelineSecondary) .tl-nodemark') + || document.querySelector('.timelineCurrent .tl-nodemark'); + return tn ? tn.id : 'm1'; + } + function focusSelectedId(){ + var tn = document.querySelector('.timelineSecondary .tl-nodemark'); + return tn ? tn.id : null; + } + function focus2ndSelectedId(){ + var tn = document.querySelector( + '.timelineSelected:not(.timelineSecondary) .tl-nodemark'); + return tn ? tn.id : null; + } + function focusCurrentId(){ + var tn = document.querySelector('.timelineCurrent .tl-nodemark'); + return tn ? tn.id : null; + } + function focusTickedId(){ + var nd = document.querySelector('.tl-node.sel'); + return nd ? 'm' + nd.id.slice(3) : null; + } + function focusFirstId(id){ + return 'm1'; + } + function focusLastId(id){ + var el = document.getElementsByClassName('tl-nodemark'); + var tn = el ? el[el.length-1] : null; + return tn ? tn.id : id; + } + function focusNextId(id,dx){ + if( dx<-1 ) return focusFirstId(id); + if( dx>+1 ) return focusLastId(id); + var m = /^m(\d+)$/.exec(id); + return m!==null ? 'm' + (parseInt(m[1]) + dx) : null; + } + // NOTE: This code relies on `getElementsByClassName()' returning elements + // in document order; however, attempts to use binary searching (using the + // signed distance to the vertical center) or 2-pass searching (to get the + // 1000-block first) didn't yield any significant speedups -- probably the + // calls to `getBoundingClientRect()' are cached because `initGraph()' has + // already done so, or because they depend on their previous siblings, and + // so `e(N).getBoundingClientRect()' ≈ `e(0..N).getBoundingClientRect()'? + function focusViewportCenterId(){ + var + fe = null, + fd = 0, + wt = 0, + wb = wt + window.innerHeight, + wc = wt + window.innerHeight/2, + el = document.getElementsByClassName('timelineGraph'); + for( var i=0; i=wt && et<=wb) || (eb>=wt && eb<=wb) ){ + var ed = Math.min(Math.abs(et-wc),Math.abs(eb-wc)); + if( !fe || ed1 ){ + var m = 0; + for( var i=0; i0 ? 'prev' : 'next' )); + if( btn ) btn.click(); + return; + } + } + else if ( !id ) id = focusDefaultId(); + focusCacheSet(id); + focusVisualize(id,true); + }/*,true*/); + // NOTE: To be able to override the default CTRL+CLICK behavior (to "select" + // the elements, indicated by an outline) for normal (non-input) elements in + // FF it's necessary to listen to `mousedown' instead of `click' events. + window.addEventListener('mousedown',function(evt){ + var + bMAIN = 0, + mCTRL = 1<<14, + mod = evt.altKey<<15|evt.ctrlKey<<14|evt.shiftKey<<13|evt.metaKey<<12; + if( evt.target.tagName in { 'INPUT':1, 'SELECT':1, 'A':1 } || + evt.button!=bMAIN || mod!=mCTRL ){ + return; + } + var e = evt.target; + while( e && !/\btimeline\w+Cell\b/.test(e.className) ){ + e = e.parentElement; + } + if( e && (e = e.previousElementSibling.querySelector('.tl-nodemark')) ){ + evt.preventDefault(); + evt.stopPropagation(); + focusCacheSet(e.id); + focusVisualize(e.id,false); + } + },false); + window.addEventListener('pageshow',function(evt){ + var id = focusCacheGet(); + if( !id || !focusVisualize(id,false) ){ + if( focusCookieQuery() ){ + id = focusDefaultId(); + focusCacheSet(id); + focusVisualize(id,false); + } + } + },false); + },false); +}()); Index: src/timeline.c ================================================================== --- src/timeline.c +++ src/timeline.c @@ -1665,10 +1665,32 @@ ** which one(s) is/are applied is unspecified and may change between ** fossil versions. ** ** CHECKIN or TIMEORTAG can be a check-in hash prefix, or a tag, or the ** name of a branch. +** +** Keyboard navigation shortcuts: +** +** N Focus first (newest) entry. +** n Focus next (newer) entry, or open next page. +** m Focus previous (older) entry, or open previous page. +** M Focus last (oldest) entry. +** j Move focus between selected, current (check-out) and ticked entries. +** , Focus entry closest to center of viewport. +** Ctrl+Click +** Focus clicked entry. +** J Scroll to focused entry. +** h Tick/untick focused entry, or open diff if two entries ticked. +** H Untick all entries. +** b Copy commit hash of focused entry to clipboard. +** B Copy branch name of focused entry to clipboard. +** l View timeline of focused entry. +** L View timeline of focused entry filtered by branch. +** k View details of focused entry. +** g View default timeline. +** G View timeline around current (check-out) entry. +** ESC Disable keyboard navigation mode. */ void page_timeline(void){ Stmt q; /* Query used to generate the timeline */ Blob sql; /* text of SQL used to generate timeline */ Blob desc; /* Description of the timeline */ @@ -2876,19 +2898,19 @@ if( zError ){ @

%h(zError)

} if( zNewerButton ){ - @ %z(chref("button","%s",zNewerButton))%h(zNewerButtonLabel)\ + @ %z(chref("button tl-button-next","%s",zNewerButton))%h(zNewerButtonLabel)\ @  ↑ } cgi_check_for_malice(); www_print_timeline(&q, tmFlags, zThisUser, zThisTag, zBrName, selectedRid, secondaryRid, 0); db_finalize(&q); if( zOlderButton ){ - @ %z(chref("button","%s",zOlderButton))%h(zOlderButtonLabel)\ + @ %z(chref("button tl-button-prev","%s",zOlderButton))%h(zOlderButtonLabel)\ @  ↓ } document_emit_js(/*handles pikchrs rendered above*/); style_finish_page(); }