// Redmine History Timeline Navigator — Quick Jump (v1.9.11)
// - Panel docks left/right via CONFIG.SIDE
// - Separate bottom offsets for Quick Jump trigger vs. panel
// - Minimal custom styling to blend with page theme
// - Configurable pill border-radius (CONFIG.PILL_RADIUS)
// - Configurable highlight colors via CONFIG.HIGHLIGHT_COLOR / HIGHLIGHT_SOFT_COLOR
// - All UI text configurable via CONFIG.TEXT (except console output)
// - Configurable z-index base via CONFIG.ZINDEX_BASE (all layers offset from this)
// - Panel width/height fixed via CONFIG.PANEL_WIDTH / CONFIG.PANEL_HEIGHT
// - Preview opens to the opposite side of the panel (left when panel is on the right)
// - Panel & preview & slider thumb reuse nearest non-transparent background color
// - No visual highlight on first init (only internal mark)
// - Preview de-dup (no heading if missing/duplicated)
// - Pills: overflow-x:hidden + ellipsis, always stacked (no spreading)
// - "Only notes" hides journals with only .has-details (no .has-notes) and hidden journals
// - "Only jump to long notes" uses CONFIG.LONG_NOTES_CHARACTER_THRESHOLD
// - Slider & counter respect filters (including "no results" → 0/0)
// - No "bounce" when scrolling active pill into view
// - Fix highlight to also style the tab, added TITLE for "long notes"
(function () {
const CONFIG = {
PANEL_WIDTH: 280,
PANEL_HEIGHT: 380,
// 'left' | 'right'
SIDE: 'left',
// Horizontal offset from chosen side
SIDE_OFFSET: 6,
// Bottom offsets
BOTTOM_OFFSET_QUICK: 60, // Quick Jump button / hover zone
BOTTOM_OFFSET_PANEL: 65, // Panel
// Pill appearance
PILL_RADIUS: 999, // px, e.g. 999 for capsule, 6 for rounded
// Threshold for "long notes" (characters)
LONG_NOTES_CHARACTER_THRESHOLD: 500,
NOTE_TARGET_PAD: 80,
PREVIEW_MAX_CHARS: 220,
PREVIEW_IMG_MAX_W: 160,
PREVIEW_IMG_MAX_H: 100,
// Highlight colors
HIGHLIGHT_COLOR: 'rgba(37,99,235,0.85)',
HIGHLIGHT_SOFT_COLOR: 'rgba(37,99,235,0.6)',
HIGHLIGHT_FADE_MS: 700,
// Base z-index (all other layers are relative to this)
// hoverzone: ZINDEX_BASE
// launcher: ZINDEX_BASE + 1
// panel: ZINDEX_BASE + 2
// preview-popup: ZINDEX_BASE + 3
// hint-tooltip: ZINDEX_BASE + 4
ZINDEX_BASE: 9990,
// All user-visible text (except console)
TEXT: {
LAUNCHER_LABEL: 'Quick jump',
PANEL_TITLE: 'History timeline',
CLOSE_LABEL: 'Close',
CLOSE_TITLE: 'Close',
PREV_BUTTON: '⟨',
PREV_TITLE: 'Previous (p/k)',
NEXT_BUTTON: '⟩',
NEXT_TITLE: 'Next (n/j)',
EDGE_OLDEST: 'Oldest',
EDGE_NEWEST: 'Newest',
ONLY_NOTES_LABEL: 'Only notes',
ONLY_NOTES_TITLE: 'Checked = show only journals with notes (has-notes) and ignore hidden journals. Unchecked = include all entries.',
ONLY_LONG_LABEL: 'Only jump to long notes',
ONLY_LONG_TITLE: 'Checked = only jump to notes longer than {N} characters.',
FILTER_PLACEHOLDER: 'Filter: author, date or #',
PILLS_ARIA_LABEL: 'Quick jump',
HELP_HTML: 'Keyboard shortcuts:
n/j next, p/k previous, g go to #',
NO_RESULTS: 'No results match the filter.',
PROMPT_GOTO: 'Go to comment # (e.g. 10):',
ALERT_GOTO_NOT_FOUND_PREFIX: 'Could not find #',
HINT_JUMP_PREFIX: 'Jump to '
}
};
const HISTORY_SEL = '#tab-content-history';
// const NOTE_SEL = '.note[id^="note-"]'; // Redmine 6
const NOTE_SEL = 'div[id^="note-"]'; // older Redmine
const JOURNAL_SEL = '.journal';
const PANEL_ID = 'qj-history-timeline-panel';
const STYLE_ID = PANEL_ID + '-style';
const LAUNCH_ID = 'qj-history-launcher';
const HOVERZONE_ID = 'qj-history-hoverzone';
const PREVIEW_ID = 'qj-history-preview';
const HINT_ID = 'qj-history-hint';
const NOTE_HL_CLASS = 'qj-note-highlight';
const NOTE_HEADER_HL_CLASS = 'qj-note-header-highlight';
const HIGHLIGHT_RING = '0 0 0 2px ' + CONFIG.HIGHLIGHT_COLOR;
if (window.__QJ_HistoryTimeline?.destroy) {
try { window.__QJ_HistoryTimeline.destroy(); } catch (e) {}
}
function ready(fn) {
if (document.readyState === 'complete' || document.readyState === 'interactive') {
setTimeout(fn, 0);
} else {
document.addEventListener('DOMContentLoaded', fn, { once: true });
}
}
ready(bootstrap);
let retryTimer = null;
function bootstrap() {
const tryInit = () => {
const historyRoot = document.querySelector(HISTORY_SEL);
if (!historyRoot) return false;
init(historyRoot);
return true;
};
if (!tryInit()) {
let tries = 0;
retryTimer = setInterval(() => {
tries++;
if (tryInit() || tries > 50) { clearInterval(retryTimer); retryTimer = null; }
}, 200);
}
}
// Find closest non-transparent background color starting from a given element.
function findEffectiveBackgroundColor(rootEl) {
const isTransparent = (bg) => {
if (!bg) return true;
bg = bg.trim().toLowerCase();
if (bg === 'transparent') return true;
return /^rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\)$/.test(bg);
};
let el = rootEl;
while (el) {
const cs = window.getComputedStyle(el);
const bg = cs && cs.backgroundColor;
if (!isTransparent(bg)) return bg;
el = el.parentElement;
}
const htmlBg = window.getComputedStyle(document.documentElement).backgroundColor;
if (!isTransparent(htmlBg)) return htmlBg;
const bodyBg = window.getComputedStyle(document.body).backgroundColor;
if (!isTransparent(bodyBg)) return bodyBg;
return '#ffffff';
}
function init(historyRoot) {
[PANEL_ID, STYLE_ID, LAUNCH_ID, HOVERZONE_ID, PREVIEW_ID, HINT_ID].forEach(id => document.getElementById(id)?.remove());
document.querySelectorAll('.' + NOTE_HL_CLASS).forEach(el => el.classList.remove(NOTE_HL_CLASS));
const esc = (s) => (s || '').replace(/[&<>"]/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[m]));
const by = (sel, root = document) => root.querySelector(sel);
function jumpTo(el) {
if (!el) return;
const journal = el.closest(JOURNAL_SEL);
if (journal && journal.style && journal.style.display === 'none') journal.style.display = '';
const rect = el.getBoundingClientRect();
const y = window.scrollY + rect.top - CONFIG.NOTE_TARGET_PAD;
window.scrollTo({ top: Math.max(0, y), behavior: 'smooth' });
highlight(el);
setActiveIndex(indexOf(el));
}
function highlight(el, silent = false) {
// Remove any existing highlight classes
document.querySelectorAll('.' + NOTE_HL_CLASS).forEach(e => e.classList.remove(NOTE_HL_CLASS));
document.querySelectorAll('.' + NOTE_HEADER_HL_CLASS).forEach(h => h.classList.remove(NOTE_HEADER_HL_CLASS));
// Also clear any previous box-shadow so we don't stack effects
document.querySelectorAll(NOTE_SEL).forEach(e => {
e.style.boxShadow = 'none';
e.style.transition = '';
});
if (silent) return;
// Mark current note (for CSS hooks)
el.classList.add(NOTE_HL_CLASS);
// Add temporary highlight on the header "tab" of this note
const header = el.querySelector('h4');
if (header) {
header.classList.add(NOTE_HEADER_HL_CLASS);
}
// Add a box-shadow ring around the whole note and fade it out
el.style.transition = 'box-shadow 0.6s ease';
el.style.boxShadow = HIGHLIGHT_RING;
setTimeout(() => {
el.style.boxShadow = '0 0 0 0 transparent';
if (header) {
header.classList.remove(NOTE_HEADER_HL_CLASS);
}
}, CONFIG.HIGHLIGHT_FADE_MS);
}
function extractWhenFromHeader(header) {
if (!header) return '';
const links = Array.from(header.querySelectorAll('a[title]'));
const dateLike = links.find(a => /\d{4}-\d{2}-\d{2}/.test(a.getAttribute('title') || ''));
if (dateLike) return dateLike.getAttribute('title') || dateLike.textContent.trim();
const act = links.find(a => /activity\?from=/.test(a.getAttribute('href') || ''));
if (act) return act.getAttribute('title') || act.textContent.trim();
const last = links[links.length - 1];
return last ? (last.getAttribute('title') || last.textContent.trim()) : '';
}
function getSummaryAndBody(wikiEl) {
const firstHeading = wikiEl.querySelector('h1,h2,h3,h4,h5,h6');
const headingText = firstHeading?.innerText?.trim() || '';
let bodyText = (wikiEl.innerText || '').replace(/\s+/g, ' ').trim();
const norm = s => (s || '').toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ' ').trim();
if (headingText && norm(bodyText).startsWith(norm(headingText))) {
const idx = bodyText.toLowerCase().indexOf(headingText.toLowerCase());
if (idx === 0) bodyText = bodyText.slice(headingText.length).trim();
}
const showTitle = !!headingText && !norm(bodyText.slice(0, 100)).includes(norm(headingText));
const summary = showTitle ? headingText : '';
return { summary, bodyText };
}
const collectNotes = (excludeHiddenJournals, onlyNotesFlag) => {
const notes = Array.from(historyRoot.querySelectorAll(NOTE_SEL))
.filter(n => {
const j = n.closest(JOURNAL_SEL);
// Hide hidden journals if requested
if (excludeHiddenJournals && j && j.style && j.style.display === 'none') {
return false;
}
// If we only want "real notes":
// skip journals that are only .has-details and NOT .has-notes
if (onlyNotesFlag && j) {
const jc = j.classList;
if (jc.contains('has-details') && !jc.contains('has-notes')) {
return false;
}
}
return true;
})
.sort((a, b) => {
const an = parseInt(a.id.replace('note-', ''), 10);
const bn = parseInt(b.id.replace('note-', ''), 10);
if (!isNaN(an) && !isNaN(bn)) return an - bn;
return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
});
return notes.map((note, i) => {
const num = parseInt(note.id.replace('note-', ''), 10);
const journal = note.closest(JOURNAL_SEL);
const header = note.querySelector('.note-header') || journal?.querySelector('.note-header');
const author = header?.querySelector('a.user')?.textContent?.trim() || 'Unknown';
const linkTxt = journal?.querySelector('.journal-link')?.textContent?.trim() || ('#' + (isNaN(num) ? (i + 1) : num));
const when = extractWhenFromHeader(header);
const wiki = note.querySelector('.wiki') || note;
const { summary, bodyText } = getSummaryAndBody(wiki);
const imgSrc = (wiki.querySelector('img')?.getAttribute('src')) || null;
return { el: note, num, author, linkTxt, when, textLen: bodyText.length, imgSrc, summary, fullText: bodyText };
});
};
let excludeHidden = true;
let onlyNotes = true;
let notes = collectNotes(excludeHidden, onlyNotes);
if (!notes.length) { console.warn('QuickJump: No notes found.'); return; }
const indexOf = (el) => notes.findIndex(n => n.el === el);
let activeIndex = 0;
const bottomQuick = CONFIG.BOTTOM_OFFSET_QUICK ?? CONFIG.BOTTOM_OFFSET ?? 60;
const bottomPanel = CONFIG.BOTTOM_OFFSET_PANEL ?? CONFIG.BOTTOM_OFFSET ?? bottomQuick;
const sideOffset = CONFIG.SIDE_OFFSET ?? 6;
const side = (CONFIG.SIDE === 'right') ? 'right' : 'left';
const zBase = CONFIG.ZINDEX_BASE ?? 9990;
const zHover = zBase;
const zLaunch = zBase + 1;
const zPanel = zBase + 2;
const zPreview = zBase + 3;
const zHint = zBase + 4;
const css = document.createElement('style');
css.id = STYLE_ID;
css.textContent = `
:root {
--qj-width: ${CONFIG.PANEL_WIDTH}px;
--qj-height: ${CONFIG.PANEL_HEIGHT}px;
--qj-bottom-quick: ${bottomQuick}px;
--qj-bottom-panel: ${bottomPanel}px;
--qj-side-offset: ${sideOffset}px;
--qj-bg: #ffffff;
--qj-highlight: ${CONFIG.HIGHLIGHT_COLOR};
--qj-highlight-soft: ${CONFIG.HIGHLIGHT_SOFT_COLOR};
}
#${HOVERZONE_ID} {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: var(--qj-bottom-quick);
width: 360px;
height: 42px;
z-index: ${zHover};
}
#${LAUNCH_ID} {
position: fixed;
left: 50%;
transform: translateX(-50%);
bottom: calc(var(--qj-bottom-quick) - 10px);
z-index: ${zLaunch};
opacity: 0;
transition: opacity .2s ease, transform .2s ease;
cursor: pointer;
padding: 4px 10px;
font-size: 0.95em;
white-space: nowrap;
}
#${HOVERZONE_ID}:hover + #${LAUNCH_ID},
#${LAUNCH_ID}:hover {
opacity: 1;
transform: translateX(-50%) translateY(-2px);
}
#${PANEL_ID} {
position: fixed;
width: var(--qj-width);
height: var(--qj-height);
bottom: calc(var(--qj-bottom-panel) + 14px);
max-width: calc(100vw - 24px);
border: 1px solid rgba(0,0,0,0.12);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
overflow: hidden;
user-select: none;
z-index: ${zPanel};
opacity: 0;
pointer-events: none;
transition: transform .2s ease, opacity .2s ease;
background-color: var(--qj-bg);
display: flex;
flex-direction: column;
}
#${PANEL_ID}.side-left {
left: var(--qj-side-offset);
right: auto;
transform: translate(-120%, 0);
}
#${PANEL_ID}.side-right {
right: var(--qj-side-offset);
left: auto;
transform: translate(120%, 0);
}
#${PANEL_ID}.open {
transform: translate(0, 0);
opacity: 1;
pointer-events: auto;
}
#${PANEL_ID} .qj-hdr {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-bottom: 1px solid rgba(0,0,0,0.08);
font-size: 0.95em;
}
#${PANEL_ID} .qj-title {
font-weight: 600;
}
#${PANEL_ID} .qj-counter {
opacity: 0.8;
font-variant-numeric: tabular-nums;
}
#${PANEL_ID} .qj-close {
font-size: 0.9em;
padding: 2px 6px;
cursor: pointer;
}
#${PANEL_ID} .qj-controls {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 6px;
align-items: center;
padding: 6px 10px;
font-size: 0.95em;
}
#${PANEL_ID} .qj-btn {
cursor: pointer;
padding: 2px 6px;
font-size: 0.95em;
}
#${PANEL_ID} .qj-slider-wrap {
display: flex;
align-items: center;
gap: 6px;
}
#${PANEL_ID} .qj-edge {
font-size: 0.85em;
opacity: 0.8;
user-select: none;
}
#${PANEL_ID} .qj-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 16px;
background: transparent;
}
#${PANEL_ID} .qj-slider::-webkit-slider-runnable-track {
height: 3px;
background: rgba(0,0,0,0.12);
border-radius: 999px;
}
#${PANEL_ID} .qj-slider::-moz-range-track {
height: 3px;
background: rgba(0,0,0,0.12);
border-radius: 999px;
}
#${PANEL_ID} .qj-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.3);
margin-top: -5px;
background-color: var(--qj-bg);
}
#${PANEL_ID} .qj-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0,0,0,0.3);
background-color: var(--qj-bg);
}
#${PANEL_ID} .qj-toggles {
display: flex;
gap: 8px;
align-items: center;
padding: 0 10px 6px 10px;
flex-wrap: wrap;
font-size: 0.95em;
}
#${PANEL_ID} .qj-toggles label {
display: flex;
gap: 4px;
align-items: center;
}
#${PANEL_ID} .qj-filter {
flex: 1;
min-width: 120px;
padding: 2px 6px;
font-size: 0.95em;
}
#${PANEL_ID} .qj-pills {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-content: flex-start;
gap: 4px;
padding: 0 10px 8px 10px;
overflow-y: auto;
overflow-x: hidden;
flex: 1 1 auto;
}
#${PANEL_ID} .qj-pill {
font-size: 0.9em;
padding: 2px 8px;
border-radius: ${CONFIG.PILL_RADIUS}px;
cursor: pointer;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 1px solid rgba(0,0,0,0.18);
}
#${PANEL_ID} .qj-pill.active {
outline: 1px solid var(--qj-highlight-soft);
outline-offset: 0;
}
#${PANEL_ID} .qj-help {
font-size: 0.85em;
opacity: 0.8;
padding: 0 10px 8px 10px;
}
.${NOTE_HL_CLASS} { border-radius: inherit !important; }
.${NOTE_HEADER_HL_CLASS}::before {
border-right-color: var(--qj-highlight) !important;
}
#${PREVIEW_ID} {
position: fixed;
z-index: ${zPreview};
pointer-events: none;
max-width: 340px;
border: 1px solid rgba(0,0,0,0.12);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.18);
padding: 6px;
font-size: 0.85em;
display: none;
background-color: var(--qj-bg);
}
#${PREVIEW_ID} .ttl { font-weight: 600; margin-bottom: 4px; }
#${PREVIEW_ID} .txt { line-height: 1.3; }
#${PREVIEW_ID} .row {
display: flex;
gap: 6px;
align-items: flex-start;
}
#${PREVIEW_ID} img {
display: block;
max-width: ${CONFIG.PREVIEW_IMG_MAX_W}px;
max-height: ${CONFIG.PREVIEW_IMG_MAX_H}px;
border-radius: 4px;
border: 1px solid rgba(0,0,0,0.1);
}
#${HINT_ID} {
position: fixed;
z-index: ${zHint};
pointer-events: none;
background: #111;
color: #fff;
border-radius: 4px;
padding: 2px 6px;
font-size: 0.8em;
box-shadow: 0 6px 18px rgba(0,0,0,0.2);
display: none;
white-space: nowrap;
}
`;
document.head.appendChild(css);
const hoverZone = document.createElement('div'); hoverZone.id = HOVERZONE_ID;
const launcher = document.createElement('button'); launcher.id = LAUNCH_ID; launcher.textContent = CONFIG.TEXT.LAUNCHER_LABEL;
document.body.appendChild(hoverZone); document.body.appendChild(launcher);
const panel = document.createElement('div');
panel.id = PANEL_ID;
panel.className = (side === 'right') ? 'side-right' : 'side-left';
const longTitle = (CONFIG.TEXT.ONLY_LONG_TITLE || '').replace('{N}', CONFIG.LONG_NOTES_CHARACTER_THRESHOLD);
panel.innerHTML = `