// Redmine History Timeline Navigator — Quick Jump (v1.10.5)
// - 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 / CONFIG.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
// - Highlight also styles the tab header, TITLE for "long notes"
// - Lazy Load override with progress indicator (OVERRIDE_DEFAULT_LAZY_LOAD / LOAD_*)
// - Loading indicator is embedded in header (no layout jump)
(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,
// Lazy-load policy
// false = do nothing extra, respect Redmine's lazy load (default, unchanged behavior)
// true = QuickJump will trigger loading more history in steps + show indicator
OVERRIDE_DEFAULT_LAZY_LOAD: false,
// How aggressively we click "show more" in steps
// (per run of loadAllLazyHistory; set higher if you have LOTS of journals)
LOAD_ALL_MAX_STEPS: 80,
LOAD_ALL_STEP_DELAY_MS: 400,
// 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 ',
LOAD_LABEL: 'Reading history…',
LOAD_DONE: 'History reading done.'
}
};
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;
// Clean up previous instance if present
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) {
// Remove any previous instances of our elements
[PANEL_ID, STYLE_ID, LAUNCH_ID, HOVERZONE_ID, PREVIEW_ID, HINT_ID].forEach(id => {
const el = document.getElementById(id);
if (el) el.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);
// ----------------------------------------------------
// Lazy-scroll mot note (tar hänsyn till lazy load)
// ----------------------------------------------------
const LAZY_SCROLL_CHECK_DELAY_MS = 1000; // ~1 sekund mellan varje koll
const LAZY_SCROLL_MAX_ATTEMPTS = 15; // max antal försök (~15 sek totalt)
let lazyScrollJob = null;
function cancelLazyScroll() {
if (lazyScrollJob && lazyScrollJob.timer) {
clearTimeout(lazyScrollJob.timer);
}
lazyScrollJob = null;
}
// Cancel auto-scroll if the user is actively scrolling (wheel/touch/scroll keys)
const userOverrideScroll = () => {
if (!lazyScrollJob) return;
cancelLazyScroll();
};
const userScrollKeyHandler = (e) => {
if (!lazyScrollJob) return;
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
const scrollKeys = ['PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' '];
if (scrollKeys.includes(e.key)) {
cancelLazyScroll();
}
};
window.addEventListener('wheel', userOverrideScroll, { passive: true });
window.addEventListener('touchmove', userOverrideScroll, { passive: true });
window.addEventListener('keydown', userScrollKeyHandler);
function isNoteInTargetViewport(el) {
if (!el) return false;
const rect = el.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
const targetTop = CONFIG.NOTE_TARGET_PAD;
const topOk = rect.top >= targetTop - 5 && rect.top <= targetTop + 5;
const bottomOk = rect.bottom <= vh - 8;
return topOk && bottomOk;
}
function scrollNoteTowardsTarget(el, behavior) {
if (!el) return;
const rect = el.getBoundingClientRect();
const targetTop = CONFIG.NOTE_TARGET_PAD;
const delta = rect.top - targetTop;
if (Math.abs(delta) < 3) return;
window.scrollTo({
top: window.scrollY + delta,
behavior: behavior || 'auto'
});
}
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 jumpTo(el) {
if (!el) return;
const journal = el.closest(JOURNAL_SEL);
if (journal && journal.style && journal.style.display === 'none') {
journal.style.display = '';
}
// Cancel any previous job
cancelLazyScroll();
const startIndex = indexOf(el);
// First “rough” scroll
scrollNoteTowardsTarget(el, 'smooth');
lazyScrollJob = {
el,
attempts: 0,
timer: null
};
const tick = () => {
if (!lazyScrollJob || lazyScrollJob.el !== el) return;
// Never force the user back if the panel is closed
if (!panel.classList.contains('open')) {
cancelLazyScroll();
return;
}
lazyScrollJob.attempts++;
if (isNoteInTargetViewport(el) || lazyScrollJob.attempts >= LAZY_SCROLL_MAX_ATTEMPTS) {
const idxNow = indexOf(el);
if (idxNow >= 0) {
setActiveIndex(idxNow);
} else if (startIndex >= 0) {
setActiveIndex(startIndex);
}
highlight(el);
cancelLazyScroll();
return;
}
// Redmine may have lazy-loaded more → adjust again
scrollNoteTowardsTarget(el, 'smooth');
lazyScrollJob.timer = setTimeout(tick, LAZY_SCROLL_CHECK_DELAY_MS);
};
lazyScrollJob.timer = setTimeout(tick, LAZY_SCROLL_CHECK_DELAY_MS);
}
// ----------------------------------------------------
// Lazy-load handling (show more / load all history)
// ----------------------------------------------------
function findLazyLoadTrigger() {
const selectors = [
'a.show_more',
'a[data-remote="true"].show-more',
'a[data-remote="true"].load-more-journals',
'a[href*="last_journal_id"]'
];
for (const sel of selectors) {
const link = historyRoot.querySelector(sel);
if (link && link.offsetParent !== null) {
return link;
}
}
return null;
}
let historyFullyLoaded = false;
let loadIndicator = null;
let loadProgress = null;
let loadLabelEl = null;
function setLoadIndicator(active, text) {
if (!loadIndicator) return;
if (text && loadProgress) {
loadProgress.textContent = text;
} else if (loadProgress && !text) {
loadProgress.textContent = '';
}
if (active) {
loadIndicator.classList.add('active');
} else {
loadIndicator.classList.remove('active');
// Rensa text när vi inte är aktiva för att inte visa "gammal" info
if (loadLabelEl) loadLabelEl.textContent = '';
if (loadProgress) loadProgress.textContent = '';
}
}
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 = [];
const indexOf = (el) => notes.findIndex(n => n.el === el);
let activeIndex = 0;
function loadAllLazyHistory(onDone) {
// If we should respect lazy load → do nothing extra
if (CONFIG.OVERRIDE_DEFAULT_LAZY_LOAD) {
if (onDone) onDone();
return;
}
if (historyFullyLoaded) {
if (onDone) onDone();
return;
}
const maxSteps = CONFIG.LOAD_ALL_MAX_STEPS || 80;
const delay = CONFIG.LOAD_ALL_STEP_DELAY_MS || 400;
let steps = 0;
let lastCount = collectNotes(excludeHidden, onlyNotes).length;
if (loadLabelEl) {
loadLabelEl.textContent = CONFIG.TEXT.LOAD_LABEL || 'Reading history…';
}
setLoadIndicator(true, `${lastCount} noter`);
const step = () => {
const link = findLazyLoadTrigger();
// CASE 1: no links left → we are truly done
if (!link) {
historyFullyLoaded = true;
rebuildAll();
if (loadLabelEl) {
loadLabelEl.textContent = CONFIG.TEXT.LOAD_DONE || 'History reading done..';
}
setTimeout(() => setLoadIndicator(false), 800);
if (onDone) onDone();
return;
}
// CASE 2: we reached the max steps but there is still a link
// → we stop, but do NOT mark historyFullyLoaded (a new run can continue)
if (steps >= maxSteps) {
console.warn('QuickJump: reached LOAD_ALL_MAX_STEPS, more history may remain. Increase CONFIG.LOAD_ALL_MAX_STEPS if needed.');
rebuildAll();
if (loadLabelEl) {
loadLabelEl.textContent = CONFIG.TEXT.LOAD_DONE || 'History reading done..';
}
setTimeout(() => setLoadIndicator(false), 800);
if (onDone) onDone();
return;
}
steps++;
try {
link.click();
} catch (e) {
console.warn('QuickJump: lazy-load click error', e);
// Don't know if everything is loaded → do NOT set historyFullyLoaded
rebuildAll();
if (loadLabelEl) {
loadLabelEl.textContent = CONFIG.TEXT.LOAD_DONE || 'History reading done..';
}
setTimeout(() => setLoadIndicator(false), 800);
if (onDone) onDone();
return;
}
setTimeout(() => {
const currCount = collectNotes(excludeHidden, onlyNotes).length;
const diff = currCount - lastCount;
lastCount = currCount;
if (loadProgress) {
// We don't know the total count, but we show how many notes we have so far
loadProgress.textContent = `${currCount} noter${diff > 0 ? ` (+${diff})` : ''}`;
}
step();
}, delay);
};
step();
}
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: flex-start;
justify-content: space-between;
padding: 6px 10px 4px 10px;
border-bottom: 1px solid rgba(0,0,0,0.08);
font-size: 0.95em;
}
#${PANEL_ID} .qj-hdr-main {
display: flex;
flex-direction: column;
gap: 1px;
}
#${PANEL_ID} .qj-title {
font-weight: 600;
line-height: 1.2;
}
#${PANEL_ID} .qj-load-indicator {
font-size: 0.5em;
opacity: 0;
transition: opacity .18s ease;
min-height: 0.6em;
}
#${PANEL_ID} .qj-load-indicator.active {
opacity: 0.85;
}
#${PANEL_ID} .qj-load-indicator .qj-load-progress {
margin-left: 4px;
font-variant-numeric: tabular-nums;
}
#${PANEL_ID} .qj-counter {
opacity: 0.8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
margin-top: 2px;
}
#${PANEL_ID} .qj-close {
font-size: 0.9em;
padding: 2px 6px;
cursor: pointer;
margin-top: 0;
}
#${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 = `